refactor: applied linter and prettier

This commit is contained in:
Vali98
2025-04-11 23:48:21 +08:00
parent 6c6ba12694
commit 785e4a4c30
129 changed files with 5653 additions and 6097 deletions

View File

@@ -16,7 +16,7 @@ module.exports = {
'@typescript-eslint/no-unused-vars': 'off',
'react/prop-types': 'off',
'@typescript-eslint/no-explicit-any': 'error', // Disallow usage of any
'@typescript-eslint/explicit-module-boundary-types': 'error', // Ensure types are explicitly declared
'no-mixed-spaces-and-tabs': 'off', // refer https://github.com/prettier/prettier/issues/4199
semi: ['error', 'never'],
},
settings: {

View File

@@ -11,4 +11,4 @@ module.exports = {
arrowParens: 'always',
endOfLine: 'lf',
trailingComma: 'all',
};
}

View File

@@ -1,170 +1,162 @@
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";
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'
export default class Client {
static #instance: Client;
static #instance: Client
private api : Api | undefined = undefined;
private user : JellifyUser | undefined = undefined;
private server : JellifyServer | undefined = undefined;
private library : JellifyLibrary | undefined = undefined;
private sessionId : string = uuid.v4();
private api: Api | undefined = undefined
private user: JellifyUser | undefined = undefined
private server: JellifyServer | undefined = undefined
private library: JellifyLibrary | undefined = undefined
private sessionId: string = uuid.v4()
private constructor(
api?: Api | undefined,
user?: JellifyUser | undefined,
server?: JellifyServer | undefined,
library?: JellifyLibrary | undefined
) {
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)
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)
else this.user = undefined
if (user)
this.setAndPersistUser(user)
else if (userJson)
this.user = JSON.parse(userJson)
else
this.user = undefined;
if (server)
this.setAndPersistServer(server)
else if (serverJson)
this.server = JSON.parse(serverJson);
else
this.server = undefined;
if (library)
this.setAndPersistLibrary(library)
else if (libraryJson)
this.library = JSON.parse(libraryJson)
else
this.library = undefined;
if (server) this.setAndPersistServer(server)
else if (serverJson) this.server = JSON.parse(serverJson)
else this.server = undefined
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);
else
this.api = undefined;
}
if (library) this.setAndPersistLibrary(library)
else if (libraryJson) this.library = JSON.parse(libraryJson)
else this.library = undefined
public static get instance(): Client {
if (!Client.#instance) {
Client.#instance = new Client();
}
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,
)
else this.api = undefined
}
return Client.#instance;
}
public static get instance(): Client {
if (!Client.#instance) {
Client.#instance = new Client()
}
public static get api(): Api | undefined {
return Client.#instance.api;
}
return Client.#instance
}
public static get server(): JellifyServer | undefined {
return Client.#instance.server;
}
public static get api(): Api | undefined {
return Client.#instance.api
}
public static get user(): JellifyUser | undefined {
return Client.#instance.user;
}
public static get server(): JellifyServer | undefined {
return Client.#instance.server
}
public static get library(): JellifyLibrary | undefined {
return Client.#instance.library;
}
public static get user(): JellifyUser | undefined {
return Client.#instance.user
}
public static get sessionId(): string {
return Client.#instance.sessionId;
}
public static get library(): JellifyLibrary | undefined {
return Client.#instance.library
}
public static signOut(): void {
Client.#instance.removeCredentials()
}
public static get sessionId(): string {
return Client.#instance.sessionId
}
public static switchServer() : void {
Client.#instance.removeServer();
}
public static signOut(): void {
Client.#instance.removeCredentials()
}
public static switchUser(): void {
Client.#instance.removeUser();
}
public static switchServer(): void {
Client.#instance.removeServer()
}
public static setUser(user: JellifyUser): void {
Client.#instance.setAndPersistUser(user);
}
public static switchUser(): void {
Client.#instance.removeUser()
}
private setAndPersistUser(user: JellifyUser) {
this.user = user;
public static setUser(user: JellifyUser): void {
Client.#instance.setAndPersistUser(user)
}
// persist user details
storage.set(MMKVStorageKeys.User, JSON.stringify(user));
}
private setAndPersistUser(user: JellifyUser) {
this.user = user
private setAndPersistServer(server : JellifyServer) {
this.server = server;
// persist user details
storage.set(MMKVStorageKeys.User, JSON.stringify(user))
}
storage.set(MMKVStorageKeys.Server, JSON.stringify(server));
}
private setAndPersistServer(server: JellifyServer) {
this.server = server
private setAndPersistLibrary(library : JellifyLibrary) {
this.library = library;
storage.set(MMKVStorageKeys.Server, JSON.stringify(server))
}
storage.set(MMKVStorageKeys.Library, JSON.stringify(library))
}
private setAndPersistLibrary(library: JellifyLibrary) {
this.library = library
private removeCredentials() {
this.library = undefined;
this.server = undefined;
this.user = undefined;
storage.set(MMKVStorageKeys.Library, JSON.stringify(library))
}
storage.delete(MMKVStorageKeys.Server)
storage.delete(MMKVStorageKeys.Library)
storage.delete(MMKVStorageKeys.User)
}
private removeCredentials() {
this.library = undefined
this.server = undefined
this.user = undefined
private removeServer() {
this.server = undefined;
storage.delete(MMKVStorageKeys.Server)
storage.delete(MMKVStorageKeys.Library)
storage.delete(MMKVStorageKeys.User)
}
storage.delete(MMKVStorageKeys.Server)
}
private removeServer() {
this.server = undefined
private removeUser() {
this.user = undefined;
storage.delete(MMKVStorageKeys.Server)
}
storage.delete(MMKVStorageKeys.User)
}
private removeUser() {
this.user = undefined
/**
* 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);
storage.delete(MMKVStorageKeys.User)
}
Client.#instance = new Client(api, undefined, server, undefined)
}
/**
* 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)
/**
*
* @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, undefined, server, undefined)
}
Client.#instance = new Client(api, user, 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)
public static setLibrary(library : JellifyLibrary) : void {
Client.#instance = new Client(api, user, server, undefined)
}
Client.#instance = new Client(undefined, undefined, undefined, library);
}
}
public static setLibrary(library: JellifyLibrary): void {
Client.#instance = new Client(undefined, undefined, undefined, library)
}
}

View File

@@ -1,18 +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";
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()
}
});
clientInfo: {
name: capitalize(name),
version: version,
},
deviceInfo: {
name: getModel(),
id: getUniqueIdSync(),
},
})

View File

@@ -1,39 +1,38 @@
import Client from "../../../api/client";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { isUndefined } from "lodash";
import Client from '../../../api/client'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { isUndefined } from 'lodash'
/**
* Manually marks an item as played.
* This should only be used for non-tracks,
* as those playbacks will be handled by the server
*
*
* This is mainly used for marking playlists
* and albums as played, so we can use Jellyfin
* to fetch recent ones later on
*
*
* @param item The item to mark as played
*/
export async function markItemPlayed(item: BaseItemDto) : Promise<void> {
return new Promise((resolve, reject) => {
export async function markItemPlayed(item: BaseItemDto): Promise<void> {
return new Promise((resolve, reject) => {
if (isUndefined(Client.api) || isUndefined(Client.user))
return reject('Client instance not set')
if (isUndefined(Client.api) || isUndefined(Client.user))
return reject("Client instance not set")
getItemsApi(Client.api)
.updateItemUserData({
itemId: item.Id!,
userId: Client.user.id,
updateUserItemDataDto: {
LastPlayedDate: new Date().getUTCDate().toLocaleString(),
Played: true,
}
})
.then(({ data }) => {
resolve()
})
.catch((error) => {
reject(error);
});
});
}
getItemsApi(Client.api)
.updateItemUserData({
itemId: item.Id!,
userId: Client.user.id,
updateUserItemDataDto: {
LastPlayedDate: new Date().getUTCDate().toLocaleString(),
Played: true,
},
})
.then(({ data }) => {
resolve()
})
.catch((error) => {
reject(error)
})
})
}

View File

@@ -1,86 +1,74 @@
import { BaseItemDto, MediaType } from "@jellyfin/sdk/lib/generated-client/models";
import Client from "../../../api/client";
import { getLibraryApi, getPlaylistsApi } from "@jellyfin/sdk/lib/utils/api";
import { BaseItemDto, MediaType } from '@jellyfin/sdk/lib/generated-client/models'
import Client from '../../../api/client'
import { getLibraryApi, getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api'
export async function addToPlaylist(track: BaseItemDto, playlist: BaseItemDto) {
console.debug('Adding track to playlist')
console.debug("Adding track to playlist");
return getPlaylistsApi(Client.api!)
.addItemToPlaylist({
ids: [
track.Id!
],
playlistId: playlist.Id!
})
return getPlaylistsApi(Client.api!).addItemToPlaylist({
ids: [track.Id!],
playlistId: playlist.Id!,
})
}
export async function removeFromPlaylist(track: BaseItemDto, playlist: BaseItemDto) {
console.debug("Removing track from playlist");
console.debug('Removing track from playlist')
return getPlaylistsApi(Client.api!)
.removeItemFromPlaylist({
playlistId: playlist.Id!,
entryIds: [
track.Id!
]
});
return getPlaylistsApi(Client.api!).removeItemFromPlaylist({
playlistId: playlist.Id!,
entryIds: [track.Id!],
})
}
export async function reorderPlaylist(playlistId: string, itemId: string, to: number) {
console.debug(`Moving track to index ${to}`)
console.debug(`Moving track to index ${to}`);
return getPlaylistsApi(Client.api!)
.moveItem({
playlistId,
itemId,
newIndex: to
});
return getPlaylistsApi(Client.api!).moveItem({
playlistId,
itemId,
newIndex: to,
})
}
export async function createPlaylist(name: string) {
console.debug("Creating new playlist...");
console.debug('Creating new playlist...')
return getPlaylistsApi(Client.api!)
.createPlaylist({
userId: Client.user!.id,
mediaType: MediaType.Audio,
createPlaylistDto: {
Name: name,
IsPublic: false,
MediaType: MediaType.Audio
}
});
return getPlaylistsApi(Client.api!).createPlaylist({
userId: Client.user!.id,
mediaType: MediaType.Audio,
createPlaylistDto: {
Name: name,
IsPublic: false,
MediaType: MediaType.Audio,
},
})
}
export async function deletePlaylist(playlistId: string) {
console.debug("Deleting playlist...");
console.debug('Deleting playlist...')
return getLibraryApi(Client.api!)
.deleteItem({
itemId: playlistId
})
return getLibraryApi(Client.api!).deleteItem({
itemId: playlistId,
})
}
/**
* Updates a Jellyfin playlist with the provided options.
*
*
* Right now this just supports renaming playlists, but this will change
* when it comes time for collaborative playlists
*
*
* @param playlistId The Jellyfin ID of the playlist to update
* @returns
* @returns
*/
export async function updatePlaylist(playlistId: string, name: string, trackIds: string[]) {
console.debug("Updating playlist");
console.debug('Updating playlist')
return getPlaylistsApi(Client.api!)
.updatePlaylist({
playlistId,
updatePlaylistDto: {
Name: name,
Ids: trackIds
}
});
}
return getPlaylistsApi(Client.api!).updatePlaylist({
playlistId,
updatePlaylistDto: {
Name: name,
Ids: trackIds,
},
})
}

View File

@@ -1,16 +1,21 @@
import { useQuery } from "@tanstack/react-query";
import { QueryKeys } from "../enums/query-keys";
import { createApi } from "./queries/functions/api";
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../enums/query-keys'
import { createApi } from './queries/functions/api'
export const useApi = (serverUrl?: string, username?: string, password?: string, accessToken?: string) => useQuery({
queryKey: [QueryKeys.Api, serverUrl, username, password, accessToken],
queryFn: ({ queryKey }) => {
export const useApi = (
serverUrl?: string,
username?: string,
password?: string,
accessToken?: string,
): ReturnType<typeof useQuery> =>
useQuery({
queryKey: [QueryKeys.Api, serverUrl, username, password, accessToken],
queryFn: ({ queryKey }) => {
const serverUrl: string | undefined = queryKey[1]
const username: string | undefined = queryKey[2]
const password: string | undefined = queryKey[3]
const accessToken: string | undefined = queryKey[4]
const serverUrl : string | undefined = queryKey[1];
const username : string | undefined = queryKey[2];
const password : string | undefined = queryKey[3];
const accessToken : string | undefined = queryKey[4];
return createApi(serverUrl, username, password, accessToken)
},
})
return createApi(serverUrl, username, password, accessToken)
},
})

View File

@@ -1,28 +1,28 @@
import { useQuery } from "@tanstack/react-query"
import { QueryKeys } from "../../enums/query-keys"
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"
import { BaseItemKind, ItemSortBy, SortOrder } from "@jellyfin/sdk/lib/generated-client/models"
import Client from "../client"
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../enums/query-keys'
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 useArtistFeaturedOnAlbums = (artistId: string) => useQuery({
queryKey: [QueryKeys.ArtistFeaturedAlbums, artistId],
queryFn: ({ queryKey }) => {
return getItemsApi(Client.api!).getItems({
includeItemTypes: [BaseItemKind.MusicAlbum],
recursive: true,
excludeItemIds: [queryKey[1] as string],
sortBy: [
ItemSortBy.PremiereDate,
ItemSortBy.ProductionYear,
ItemSortBy.SortName
],
sortOrder: [ SortOrder.Descending ],
contributingArtistIds: [queryKey[1] as string]
})
.then((response) => {
return response.data.Items ? response.data.Items! : [];
})
}
})
export const useArtistFeaturedOnAlbums = (artistId: string) =>
useQuery({
queryKey: [QueryKeys.ArtistFeaturedAlbums, artistId],
queryFn: ({ queryKey }) => {
return getItemsApi(Client.api!)
.getItems({
includeItemTypes: [BaseItemKind.MusicAlbum],
recursive: true,
excludeItemIds: [queryKey[1] as string],
sortBy: [
ItemSortBy.PremiereDate,
ItemSortBy.ProductionYear,
ItemSortBy.SortName,
],
sortOrder: [SortOrder.Descending],
contributingArtistIds: [queryKey[1] as string],
})
.then((response) => {
return response.data.Items ? response.data.Items! : []
})
},
})

View File

@@ -1,34 +1,35 @@
import { Api } from "@jellyfin/sdk";
import { JellyfinInfo } from "../../info";
import _ from "lodash";
import { Api } from '@jellyfin/sdk'
import { JellyfinInfo } from '../../info'
import _ from 'lodash'
export function createApi(serverUrl?: string, username?: string, password?: string, accessToken?: string): Promise<Api> {
return new Promise((resolve, reject) => {
export function createApi(
serverUrl?: string,
username?: string,
password?: string,
accessToken?: string,
): Promise<Api> {
return new Promise((resolve, reject) => {
if (_.isUndefined(serverUrl)) {
console.info("Server Url doesn't exist yet")
return reject("Server Url doesn't exist")
}
if (_.isUndefined(serverUrl)) {
console.info("Server Url doesn't exist yet")
return reject("Server Url doesn't exist");
}
if (!_.isUndefined(accessToken)) {
console.info('Creating API with accessToken')
return resolve(JellyfinInfo.createApi(serverUrl, accessToken))
}
if (!_.isUndefined(accessToken)) {
console.info("Creating API with accessToken")
return resolve(JellyfinInfo.createApi(serverUrl, accessToken));
}
if (_.isUndefined(username) && _.isUndefined(password)) {
console.info('Creating public API for server url')
return resolve(JellyfinInfo.createApi(serverUrl))
}
if (_.isUndefined(username) && _.isUndefined(password)) {
console.info("Creating public API for server url")
return resolve(JellyfinInfo.createApi(serverUrl));
}
JellyfinInfo.createApi(serverUrl).authenticateUserByName(username!, password)
.then(({ data }) => {
if (data.AccessToken)
return resolve(JellyfinInfo.createApi(serverUrl, data.AccessToken));
else
return reject("Unable to sign in");
});
});
}
JellyfinInfo.createApi(serverUrl)
.authenticateUserByName(username!, password)
.then(({ data }) => {
if (data.AccessToken)
return resolve(JellyfinInfo.createApi(serverUrl, data.AccessToken))
else return reject('Unable to sign in')
})
})
}

View File

@@ -1,26 +1,28 @@
import { Dirs, FileSystem } from "react-native-file-access";
import Client from "../../../api/client";
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { Dirs, FileSystem } from 'react-native-file-access'
import Client from '../../../api/client'
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api'
export async function downloadTrack(itemId: string) : Promise<void> {
export async function downloadTrack(itemId: string): Promise<void> {
// Make sure downloads folder exists, create if it doesn't
if (!(await FileSystem.exists(`${Dirs.DocumentDir}/downloads`)))
await FileSystem.mkdir(`${Dirs.DocumentDir}/downloads`)
// Make sure downloads folder exists, create if it doesn't
if (!(await FileSystem.exists(`${Dirs.DocumentDir}/downloads`)))
await FileSystem.mkdir(`${Dirs.DocumentDir}/downloads`)
getLibraryApi(Client.api!)
.getDownload({
itemId
}, {
'responseType': 'blob'
})
.then(async (response) => {
if (response.status < 300) {
await FileSystem.writeFile(getTrackFilePath(itemId), response.data)
}
})
getLibraryApi(Client.api!)
.getDownload(
{
itemId,
},
{
responseType: 'blob',
},
)
.then(async (response) => {
if (response.status < 300) {
await FileSystem.writeFile(getTrackFilePath(itemId), response.data)
}
})
}
function getTrackFilePath(itemId: string) {
return `${Dirs.DocumentDir}/downloads/${itemId}`
}
return `${Dirs.DocumentDir}/downloads/${itemId}`
}

View File

@@ -1,155 +1,117 @@
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";
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`);
export async function fetchFavoriteArtists(): Promise<BaseItemDto[]> {
console.debug(`Fetching user's favorite artists`)
return await 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)
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);
})
})
if (response.data.Items) return response.data.Items
else return []
})
.catch((error) => {
console.error(error)
return []
})
}
export function fetchFavoriteAlbums(): Promise<BaseItemDto[]> {
console.debug(`Fetching user's favorite albums`);
export async 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.DatePlayed,
ItemSortBy.SortName
],
sortOrder: [
SortOrder.Descending,
SortOrder.Ascending,
]
})
.then((response) => {
console.debug(`Received favorite album response`, response);
return await getItemsApi(Client.api!)
.getItems({
includeItemTypes: [BaseItemKind.MusicAlbum],
isFavorite: true,
parentId: Client.library!.musicLibraryId!,
recursive: true,
sortBy: [ItemSortBy.DatePlayed, ItemSortBy.SortName],
sortOrder: [SortOrder.Descending, 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);
})
})
if (response.data.Items) return response.data.Items
else return []
})
.catch((error) => {
console.error(error)
return []
})
}
export function fetchFavoritePlaylists(): Promise<BaseItemDto[]> {
console.debug(`Fetching user's favorite playlists`);
export async function fetchFavoritePlaylists(): Promise<BaseItemDto[]> {
console.debug(`Fetching user's favorite playlists`)
return new Promise(async (resolve, reject) => {
getItemsApi(Client.api!)
.getItems({
userId: Client.user!.id,
parentId: Client.library!.playlistLibraryId,
fields: [
"Path"
],
sortBy: [
ItemSortBy.SortName
],
sortOrder: [
SortOrder.Ascending
]
})
.then((response) => {
console.log(response);
if (response.data.Items)
resolve(response.data.Items.filter(item =>
item.UserData?.IsFavorite ||
item.Path?.includes("/data/playlists")
))
else
resolve([])
})
.catch((error) => {
console.error(error);
reject(error);
});
});
return await getItemsApi(Client.api!)
.getItems({
userId: Client.user!.id,
parentId: Client.library!.playlistLibraryId,
fields: ['Path'],
sortBy: [ItemSortBy.SortName],
sortOrder: [SortOrder.Ascending],
})
.then((response) => {
console.log(response)
if (response.data.Items)
return response.data.Items.filter(
(item) => item.UserData?.IsFavorite || item.Path?.includes('/data/playlists'),
)
else return []
})
.catch((error) => {
console.error(error)
return []
})
}
export function fetchFavoriteTracks(): Promise<BaseItemDto[]> {
console.debug(`Fetching user's favorite tracks`);
export async function fetchFavoriteTracks(): Promise<BaseItemDto[]> {
console.debug(`Fetching user's favorite tracks`)
return await 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)
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);
})
})
if (response.data.Items) return response.data.Items
else return []
})
.catch((error) => {
console.error(error)
return []
})
}
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);
})
});
}
export async function fetchUserData(itemId: string): Promise<UserItemDataDto | void> {
return await getItemsApi(Client.api!)
.getItemUserData({
itemId,
})
.then((response) => {
return response.data
})
.catch((error) => {
console.error(error)
})
}

View File

@@ -1,46 +1,43 @@
import { ImageFormat, ImageType } from "@jellyfin/sdk/lib/generated-client/models"
import { getImageApi } from "@jellyfin/sdk/lib/utils/api"
import _ from "lodash"
import Client from "../../../api/client"
import { ImageFormat, ImageType } from '@jellyfin/sdk/lib/generated-client/models'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import _ from 'lodash'
import Client from '../../../api/client'
export function fetchItemImage(itemId: string, imageType: ImageType, width: number, height: number) {
return new Promise<string>(async (resolve, reject) => {
console.debug("Fetching item image");
if (!Client.api)
return reject("Client instance not set")
else
getImageApi(Client.api)
.getItemImage({
itemId,
imageType,
width: Math.ceil(width / 100) * 100 * 2, // Round to the nearest 100 for simplicity and to avoid
height: Math.ceil(height / 100) * 100 * 2, // redundant images in storage, then double it to make sure it's crispy
format: ImageFormat.Png
},
{
responseType: 'blob',
})
.then(async (response) => {
if (response.status < 300) {
return resolve(await blobToBase64(response.data))
} else {
return reject("Invalid image response");
}
}).catch((error) => {
console.error(error);
reject(error);
})
});
export async function fetchItemImage(
itemId: string,
imageType: ImageType,
width: number,
height: number,
) {
console.debug('Fetching item image')
if (!Client.api) return console.error('Client instance not set')
try {
const response = await getImageApi(Client.api).getItemImage(
{
itemId,
imageType,
width: Math.ceil(width / 100) * 100 * 2, // Round to the nearest 100 for simplicity and to avoid
height: Math.ceil(height / 100) * 100 * 2, // redundant images in storage, then double it to make sure it's crispy
format: ImageFormat.Png,
},
{
responseType: 'blob',
},
)
if (response.status < 300) {
return await blobToBase64(response.data)
} else {
return console.error('Invalid image response')
}
} catch (error) {
console.error(error)
}
}
function blobToBase64(blob : Blob) {
return new Promise<string>((resolve, _) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
}
function blobToBase64(blob: Blob) {
return new Promise<string>((resolve, _) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result as string)
reader.readAsDataURL(blob)
})
}

View File

@@ -1,25 +1,20 @@
import Client from "../../../api/client";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { isEmpty } from "lodash";
import Client from '../../../api/client'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { isEmpty } from 'lodash'
export async function fetchItem(itemId: string) : Promise<BaseItemDto> {
return new Promise((resolve, reject) => {
export async function fetchItem(itemId: string): Promise<BaseItemDto> {
return new Promise((resolve, reject) => {
if (isEmpty(itemId)) reject('No item ID proviced')
if (isEmpty(itemId))
reject("No item ID proviced")
getItemsApi(Client.api!)
.getItems({
ids: [
itemId
]
})
.then((response) => {
if (response.data.Items && response.data.TotalRecordCount == 1)
resolve(response.data.Items[0])
else
reject(`${response.data.TotalRecordCount} items returned for ID`);
})
});
}
getItemsApi(Client.api!)
.getItems({
ids: [itemId],
})
.then((response) => {
if (response.data.Items && response.data.TotalRecordCount == 1)
resolve(response.data.Items[0])
else reject(`${response.data.TotalRecordCount} items returned for ID`)
})
})
}

View File

@@ -1,76 +1,67 @@
import Client from "../../client";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
import { isUndefined } from "lodash";
import Client from '../../client'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getUserViewsApi } from '@jellyfin/sdk/lib/utils/api'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api'
import { isUndefined } from 'lodash'
export async function fetchMusicLibraries(): Promise<BaseItemDto[] | void> {
console.debug('Fetching music libraries from Jellyfin')
export function fetchMusicLibraries(): Promise<BaseItemDto[]> {
return new Promise(async (resolve, reject) => {
console.debug("Fetching music libraries from Jellyfin");
const libraries = await getItemsApi(Client.api!).getItems({
includeItemTypes: ['CollectionFolder']
});
const libraries = await getItemsApi(Client.api!).getItems({
includeItemTypes: ['CollectionFolder'],
})
if (isUndefined(libraries.data.Items)) {
console.warn("No libraries found on Jellyfin");
return reject("No libraries found on Jellyfin");
}
if (isUndefined(libraries.data.Items)) {
console.warn('No libraries found on Jellyfin')
return
}
const musicLibraries = libraries.data.Items!.filter(library =>
library.CollectionType == 'music');
return resolve(musicLibraries);
});
const musicLibraries = libraries.data.Items!.filter(
(library) => library.CollectionType == 'music',
)
return musicLibraries
}
export function fetchPlaylistLibrary(): Promise<BaseItemDto> {
return new Promise(async (resolve, reject) => {
console.debug("Fetching playlist library from Jellyfin");
const libraries = await getItemsApi(Client.api!).getItems({
includeItemTypes: ['ManualPlaylistsFolder'],
excludeItemTypes: ['CollectionFolder']
});
export async function fetchPlaylistLibrary(): Promise<BaseItemDto | void> {
console.debug('Fetching playlist library from Jellyfin')
if (isUndefined(libraries.data.Items)) {
console.warn("No playlist libraries found on Jellyfin");
return reject("No playlist libraries found on Jellyfin");
}
const libraries = await getItemsApi(Client.api!).getItems({
includeItemTypes: ['ManualPlaylistsFolder'],
excludeItemTypes: ['CollectionFolder'],
})
console.debug("Playlist libraries", libraries.data.Items!)
if (isUndefined(libraries.data.Items)) {
console.warn('No playlist libraries found on Jellyfin')
return
}
const playlistLibrary = libraries.data.Items!.filter(library =>
library.CollectionType == 'playlists'
)[0];
console.debug('Playlist libraries', libraries.data.Items!)
if (isUndefined(playlistLibrary)) {
console.warn("Playlist libary does not exist on server");
return reject("Playlist library does not exist on server");
}
return resolve(playlistLibrary);
})
const playlistLibrary = libraries.data.Items!.filter(
(library) => library.CollectionType == 'playlists',
)[0]
if (isUndefined(playlistLibrary)) {
console.warn('Playlist libary does not exist on server')
return
}
return playlistLibrary
}
export function fetchUserViews() : Promise<BaseItemDto[]> {
return new Promise(async (resolve, reject) => {
console.debug("Fetching user views")
export async function fetchUserViews(): Promise<BaseItemDto[] | void> {
console.debug('Fetching user views')
getUserViewsApi(Client.api!)
.getUserViews({
userId: Client.user!.id
})
.then((response) => {
if (response.data.Items)
resolve(response.data.Items)
else
resolve([])
})
.catch((error) => {
reject(error)
})
});
}
return await getUserViewsApi(Client.api!)
.getUserViews({
userId: Client.user!.id,
})
.then((response) => {
if (response.data.Items) return response.data.Items
else return []
})
.catch((error) => {
console.warn(error)
})
}

View File

@@ -1,75 +1,57 @@
import Client from "../../client";
import { BaseItemDto, ItemSortBy, SortOrder } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import Client from '../../client'
import { BaseItemDto, ItemSortBy, SortOrder } from '@jellyfin/sdk/lib/generated-client/models'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
export function fetchUserPlaylists(
sortBy: ItemSortBy[] = []
): Promise<BaseItemDto[]> {
console.debug(`Fetching user playlists ${sortBy.length > 0 ? "sorting by " + sortBy.toString() : ""}`);
export async function fetchUserPlaylists(sortBy: ItemSortBy[] = []): Promise<BaseItemDto[] | void> {
console.debug(
`Fetching user playlists ${sortBy.length > 0 ? 'sorting by ' + sortBy.toString() : ''}`,
)
const defaultSorting : ItemSortBy[] = [
ItemSortBy.IsFolder,
ItemSortBy.SortName,
]
const defaultSorting: ItemSortBy[] = [ItemSortBy.IsFolder, ItemSortBy.SortName]
return new Promise(async (resolve, reject) => {
getItemsApi(Client.api!)
.getItems({
userId: Client.user!.id,
parentId: Client.library!.playlistLibraryId!,
fields: [
"Path"
],
sortBy: sortBy.concat(defaultSorting),
sortOrder: [
SortOrder.Ascending
]
})
.then((response) => {
return await getItemsApi(Client.api!)
.getItems({
userId: Client.user!.id,
parentId: Client.library!.playlistLibraryId!,
fields: ['Path'],
sortBy: sortBy.concat(defaultSorting),
sortOrder: [SortOrder.Ascending],
})
.then((response) => {
console.log(response)
console.log(response);
if (response.data.Items)
resolve(response.data.Items.filter(playlist =>
playlist.Path?.includes("/data/playlists")
))
else
resolve([]);
})
.catch((error) => {
console.error(error);
reject(error)
})
})
if (response.data.Items)
return response.data.Items.filter((playlist) =>
playlist.Path?.includes('/data/playlists'),
)
else return []
})
.catch((error) => {
console.error(error)
return []
})
}
export function fetchPublicPlaylists(): Promise<BaseItemDto[]> {
console.debug("Fetching public playlists");
export async function fetchPublicPlaylists(): Promise<BaseItemDto[]> {
console.debug('Fetching public playlists')
return new Promise(async (resolve, reject) => {
getItemsApi(Client.api!)
.getItems({
parentId: Client.library!.playlistLibraryId!,
sortBy: [
ItemSortBy.IsFolder,
ItemSortBy.SortName
],
sortOrder: [
SortOrder.Ascending
]
})
.then((response) => {
return await getItemsApi(Client.api!)
.getItems({
parentId: Client.library!.playlistLibraryId!,
sortBy: [ItemSortBy.IsFolder, ItemSortBy.SortName],
sortOrder: [SortOrder.Ascending],
})
.then((response) => {
console.log(response)
console.log(response);
if (response.data.Items)
resolve(response.data.Items.filter(playlist => !playlist.Path?.includes("/data/playlists")))
else
resolve([]);
})
.catch((error) => {
console.error(error);
reject(error)
})
})
}
if (response.data.Items)
return response.data.Items.filter(
(playlist) => !playlist.Path?.includes('/data/playlists'),
)
else return []
})
.catch((error) => {
console.error(error)
return []
})
}

View File

@@ -1,76 +1,72 @@
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";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/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'
import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api'
export function fetchRecentlyAdded(limit: number = QueryConfig.limits.recents, offset?: number | undefined) : Promise<BaseItemDto[]> {
return new Promise(async (resolve, reject) => {
if (!Client.api)
return reject("Client not set")
if (!Client.library)
return reject("Library not set")
else
getUserLibraryApi(Client.api)
.getLatestMedia({
parentId: Client.library.musicLibraryId,
limit,
})
.then(({ data }) => {
resolve(offset ? data.slice(offset, data.length - 1) : data);
});
})
export async function fetchRecentlyAdded(
limit: number = QueryConfig.limits.recents,
offset?: number | undefined,
): Promise<BaseItemDto[]> {
if (!Client.api) {
console.error('Client not set')
return []
}
if (!Client.library) return []
return await getUserLibraryApi(Client.api)
.getLatestMedia({
parentId: Client.library.musicLibraryId,
limit,
})
.then(({ data }) => {
return offset ? data.slice(offset, data.length - 1) : data
})
}
export function fetchRecentlyPlayed(limit: number = QueryConfig.limits.recents, offset?: number | undefined): Promise<BaseItemDto[]> {
export async function fetchRecentlyPlayed(
limit: number = QueryConfig.limits.recents,
offset?: number | undefined,
): Promise<BaseItemDto[]> {
console.debug('Fetching recently played items')
console.debug("Fetching recently played items");
return await getItemsApi(Client.api!)
.getItems({
includeItemTypes: [BaseItemKind.Audio],
startIndex: offset,
limit,
parentId: Client.library!.musicLibraryId,
recursive: true,
sortBy: [ItemSortBy.DatePlayed],
sortOrder: [SortOrder.Descending],
})
.then((response) => {
console.debug('Received recently played items response')
return new Promise(async (resolve, reject) => {
getItemsApi(Client.api!)
.getItems({
includeItemTypes: [
BaseItemKind.Audio
],
startIndex: offset,
limit,
parentId: Client.library!.musicLibraryId,
recursive: true,
sortBy: [
ItemSortBy.DatePlayed
],
sortOrder: [
SortOrder.Descending
],
})
.then((response) => {
console.debug("Received recently played items response");
if (response.data.Items)
resolve(response.data.Items);
else
resolve([]);
}).catch((error) => {
console.error(error);
reject(error);
})
})
if (response.data.Items) return response.data.Items
return []
})
.catch((error) => {
console.error(error)
return []
})
}
export function fetchRecentlyPlayedArtists(limit: number = QueryConfig.limits.recents, offset?: number | undefined) : Promise<BaseItemDto[]> {
return fetchRecentlyPlayed(limit * 2, offset ? offset + 10 : undefined)
.then((tracks) => {
return getItemsApi(Client.api!)
.getItems({
ids: tracks.map(track => track.ArtistItems![0].Id!)
})
.then((recentArtists) => {
return recentArtists.data.Items!
});
});
}
export function fetchRecentlyPlayedArtists(
limit: number = QueryConfig.limits.recents,
offset?: number | undefined,
): Promise<BaseItemDto[]> {
return fetchRecentlyPlayed(limit * 2, offset ? offset + 10 : undefined).then((tracks) => {
return getItemsApi(Client.api!)
.getItems({
ids: tracks.map((track) => track.ArtistItems![0].Id!),
})
.then((recentArtists) => {
return recentArtists.data.Items!
})
})
}

View File

@@ -1,8 +1,8 @@
import Client from "../../../api/client";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { isEmpty, trim } from "lodash";
import { QueryConfig } from "../query.config";
import Client from '../../../api/client'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { isEmpty, trim } from 'lodash'
import { QueryConfig } from '../query.config'
/**
* Performs a search for items against the Jellyfin server, trimming whitespace
@@ -10,40 +10,27 @@ import { QueryConfig } from "../query.config";
* @param searchString The search term to look up against
* @returns A promise of a BaseItemDto array, be it empty or not
*/
export async function fetchSearchResults(searchString: string | undefined) : Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
export async function fetchSearchResults(searchString: string | undefined): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
console.debug('Searching Jellyfin for items')
console.debug("Searching Jellyfin for items")
if (isEmpty(searchString)) resolve([])
if (isEmpty(searchString))
resolve([]);
getItemsApi(Client.api!)
.getItems({
searchTerm: trim(searchString),
recursive: true,
includeItemTypes: [
'MusicArtist',
'Audio',
'MusicAlbum',
'Playlist'
],
limit: QueryConfig.limits.search,
sortBy: [
'IsFolder'
],
sortOrder: [
'Descending'
]
})
.then((response) => {
if (response.data.Items)
resolve(response.data.Items)
else
resolve([]);
})
.catch((error) => {
reject(error)
});
})
}
getItemsApi(Client.api!)
.getItems({
searchTerm: trim(searchString),
recursive: true,
includeItemTypes: ['MusicArtist', 'Audio', 'MusicAlbum', 'Playlist'],
limit: QueryConfig.limits.search,
sortBy: ['IsFolder'],
sortOrder: ['Descending'],
})
.then((response) => {
if (response.data.Items) resolve(response.data.Items)
else resolve([])
})
.catch((error) => {
reject(error)
})
})
}

View File

@@ -1,25 +1,26 @@
import Client from "../../../api/client";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import Client from '../../../api/client'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api'
export default function fetchSimilar(itemId : string, limit : number = 10, startIndex : number = 0) : Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
if (!Client.api || !Client.user)
reject("Client has not been set")
else
getLibraryApi(Client.api)
.getSimilarArtists({
userId: Client.user.id,
itemId: itemId,
limit
})
.then(({ data }) => {
resolve(data.Items ?? [])
})
.catch((error) => {
reject(error)
})
})
}
export default function fetchSimilar(
itemId: string,
limit: number = 10,
startIndex: number = 0,
): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
if (!Client.api || !Client.user) reject('Client has not been set')
else
getLibraryApi(Client.api)
.getSimilarArtists({
userId: Client.user.id,
itemId: itemId,
limit,
})
.then(({ data }) => {
resolve(data.Items ?? [])
})
.catch((error) => {
reject(error)
})
})
}

View File

@@ -1,107 +1,90 @@
import { getItemsApi, getSuggestionsApi } from "@jellyfin/sdk/lib/utils/api";
import Client from "../../../api/client";
import { BaseItemDto, BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getSuggestionsApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../../../api/client'
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'
export async function fetchSearchSuggestions() : Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
getItemsApi(Client.api!)
.getItems({
recursive: true,
limit: 10,
includeItemTypes: [
BaseItemKind.MusicArtist,
BaseItemKind.Playlist,
BaseItemKind.Audio,
BaseItemKind.MusicAlbum
],
sortBy: [
"IsFavoriteOrLiked",
"Random"
]
})
.then(({ data }) => {
if (data.Items)
resolve(data.Items);
else
resolve([]);
})
.catch((error) => {
reject(error);
})
})
export async function fetchSearchSuggestions(): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
getItemsApi(Client.api!)
.getItems({
recursive: true,
limit: 10,
includeItemTypes: [
BaseItemKind.MusicArtist,
BaseItemKind.Playlist,
BaseItemKind.Audio,
BaseItemKind.MusicAlbum,
],
sortBy: ['IsFavoriteOrLiked', 'Random'],
})
.then(({ data }) => {
if (data.Items) resolve(data.Items)
else resolve([])
})
.catch((error) => {
reject(error)
})
})
}
/**
* @deprecated Use Items API based functions instead of Suggestions API
* @returns
* @returns
*/
export async function fetchSuggestedArtists() : Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
getSuggestionsApi(Client.api!)
.getSuggestions({
userId: Client.user!.id,
type: [
'MusicArtist'
]
})
.then((response) => {
if (response.data.Items)
resolve(response.data.Items)
else
resolve([]);
})
.catch((error) => {
reject(error);
})
})
export async function fetchSuggestedArtists(): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
getSuggestionsApi(Client.api!)
.getSuggestions({
userId: Client.user!.id,
type: ['MusicArtist'],
})
.then((response) => {
if (response.data.Items) resolve(response.data.Items)
else resolve([])
})
.catch((error) => {
reject(error)
})
})
}
/**
* @deprecated Use Items API based functions instead of Suggestions API
* @returns
* @returns
*/
export async function fetchSuggestedAlbums() : Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
getSuggestionsApi(Client.api!)
.getSuggestions({
userId: Client.user!.id,
type: [
'MusicAlbum'
]
})
.then((response) => {
if (response.data.Items)
resolve(response.data.Items)
else
resolve([]);
})
.catch((error) => {
reject(error);
})
})
export async function fetchSuggestedAlbums(): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
getSuggestionsApi(Client.api!)
.getSuggestions({
userId: Client.user!.id,
type: ['MusicAlbum'],
})
.then((response) => {
if (response.data.Items) resolve(response.data.Items)
else resolve([])
})
.catch((error) => {
reject(error)
})
})
}
/**
* @deprecated Use Items API based functions instead of Suggestions API
* @returns
* @returns
*/
export async function fetchSuggestedTracks() : Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
getSuggestionsApi(Client.api!)
.getSuggestions({
userId: Client.user!.id,
type: [
'Audio'
]
})
.then((response) => {
if (response.data.Items)
resolve(response.data.Items)
else
resolve([]);
})
.catch((error) => {
reject(error);
})
})
}
export async function fetchSuggestedTracks(): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
getSuggestionsApi(Client.api!)
.getSuggestions({
userId: Client.user!.id,
type: ['Audio'],
})
.then((response) => {
if (response.data.Items) resolve(response.data.Items)
else resolve([])
})
.catch((error) => {
reject(error)
})
})
}

View File

@@ -1,33 +1,33 @@
import { ImageFormat } from "@jellyfin/sdk/lib/generated-client/models";
import { ImageFormat } from '@jellyfin/sdk/lib/generated-client/models'
export const QueryConfig = {
limits: {
recents: 20,
search: 50, // TODO: make this a paginated search so limits don't even matter
},
images: {
height: 300,
width: 300,
format: ImageFormat.Jpg
},
banners: {
fillHeight: 300,
fillWidth: 1000,
format: ImageFormat.Jpg,
},
logos: {
fillHeight: 50,
fillWidth: 300,
format: ImageFormat.Png
},
playerArtwork: {
height: 1000,
width: 1000,
format: ImageFormat.Jpg
},
staleTime: {
oneDay: 1000 * 60 * 60 * 24, // 1 Day
oneWeek: 1000 * 60 * 60 * 24 * 7, // 7 Days
oneFortnight: 1000 * 60 * 60 * 24 * 7 * 14 // 14 Days
}
}
limits: {
recents: 20,
search: 50, // TODO: make this a paginated search so limits don't even matter
},
images: {
height: 300,
width: 300,
format: ImageFormat.Jpg,
},
banners: {
fillHeight: 300,
fillWidth: 1000,
format: ImageFormat.Jpg,
},
logos: {
fillHeight: 50,
fillWidth: 300,
format: ImageFormat.Png,
},
playerArtwork: {
height: 1000,
width: 1000,
format: ImageFormat.Jpg,
},
staleTime: {
oneDay: 1000 * 60 * 60 * 24, // 1 Day
oneWeek: 1000 * 60 * 60 * 24 * 7, // 7 Days
oneFortnight: 1000 * 60 * 60 * 24 * 7 * 14, // 14 Days
},
}

View File

@@ -1,12 +1,11 @@
export class JellyfinCredentials {
username: string;
password?: string | undefined;
accessToken?: string | undefined;
username: string
password?: string | undefined
accessToken?: string | undefined
constructor(username: string, password?: string | undefined, accessToken?: string | undefined) {
this.username = username;
this.password = password;
this.accessToken = accessToken;
}
}
constructor(username: string, password?: string | undefined, accessToken?: string | undefined) {
this.username = username
this.password = password
this.accessToken = accessToken
}
}

View File

@@ -1,135 +1,119 @@
import { HomeAlbumProps } from "../types";
import { YStack, XStack, Separator, getToken } from "tamagui";
import { ItemSortBy } from "@jellyfin/sdk/lib/generated-client/models";
import { H3, H5, Text } from "../Global/helpers/text";
import { FlatList } from "react-native";
import { RunTimeTicks } from "../Global/helpers/time-codes";
import Track from "../Global/components/track";
import { useSafeAreaFrame } from "react-native-safe-area-context";
import FavoriteButton from "../Global/components/favorite-button";
import { useQuery } from "@tanstack/react-query";
import { QueryKeys } from "../../enums/query-keys";
import { getImageApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import Client from "../../api/client";
import { useMemo } from "react";
import { ItemCard } from "../Global/components/item-card";
import { HomeAlbumProps } from '../types'
import { YStack, XStack, Separator, getToken } from 'tamagui'
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models'
import { H3, H5, Text } from '../Global/helpers/text'
import { FlatList } from 'react-native'
import { RunTimeTicks } from '../Global/helpers/time-codes'
import Track from '../Global/components/track'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import FavoriteButton from '../Global/components/favorite-button'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../enums/query-keys'
import { getImageApi, getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../../api/client'
import { useMemo } from 'react'
import { ItemCard } from '../Global/components/item-card'
import { Image } from 'expo-image'
export function AlbumScreen({
route,
navigation
} : HomeAlbumProps): React.JSX.Element {
export function AlbumScreen({ route, navigation }: HomeAlbumProps): React.JSX.Element {
const { album } = route.params
const { album } = route.params;
navigation.setOptions({
headerRight: () => {
return <FavoriteButton item={album} />
},
})
const { width } = useSafeAreaFrame()
navigation.setOptions({
headerRight: () => {
return (
<FavoriteButton item={album} />
)
}
})
const { width } = useSafeAreaFrame();
const { data: tracks } = useQuery({
queryKey: [QueryKeys.ItemTracks, album.Id!],
queryFn: () => {
let sortBy: ItemSortBy[] = []
const { data: tracks } = useQuery({
queryKey: [QueryKeys.ItemTracks, album.Id!],
queryFn: () => {
sortBy = [ItemSortBy.ParentIndexNumber, ItemSortBy.IndexNumber, ItemSortBy.SortName]
let sortBy: ItemSortBy[] = [];
return getItemsApi(Client.api!)
.getItems({
parentId: album.Id!,
sortBy,
})
.then((response) => {
return response.data.Items ? response.data.Items! : []
})
},
})
sortBy = [
ItemSortBy.ParentIndexNumber,
ItemSortBy.IndexNumber,
ItemSortBy.SortName
]
return (
<FlatList
contentInsetAdjustmentBehavior='automatic'
data={tracks}
keyExtractor={(item) => item.Id!}
numColumns={1}
ItemSeparatorComponent={() => <Separator />}
ListHeaderComponent={useMemo(() => {
return (
<YStack
alignItems='center'
alignContent='center'
marginTop={'$4'}
minHeight={getToken('$20') + getToken('$15')}
>
<Image
source={getImageApi(Client.api!).getItemImageUrlById(album.Id!)}
style={{
borderRadius: getToken('$5'),
width: getToken('$20') + getToken('$15'),
height: getToken('$20') + getToken('$15'),
}}
/>
return getItemsApi(Client.api!).getItems({
parentId: album.Id!,
sortBy
})
.then((response) => {
return response.data.Items ? response.data.Items! : [];
})
},
});
<H5 textAlign='center'>{album.Name ?? 'Untitled Album'}</H5>
<Text>{album.ProductionYear?.toString() ?? ''}</Text>
</YStack>
)
}, [album])}
renderItem={({ item: track, index }) => (
<Track
track={track}
tracklist={tracks!}
index={index}
navigation={navigation}
queue={album}
/>
)}
ListFooterComponent={
<YStack justifyContent='flex-start'>
<XStack flex={1} marginTop={'$3'} justifyContent='flex-end'>
<Text
color={'$borderColor'}
style={{ display: 'block' }}
marginRight={'$1'}
>
Total Runtime:
</Text>
<RunTimeTicks>{album.RunTimeTicks}</RunTimeTicks>
</XStack>
return (
<FlatList
contentInsetAdjustmentBehavior="automatic"
data={tracks}
keyExtractor={(item) => item.Id!}
numColumns={1}
ItemSeparatorComponent={() => <Separator />}
ListHeaderComponent={(
useMemo(() => {
return (
<YStack
alignItems="center"
alignContent="center"
marginTop={"$4"}
minHeight={getToken("$20") + getToken("$15")}
>
<Image
source={getImageApi(Client.api!).getItemImageUrlById(album.Id!)}
style={{
borderRadius: getToken("$5"),
width: getToken("$20") + getToken("$15"),
height: getToken("$20") + getToken("$15")
}}
/>
<H5 textAlign="center">{ album.Name ?? "Untitled Album" }</H5>
<Text>{ album.ProductionYear?.toString() ?? "" }</Text>
</YStack>
)
}, [
album
])
)}
renderItem={({ item: track, index }) =>
<Track
track={track}
tracklist={tracks!}
index={index}
navigation={navigation}
queue={album}
/>
}
ListFooterComponent={(
<YStack justifyContent="flex-start">
<XStack flex={1} marginTop={"$3"} justifyContent="flex-end">
<Text
color={"$borderColor"}
style={{ display: "block"}}
marginRight={"$1"}
>
Total Runtime:
</Text>
<RunTimeTicks>{ album.RunTimeTicks }</RunTimeTicks>
</XStack>
<H3>Album Artists</H3>
<FlatList
horizontal
keyExtractor={(item) => item.Id!}
data={album.ArtistItems}
renderItem={({ index, item: artist }) =>
<ItemCard
width={100}
item={artist}
caption={artist.Name ?? "Unknown Artist"}
onPress={() => {
navigation.navigate("Artist", {
artist
});
}}
/>
}
/>
</YStack>
)}
/>
)
}
<H3>Album Artists</H3>
<FlatList
horizontal
keyExtractor={(item) => item.Id!}
data={album.ArtistItems}
renderItem={({ index, item: artist }) => (
<ItemCard
width={100}
item={artist}
caption={artist.Name ?? 'Unknown Artist'}
onPress={() => {
navigation.navigate('Artist', {
artist,
})
}}
/>
)}
/>
</YStack>
}
/>
)
}

View File

@@ -1,50 +1,48 @@
import { AlbumsProps } from "../types";
import { useSafeAreaFrame } from "react-native-safe-area-context";
import { ItemCard } from "../Global/components/item-card";
import { FlatList, RefreshControl } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { QueryKeys } from "../../enums/query-keys";
import { fetchFavoriteAlbums } from "../../api/queries/functions/favorites";
import { fetchRecentlyAdded } from "../../api/queries/functions/recents";
import { QueryConfig } from "../../api/queries/query.config";
import { AlbumsProps } from '../types'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { ItemCard } from '../Global/components/item-card'
import { FlatList, RefreshControl } from 'react-native'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../enums/query-keys'
import { fetchFavoriteAlbums } from '../../api/queries/functions/favorites'
import { fetchRecentlyAdded } from '../../api/queries/functions/recents'
import { QueryConfig } from '../../api/queries/query.config'
export default function Albums({ navigation, route }: AlbumsProps) : React.JSX.Element {
export default function Albums({ navigation, route }: AlbumsProps): React.JSX.Element {
const fetchRecentlyAddedAlbums = route.params.query === QueryKeys.RecentlyAdded
const fetchRecentlyAddedAlbums = route.params.query === QueryKeys.RecentlyAdded;
const {
data: albums,
refetch,
isPending,
} = useQuery({
queryKey: [route.params.query],
queryFn: () =>
fetchRecentlyAddedAlbums
? fetchRecentlyAdded(QueryConfig.limits.recents * 4, QueryConfig.limits.recents)
: fetchFavoriteAlbums(),
})
const { data: albums, refetch, isPending } = useQuery({
queryKey: [route.params.query],
queryFn: () =>
fetchRecentlyAddedAlbums
? fetchRecentlyAdded(QueryConfig.limits.recents * 4, QueryConfig.limits.recents)
: fetchFavoriteAlbums()
});
const { width } = useSafeAreaFrame()
const { width } = useSafeAreaFrame();
return (
<FlatList
contentInsetAdjustmentBehavior="automatic"
numColumns={2}
data={albums}
refreshControl={
<RefreshControl
refreshing={isPending}
onRefresh={refetch}
/>
}
renderItem={({ index, item: album}) =>
<ItemCard
item={album}
caption={album.Name ?? "Untitled Album"}
subCaption={album.ProductionYear?.toString() ?? ""}
squared
onPress={() => {
navigation.navigate("Album", { album })
}}
width={width / 2.1}
/>
}
/>
)
}
return (
<FlatList
contentInsetAdjustmentBehavior='automatic'
numColumns={2}
data={albums}
refreshControl={<RefreshControl refreshing={isPending} onRefresh={refetch} />}
renderItem={({ index, item: album }) => (
<ItemCard
item={album}
caption={album.Name ?? 'Untitled Album'}
subCaption={album.ProductionYear?.toString() ?? ''}
squared
onPress={() => {
navigation.navigate('Album', { album })
}}
width={width / 2.1}
/>
)}
/>
)
}

View File

@@ -1,12 +1,10 @@
import React from "react"
import { StackParamList } from "../types"
import { NativeStackScreenProps } from "@react-navigation/native-stack"
import Albums from "./component"
import React from 'react'
import { StackParamList } from '../types'
import { NativeStackScreenProps } from '@react-navigation/native-stack'
import Albums from './component'
export default function AlbumsScreen(
props: NativeStackScreenProps<StackParamList, 'Albums'>
) : React.JSX.Element {
return (
<Albums {...props} />
)
}
props: NativeStackScreenProps<StackParamList, 'Albums'>,
): React.JSX.Element {
return <Albums {...props} />
}

View File

@@ -1,131 +1,125 @@
import { RouteProp } from "@react-navigation/native";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { StackParamList } from "../types";
import Client from "../../api/client";
import { QueryKeys } from "../../enums/query-keys";
import { BaseItemKind, ItemSortBy, SortOrder } from "@jellyfin/sdk/lib/generated-client/models";
import { getImageApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { ScrollView, FlatList } from "react-native";
import { useSafeAreaFrame } from "react-native-safe-area-context";
import { YStack } from "tamagui";
import FavoriteButton from "../Global/components/favorite-button";
import { ItemCard } from "../Global/components/item-card";
import { H3 } from "../Global/helpers/text";
import fetchSimilar from "../../api/queries/functions/similar";
import { Image } from "expo-image";
import { RouteProp } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { StackParamList } from '../types'
import Client from '../../api/client'
import { QueryKeys } from '../../enums/query-keys'
import { BaseItemKind, ItemSortBy, SortOrder } from '@jellyfin/sdk/lib/generated-client/models'
import { getImageApi, getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { ScrollView, FlatList } from 'react-native'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { YStack } from 'tamagui'
import FavoriteButton from '../Global/components/favorite-button'
import { ItemCard } from '../Global/components/item-card'
import { H3 } from '../Global/helpers/text'
import fetchSimilar from '../../api/queries/functions/similar'
import { Image } from 'expo-image'
export function ArtistScreen({
route,
navigation
} : {
route: RouteProp<StackParamList, "Artist">,
navigation: NativeStackNavigationProp<StackParamList>
export function ArtistScreen({
route,
navigation,
}: {
route: RouteProp<StackParamList, 'Artist'>
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { artist } = route.params
const { artist } = route.params;
navigation.setOptions({
headerRight: () => {
return <FavoriteButton item={artist} />
},
})
navigation.setOptions({
headerRight: () => {
return (
<FavoriteButton item={artist} />
)
}
});
const [columns, setColumns] = useState<number>(2)
const [columns, setColumns] = useState<number>(2);
const { height, width } = useSafeAreaFrame()
const { height, width } = useSafeAreaFrame();
const bannerHeight = height / 6
const bannerHeight = height / 6;
const { data: albums } = useQuery({
queryKey: [QueryKeys.ArtistAlbums, artist.Id!],
queryFn: ({ queryKey }) => {
return getItemsApi(Client.api!)
.getItems({
includeItemTypes: [BaseItemKind.MusicAlbum],
recursive: true,
excludeItemIds: [queryKey[1] as string],
sortBy: [
ItemSortBy.PremiereDate,
ItemSortBy.ProductionYear,
ItemSortBy.SortName,
],
sortOrder: [SortOrder.Descending],
artistIds: [queryKey[1] as string],
})
.then((response) => {
return response.data.Items ? response.data.Items! : []
})
},
})
const { data: albums } = useQuery({
queryKey: [QueryKeys.ArtistAlbums, artist.Id!],
queryFn: ({ queryKey }) => {
return getItemsApi(Client.api!).getItems({
includeItemTypes: [BaseItemKind.MusicAlbum],
recursive: true,
excludeItemIds: [queryKey[1] as string],
sortBy: [
ItemSortBy.PremiereDate,
ItemSortBy.ProductionYear,
ItemSortBy.SortName
],
sortOrder: [SortOrder.Descending],
artistIds: [queryKey[1] as string],
})
.then((response) => {
return response.data.Items ? response.data.Items! : [];
})
}
});
const { data: similarArtists } = useQuery({
queryKey: [QueryKeys.SimilarItems, artist.Id],
queryFn: () => fetchSimilar(artist.Id!),
})
const { data: similarArtists } = useQuery({
queryKey: [QueryKeys.SimilarItems, artist.Id],
queryFn: () => fetchSimilar(artist.Id!)
})
return (
<ScrollView contentInsetAdjustmentBehavior='automatic' removeClippedSubviews>
<YStack alignContent='center' justifyContent='center' minHeight={bannerHeight}>
<Image
source={getImageApi(Client.api!).getItemImageUrlById(artist.Id!)}
style={{
width: width,
height: bannerHeight,
}}
/>
</YStack>
return (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
removeClippedSubviews
>
<YStack alignContent="center" justifyContent="center" minHeight={bannerHeight}>
<Image
source={getImageApi(Client.api!).getItemImageUrlById(artist.Id!)}
style={{
width: width,
height: bannerHeight
}}
/>
</YStack>
<H3>Albums</H3>
<FlatList
contentContainerStyle={{
flexGrow: 1,
alignItems: 'center',
}}
data={albums}
numColumns={columns} // TODO: Make this adjustable
renderItem={({ item: album }) => (
<ItemCard
caption={album.Name}
subCaption={album.ProductionYear?.toString()}
size={'$14'}
squared
item={album}
onPress={() => {
navigation.navigate('Album', {
album,
})
}}
/>
)}
ListFooterComponent={
<YStack>
<H3>{`Similar to ${artist.Name ?? 'Unknown Artist'}`} </H3>
<H3>Albums</H3>
<FlatList
contentContainerStyle={{
flexGrow: 1,
alignItems: "center"
}}
data={albums}
numColumns={columns} // TODO: Make this adjustable
renderItem={({ item: album }) =>
<ItemCard
caption={album.Name}
subCaption={album.ProductionYear?.toString()}
size={"$14"}
squared
item={album}
onPress={() => {
navigation.navigate('Album', {
album
})
}}
/>
}
ListFooterComponent={(
<YStack>
<H3>{`Similar to ${artist.Name ?? 'Unknown Artist'}`} </H3>
<FlatList
data={similarArtists}
horizontal
renderItem={({ item: artist }) => (
<ItemCard
caption={artist.Name ?? "Unknown Artist"}
item={artist}
onPress={() => {
navigation.push('Artist', {
artist
})
}}
/>
)}
/>
</YStack>
)}
/>
</ScrollView>
)
}
<FlatList
data={similarArtists}
horizontal
renderItem={({ item: artist }) => (
<ItemCard
caption={artist.Name ?? 'Unknown Artist'}
item={artist}
onPress={() => {
navigation.push('Artist', {
artist,
})
}}
/>
)}
/>
</YStack>
}
/>
</ScrollView>
)
}

View File

@@ -1,54 +1,55 @@
import { useSafeAreaFrame } from "react-native-safe-area-context";
import React from "react";
import { FlatList, RefreshControl } from "react-native";
import { ItemCard } from "../Global/components/item-card";
import { ArtistsProps } from "../types";
import { QueryKeys } from "../../enums/query-keys";
import { useQuery } from "@tanstack/react-query";
import { fetchRecentlyPlayedArtists } from "../../api/queries/functions/recents";
import { fetchFavoriteArtists } from "../../api/queries/functions/favorites";
import { QueryConfig } from "../../api/queries/query.config";
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import React from 'react'
import { FlatList, RefreshControl } from 'react-native'
import { ItemCard } from '../Global/components/item-card'
import { ArtistsProps } from '../types'
import { QueryKeys } from '../../enums/query-keys'
import { useQuery } from '@tanstack/react-query'
import { fetchRecentlyPlayedArtists } from '../../api/queries/functions/recents'
import { fetchFavoriteArtists } from '../../api/queries/functions/favorites'
import { QueryConfig } from '../../api/queries/query.config'
export default function Artists({
navigation,
route
}: ArtistsProps): React.JSX.Element {
export default function Artists({ navigation, route }: ArtistsProps): React.JSX.Element {
const {
data: artists,
refetch,
isPending,
} = route.params.query === QueryKeys.RecentlyPlayedArtists
? useQuery({
queryKey: [
QueryKeys.RecentlyPlayedArtists,
QueryConfig.limits.recents * 4,
QueryConfig.limits.recents,
],
queryFn: () =>
fetchRecentlyPlayedArtists(
QueryConfig.limits.recents * 4,
QueryConfig.limits.recents,
),
})
: useQuery({
queryKey: [QueryKeys.FavoriteArtists],
queryFn: () => fetchFavoriteArtists(),
})
const { data: artists, refetch, isPending } =
route.params.query ===
QueryKeys.RecentlyPlayedArtists ? useQuery({
queryKey: [QueryKeys.RecentlyPlayedArtists, QueryConfig.limits.recents * 4, QueryConfig.limits.recents],
queryFn: () => fetchRecentlyPlayedArtists(QueryConfig.limits.recents * 4, QueryConfig.limits.recents)
}) :
useQuery({
queryKey: [QueryKeys.FavoriteArtists],
queryFn: () => fetchFavoriteArtists()
});
const { width } = useSafeAreaFrame()
const { width } = useSafeAreaFrame();
return (
<FlatList
contentInsetAdjustmentBehavior="automatic"
numColumns={2}
data={artists}
refreshControl={
<RefreshControl
refreshing={isPending}
onRefresh={refetch}
/>
}
renderItem={({ index, item: artist}) =>
<ItemCard
item={artist}
caption={artist.Name ?? "Unknown Artist"}
onPress={() => {
navigation.navigate("Artist", { artist })
}}
width={width / 2.1}
/>
}
/>
)
}
return (
<FlatList
contentInsetAdjustmentBehavior='automatic'
numColumns={2}
data={artists}
refreshControl={<RefreshControl refreshing={isPending} onRefresh={refetch} />}
renderItem={({ index, item: artist }) => (
<ItemCard
item={artist}
caption={artist.Name ?? 'Unknown Artist'}
onPress={() => {
navigation.navigate('Artist', { artist })
}}
width={width / 2.1}
/>
)}
/>
)
}

View File

@@ -1,17 +1,15 @@
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"
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}/>
)
}
route,
navigation,
}: {
route: RouteProp<StackParamList, 'Artists'>
navigation: NativeStackNavigationProp<StackParamList, 'Artists', undefined>
}): React.JSX.Element {
return <Artists route={route} navigation={navigation} />
}

View File

@@ -1,7 +1,7 @@
import { ListTemplate } from "react-native-carplay";
import { ListTemplate } from 'react-native-carplay'
const CarPlayDiscover = new ListTemplate({
tabTitle: "Discover"
tabTitle: 'Discover',
})
export default CarPlayDiscover;
export default CarPlayDiscover

View File

@@ -1,45 +1,48 @@
import { QueryKeys } from "../../enums/query-keys";
import Client from "../../api/client";
import { fetchRecentlyPlayed } from "../../api/queries/functions/recents";
import { CarPlay, ListTemplate } from "react-native-carplay";
import { queryClient } from "../../constants/query-client";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import ListItemTemplate from "./ListTemplate";
import { QueryKeys } from '../../enums/query-keys'
import Client from '../../api/client'
import { fetchRecentlyPlayed } from '../../api/queries/functions/recents'
import { CarPlay, ListTemplate } from 'react-native-carplay'
import { queryClient } from '../../constants/query-client'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import ListItemTemplate from './ListTemplate'
const CarPlayHome : ListTemplate = new ListTemplate({
id: 'Home',
title: "Home",
tabTitle: "Home",
sections: [
{
header: `Hi ${Client.user?.name ?? "there"}`,
items: [
{ id: QueryKeys.RecentlyPlayedArtists, text: 'Recent Artists' },
{ id: QueryKeys.RecentlyPlayed, text: 'Recently Played'},
{ id: QueryKeys.UserPlaylists, text: 'Your Playlists'}
]
}
],
onItemSelect: async ({ index }) => {
const CarPlayHome: ListTemplate = new ListTemplate({
id: 'Home',
title: 'Home',
tabTitle: 'Home',
sections: [
{
header: `Hi ${Client.user?.name ?? 'there'}`,
items: [
{ id: QueryKeys.RecentlyPlayedArtists, text: 'Recent Artists' },
{ id: QueryKeys.RecentlyPlayed, text: 'Recently Played' },
{ id: QueryKeys.UserPlaylists, text: 'Your Playlists' },
],
},
],
onItemSelect: async ({ index }) => {
console.debug(`Home item selected`)
console.debug(`Home item selected`);
switch (index) {
case 0: {
const artists = queryClient.getQueryData<BaseItemDto[]>([
QueryKeys.RecentlyPlayedArtists,
])
CarPlay.pushTemplate(ListItemTemplate(artists))
break
}
case 1: {
const tracks = await fetchRecentlyPlayed()
CarPlay.pushTemplate(ListItemTemplate(tracks))
break
}
case 2: {
const playlists = queryClient.getQueryData<BaseItemDto[]>([QueryKeys.UserPlaylists])
CarPlay.pushTemplate(ListItemTemplate(playlists))
break
}
}
},
})
switch (index) {
case 0:
const artists = queryClient.getQueryData<BaseItemDto[]>([QueryKeys.RecentlyPlayedArtists]);
CarPlay.pushTemplate(ListItemTemplate(artists))
break;
case 1:
const tracks = await fetchRecentlyPlayed()
CarPlay.pushTemplate(ListItemTemplate(tracks))
break;
case 2:
const playlists = queryClient.getQueryData<BaseItemDto[]>([QueryKeys.UserPlaylists])
CarPlay.pushTemplate(ListItemTemplate(playlists))
break;
}
}
});
export default CarPlayHome;
export default CarPlayHome

View File

@@ -1,22 +1,26 @@
import { queryClient } from "../../constants/query-client";
import { QueryKeys } from "../../enums/query-keys";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { ListTemplate } from "react-native-carplay";
import { queryClient } from '../../constants/query-client'
import { QueryKeys } from '../../enums/query-keys'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { ListTemplate } from 'react-native-carplay'
export default function ListItemTemplate(items: BaseItemDto[] | undefined) : ListTemplate {
return new ListTemplate({
sections: [
{
items: items?.map(item => {
return {
id: item.Id!,
text: item.Name ?? "Untitled",
image: {
uri: queryClient.getQueryData<string | undefined>([QueryKeys.ItemImage, item.Id!])
}
}
}) ?? []
}
],
})
}
export default function ListItemTemplate(items: BaseItemDto[] | undefined): ListTemplate {
return new ListTemplate({
sections: [
{
items:
items?.map((item) => {
return {
id: item.Id!,
text: item.Name ?? 'Untitled',
image: {
uri: queryClient.getQueryData<string | undefined>([
QueryKeys.ItemImage,
item.Id!,
]),
},
}
}) ?? [],
},
],
})
}

View File

@@ -1,18 +1,14 @@
import { CarPlay, TabBarTemplate } from "react-native-carplay";
import CarPlayHome from "./Home";
import CarPlayDiscover from "./Discover";
import { CarPlay, TabBarTemplate } from 'react-native-carplay'
import CarPlayHome from './Home'
import CarPlayDiscover from './Discover'
const CarPlayNavigation : TabBarTemplate = new TabBarTemplate({
id: "Tabs",
title: 'Tabs',
templates: [
CarPlayHome,
CarPlayDiscover
],
onTemplateSelect(template, e) {
if (template)
CarPlay.pushTemplate(template, true)
},
});
const CarPlayNavigation: TabBarTemplate = new TabBarTemplate({
id: 'Tabs',
title: 'Tabs',
templates: [CarPlayHome, CarPlayDiscover],
onTemplateSelect(template, e) {
if (template) CarPlay.pushTemplate(template, true)
},
})
export default CarPlayNavigation;
export default CarPlayNavigation

View File

@@ -1,7 +1,5 @@
import { NowPlayingTemplate } from "react-native-carplay";
import { NowPlayingTemplate } from 'react-native-carplay'
const CarPlayNowPlaying : NowPlayingTemplate = new NowPlayingTemplate({
})
const CarPlayNowPlaying: NowPlayingTemplate = new NowPlayingTemplate({})
export default CarPlayNowPlaying;
export default CarPlayNowPlaying

View File

@@ -1,38 +1,32 @@
import React from "react";
import { SafeAreaView } from "react-native-safe-area-context";
import { ScrollView } from "tamagui";
import RecentlyAdded from "./helpers/just-added";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { StackParamList } from "../types";
import { H2 } from "../Global/helpers/text";
import { useDiscoverContext } from "./provider";
import { RefreshControl } from "react-native";
import React from 'react'
import { SafeAreaView } from 'react-native-safe-area-context'
import { ScrollView } from 'tamagui'
import RecentlyAdded from './helpers/just-added'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { StackParamList } from '../types'
import { H2 } from '../Global/helpers/text'
import { useDiscoverContext } from './provider'
import { RefreshControl } from 'react-native'
export default function Index({
navigation
} : {
navigation : NativeStackNavigationProp<StackParamList>
}) : React.JSX.Element {
export default function Index({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { refreshing, refresh } = useDiscoverContext()
const { refreshing, refresh } = useDiscoverContext();
return (
<SafeAreaView edges={["top", "left", "right"]}>
<ScrollView
flexGrow={1}
contentInsetAdjustmentBehavior="automatic"
removeClippedSubviews
paddingBottom={"$15"}
refreshControl={(
<RefreshControl
refreshing={refreshing}
onRefresh={refresh}
/>
)}
>
<H2>{`Recently added`}</H2>
<RecentlyAdded navigation={navigation} />
</ScrollView>
</SafeAreaView>
)
}
return (
<SafeAreaView edges={['top', 'left', 'right']}>
<ScrollView
flexGrow={1}
contentInsetAdjustmentBehavior='automatic'
removeClippedSubviews
paddingBottom={'$15'}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} />}
>
<H2>{`Recently added`}</H2>
<RecentlyAdded navigation={navigation} />
</ScrollView>
</SafeAreaView>
)
}

View File

@@ -1,41 +1,40 @@
import { StackParamList } from "../../../components/types";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { QueryKeys } from "../../../enums/query-keys";
import HorizontalCardList from "../../../components/Global/components/horizontal-list";
import { ItemCard } from "../../../components/Global/components/item-card";
import { useDiscoverContext } from "../provider";
import { StackParamList } from '../../../components/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { QueryKeys } from '../../../enums/query-keys'
import HorizontalCardList from '../../../components/Global/components/horizontal-list'
import { ItemCard } from '../../../components/Global/components/item-card'
import { useDiscoverContext } from '../provider'
export default function RecentlyAdded({
navigation
} : {
navigation: NativeStackNavigationProp<StackParamList>
}) : React.JSX.Element {
export default function RecentlyAdded({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { recentlyAdded } = useDiscoverContext()
const { recentlyAdded } = useDiscoverContext();
return (
<HorizontalCardList
squared
data={recentlyAdded}
onSeeMore={() => {
navigation.navigate("Albums", {
query: QueryKeys.RecentlyAdded
})
}}
renderItem={({ item }) =>
<ItemCard
caption={item.Name}
subCaption={`${item.Artists?.join(", ")}`}
squared
size={"$12"}
item={item}
onPress={() => {
navigation.navigate("Album", {
album: item
})
}}
/>
}
/>
)
}
return (
<HorizontalCardList
squared
data={recentlyAdded}
onSeeMore={() => {
navigation.navigate('Albums', {
query: QueryKeys.RecentlyAdded,
})
}}
renderItem={({ item }) => (
<ItemCard
caption={item.Name}
subCaption={`${item.Artists?.join(', ')}`}
squared
size={'$12'}
item={item}
onPress={() => {
navigation.navigate('Album', {
album: item,
})
}}
/>
)}
/>
)
}

View File

@@ -1,73 +1,62 @@
import { useQuery } from "@tanstack/react-query";
import { fetchRecentlyAdded } from "../../api/queries/functions/recents";
import { QueryKeys } from "../../enums/query-keys";
import { createContext, ReactNode, useContext, useState } from "react";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from '@tanstack/react-query'
import { fetchRecentlyAdded } from '../../api/queries/functions/recents'
import { QueryKeys } from '../../enums/query-keys'
import { createContext, ReactNode, useContext, useState } from 'react'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
interface DiscoverContext {
refreshing: boolean;
refresh: () => void;
recentlyAdded: BaseItemDto[] | undefined;
interface DiscoverContext {
refreshing: boolean
refresh: () => void
recentlyAdded: BaseItemDto[] | undefined
}
const DiscoverContextInitializer = () => {
const [refreshing, setRefreshing] = useState<boolean>(false);
const [refreshing, setRefreshing] = useState<boolean>(false)
const { data: recentlyAdded, refetch } = useQuery({
queryKey: [QueryKeys.RecentlyAdded],
queryFn: () => fetchRecentlyAdded(),
staleTime: (1000 * 60 * 5) // 5 minutes
});
const { data: recentlyAdded, refetch } = useQuery({
queryKey: [QueryKeys.RecentlyAdded],
queryFn: () => fetchRecentlyAdded(),
staleTime: 1000 * 60 * 5, // 5 minutes
})
const refresh = async () => {
setRefreshing(true);
const refresh = async () => {
setRefreshing(true)
await Promise.all([
refetch()
])
setRefreshing(false);
}
await Promise.all([refetch()])
setRefreshing(false)
}
return {
refreshing,
refresh,
recentlyAdded
}
return {
refreshing,
refresh,
recentlyAdded,
}
}
const DiscoverContext = createContext<DiscoverContext>({
refreshing: false,
refresh: () =>{},
recentlyAdded: undefined
});
refreshing: false,
refresh: () => {},
recentlyAdded: undefined,
})
export const DiscoverProvider: ({
children
} : {
children: ReactNode
}) => React.JSX.Element = ({
children
} : {
children: ReactNode
export const DiscoverProvider: ({ children }: { children: ReactNode }) => React.JSX.Element = ({
children,
}: {
children: ReactNode
}) => {
const { refreshing, refresh, recentlyAdded } = DiscoverContextInitializer()
const {
refreshing,
refresh,
recentlyAdded
} = DiscoverContextInitializer();
return (
<DiscoverContext.Provider
value={{
refreshing,
refresh,
recentlyAdded
}}
>
{ children }
</DiscoverContext.Provider>
)
return (
<DiscoverContext.Provider
value={{
refreshing,
refresh,
recentlyAdded,
}}
>
{children}
</DiscoverContext.Provider>
)
}
export const useDiscoverContext = () => useContext(DiscoverContext);
export const useDiscoverContext = () => useContext(DiscoverContext)

View File

@@ -1,71 +1,63 @@
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { StackParamList } from "../types";
import Index from "./component";
import DetailsScreen from "../ItemDetail/screen";
import Player from "../Player/stack";
import Albums from "../Albums/component";
import { AlbumScreen } from "../Album";
import { ArtistScreen } from "../Artist";
import { DiscoverProvider } from "./provider";
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { StackParamList } from '../types'
import Index from './component'
import DetailsScreen from '../ItemDetail/screen'
import Player from '../Player/stack'
import Albums from '../Albums/component'
import { AlbumScreen } from '../Album'
import { ArtistScreen } from '../Artist'
import { DiscoverProvider } from './provider'
export const DiscoverStack = createNativeStackNavigator<StackParamList>();
export const DiscoverStack = createNativeStackNavigator<StackParamList>()
export function Discover(): React.JSX.Element {
return (
<DiscoverProvider>
<DiscoverStack.Navigator
initialRouteName="Discover"
screenOptions={{
return (
<DiscoverProvider>
<DiscoverStack.Navigator initialRouteName='Discover' screenOptions={{}}>
<DiscoverStack.Screen
name='Discover'
component={Index}
options={{
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold',
},
}}
/>
}}>
<DiscoverStack.Screen
name='Artist'
component={ArtistScreen}
options={({ route }) => ({
title: route.params.artist.Name ?? 'Unknown Artist',
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold',
},
})}
/>
<DiscoverStack.Screen
name="Discover"
component={Index}
options={{
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold'
}
}}
/>
<DiscoverStack.Screen
name='Album'
component={AlbumScreen}
options={({ route }) => ({
title: route.params.album.Name ?? 'Untitled Album',
headerTitle: '',
})}
/>
<DiscoverStack.Screen
name="Artist"
component={ArtistScreen}
options={({ route }) => ({
title: route.params.artist.Name ?? "Unknown Artist",
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold'
}
})}
/>
<DiscoverStack.Screen name='Albums' component={Albums} />
<DiscoverStack.Screen
name="Album"
component={AlbumScreen}
options={({ route }) => ({
title: route.params.album.Name ?? "Untitled Album",
headerTitle: ""
})}
/>
<DiscoverStack.Screen
name="Albums"
component={Albums}
/>
<DiscoverStack.Group screenOptions={{ presentation: "modal"}}>
<DiscoverStack.Screen
name="Details"
component={DetailsScreen}
options={{
headerShown: false,
}}
/>
</DiscoverStack.Group>
</DiscoverStack.Navigator>
</DiscoverProvider>
)
}
<DiscoverStack.Group screenOptions={{ presentation: 'modal' }}>
<DiscoverStack.Screen
name='Details'
component={DetailsScreen}
options={{
headerShown: false,
}}
/>
</DiscoverStack.Group>
</DiscoverStack.Navigator>
</DiscoverProvider>
)
}

View File

@@ -1,10 +1,10 @@
export const cardDimensions = {
artist: {
width: 150,
height: 150
},
album: {
width: 150,
height: 150
}
}
artist: {
width: 150,
height: 150,
},
album: {
width: 150,
height: 150,
},
}

View File

@@ -1,53 +1,45 @@
import type { AvatarProps as TamaguiAvatarProps } from "tamagui";
import { Avatar as TamaguiAvatar, YStack } from "tamagui"
import { Text } from "../helpers/text"
import { BaseItemDto, ImageType } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { QueryKeys } from "../../../enums/query-keys";
import { fetchItemImage } from "../../../api/queries/functions/images";
import type { AvatarProps as TamaguiAvatarProps } from 'tamagui'
import { Avatar as TamaguiAvatar, YStack } from 'tamagui'
import { Text } from '../helpers/text'
import { BaseItemDto, ImageType } from '@jellyfin/sdk/lib/generated-client/models'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../../enums/query-keys'
import { fetchItemImage } from '../../../api/queries/functions/images'
interface AvatarProps extends TamaguiAvatarProps {
item: BaseItemDto,
imageType?: ImageType,
subheading?: string | null | undefined;
item: BaseItemDto
imageType?: ImageType
subheading?: string | null | undefined
}
export default function Avatar({
item,
imageType,
subheading,
...props
item,
imageType,
subheading,
...props
}: AvatarProps): React.JSX.Element {
const { data } = useQuery({
queryKey: [
QueryKeys.ItemImage,
item.Id!,
imageType,
Math.ceil(150 / 100) * 100, // Images are fetched at a higher, generic resolution
Math.ceil(150 / 100) * 100, // So these keys need to match
],
queryFn: () => fetchItemImage(item.Id!, imageType ?? ImageType.Primary, 150, 150),
retry: 2,
gcTime: 1000 * 60, // 1 minute
staleTime: 1000 * 60, // 1 minute,
})
const { data } = useQuery({
queryKey: [
QueryKeys.ItemImage,
item.Id!,
imageType,
Math.ceil(150 / 100) * 100, // Images are fetched at a higher, generic resolution
Math.ceil(150 / 100) * 100 // So these keys need to match
],
queryFn: () => fetchItemImage(item.Id!, imageType ?? ImageType.Primary, 150, 150),
retry: 2,
gcTime: (1000 * 60), // 1 minute
staleTime: (1000 * 60) // 1 minute,
});
return (
<YStack alignItems="center" marginHorizontal={10}>
<TamaguiAvatar
borderRadius={!props.circular ? 4 : 'unset'}
{...props}
>
<TamaguiAvatar.Image src={data} />
<TamaguiAvatar.Fallback backgroundColor="$borderColor" />
</TamaguiAvatar>
{ props.children && (
<Text>{props.children}</Text>
)}
{ subheading && (
<Text bold>{ subheading }</Text>
)}
</YStack>
)
}
return (
<YStack alignItems='center' marginHorizontal={10}>
<TamaguiAvatar borderRadius={!props.circular ? 4 : 'unset'} {...props}>
<TamaguiAvatar.Image src={data ?? undefined} />
<TamaguiAvatar.Fallback backgroundColor='$borderColor' />
</TamaguiAvatar>
{props.children && <Text>{props.children}</Text>}
{subheading && <Text bold>{subheading}</Text>}
</YStack>
)
}

View File

@@ -1,97 +1,107 @@
import { BaseItemDto, ImageType } from "@jellyfin/sdk/lib/generated-client/models";
import { Blurhash } from "react-native-blurhash";
import { Square, View } from "tamagui";
import { isEmpty } from "lodash";
import { Image } from "react-native";
import { QueryKeys } from "../../../enums/query-keys";
import { useQuery } from "@tanstack/react-query";
import { fetchItemImage } from "../../../api/queries/functions/images";
import { BaseItemDto, ImageType } from '@jellyfin/sdk/lib/generated-client/models'
import { Blurhash } from 'react-native-blurhash'
import { Square, View } from 'tamagui'
import { isEmpty } from 'lodash'
import { Image } from 'react-native'
import { QueryKeys } from '../../../enums/query-keys'
import { useQuery } from '@tanstack/react-query'
import { fetchItemImage } from '../../../api/queries/functions/images'
interface BlurhashLoadingProps {
item: BaseItemDto;
width: number;
height?: number;
type?: ImageType;
borderRadius?: number | undefined
item: BaseItemDto
width: number
height?: number
type?: ImageType
borderRadius?: number | undefined
}
/**
* @deprecated
*
* Please use the `Image` component from
* @deprecated
*
* Please use the `Image` component from
* the `expo-image` module instead, as that is more performant
*
*
* A React component that will render a Blurhash
* string as an image while loading the full image
* from the server
*
*
* Image Query is stale after 30 minutes and collected
* after an hour to keep the cache size down and the
* after an hour to keep the cache size down and the
* app performant
*
*
* TODO: Keep images in offline mode
*
* @param param0
* @returns
*
* @param param0
* @returns
*/
export default function BlurhashedImage({
item,
width,
height,
type,
borderRadius
} : BlurhashLoadingProps) : React.JSX.Element {
export default function BlurhashedImage({
item,
width,
height,
type,
borderRadius,
}: BlurhashLoadingProps): React.JSX.Element {
const { data: image, isSuccess } = useQuery({
queryKey: [
QueryKeys.ItemImage,
item.AlbumId ? item.AlbumId : item.Id!,
type ?? ImageType.Primary,
Math.ceil(width / 100) * 100, // Images are fetched at a higher, generic resolution
Math.ceil(height ?? width / 100) * 100, // So these keys need to match
],
queryFn: () =>
fetchItemImage(
item.AlbumId ? item.AlbumId : item.Id!,
type ?? ImageType.Primary,
width,
height ?? width,
),
staleTime: 1000 * 60 * 30, // 30 minutes
gcTime: 1000 * 60 * 60, // 1 hour
refetchOnMount: false,
refetchOnWindowFocus: false,
})
const { data: image, isSuccess } = useQuery({
queryKey: [
QueryKeys.ItemImage,
item.AlbumId ? item.AlbumId : item.Id!,
type ?? ImageType.Primary,
Math.ceil(width / 100) * 100, // Images are fetched at a higher, generic resolution
Math.ceil(height ?? width / 100) * 100 // So these keys need to match
],
queryFn: () => fetchItemImage(item.AlbumId ? item.AlbumId : item.Id!, type ?? ImageType.Primary, width, height ?? width),
staleTime: (1000 * 60 * 30), // 30 minutes
gcTime: (1000 * 60 * 60), // 1 hour
refetchOnMount: false,
refetchOnWindowFocus: false
});
const blurhash =
!isEmpty(item.ImageBlurHashes) &&
!isEmpty(type ? item.ImageBlurHashes[type] : item.ImageBlurHashes.Primary)
? Object.values(type ? item.ImageBlurHashes[type]! : item.ImageBlurHashes.Primary!)[0]
: undefined
const blurhash = !isEmpty(item.ImageBlurHashes)
&& !isEmpty(type ? item.ImageBlurHashes[type] : item.ImageBlurHashes.Primary)
? Object.values(type ? item.ImageBlurHashes[type]! : item.ImageBlurHashes.Primary!)[0]
: undefined;
return (
<View minHeight={height ?? width} minWidth={width} borderRadius={borderRadius ? borderRadius : 25}>
{ isSuccess ? (
<Image
source={{
uri: image
}}
style={{
height: height ?? width,
width,
borderRadius: borderRadius ? borderRadius : 25,
resizeMode: "contain"
}}
/>
) : blurhash ? (
<Blurhash blurhash={blurhash!} style={{
height: height ?? width,
width: width,
borderRadius: borderRadius ? borderRadius : 25
}} />
) : (
<Square
backgroundColor="$amethyst"
width={width}
height={height ?? width}
borderRadius={borderRadius ? borderRadius : 25}
/>
)
}
</View>
)
}
return (
<View
minHeight={height ?? width}
minWidth={width}
borderRadius={borderRadius ? borderRadius : 25}>
{isSuccess ? (
<Image
source={{
uri: image ?? undefined,
}}
style={{
height: height ?? width,
width,
borderRadius: borderRadius ? borderRadius : 25,
resizeMode: 'contain',
}}
/>
) : blurhash ? (
<Blurhash
blurhash={blurhash!}
style={{
height: height ?? width,
width: width,
borderRadius: borderRadius ? borderRadius : 25,
}}
/>
) : (
<Square
backgroundColor='$amethyst'
width={width}
height={height ?? width}
borderRadius={borderRadius ? borderRadius : 25}
/>
)}
</View>
)
}

View File

@@ -1,113 +1,105 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useEffect, useState } from "react";
import Icon from "../helpers/icon";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isUndefined } from "lodash";
import { getTokens, Spinner } from "tamagui";
import Client from "../../../api/client";
import { usePlayerContext } from "../../..//player/provider";
import { queryClient } from "../../../constants/query-client";
import { QueryKeys } from "../../../enums/query-keys";
import { trigger } from "react-native-haptic-feedback";
import { fetchUserData } from "../../../api/queries/functions/favorites";
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import React, { useEffect, useState } from 'react'
import Icon from '../helpers/icon'
import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api'
import { useMutation, useQuery } from '@tanstack/react-query'
import { isUndefined } from 'lodash'
import { getTokens, Spinner } from 'tamagui'
import Client from '../../../api/client'
import { usePlayerContext } from '../../..//player/provider'
import { queryClient } from '../../../constants/query-client'
import { QueryKeys } from '../../../enums/query-keys'
import { trigger } from 'react-native-haptic-feedback'
import { fetchUserData } from '../../../api/queries/functions/favorites'
import * as Burnt from "burnt";
import * as Burnt from 'burnt'
interface SetFavoriteMutation {
item: BaseItemDto,
item: BaseItemDto
}
export default function FavoriteButton({
item,
onToggle
export default function FavoriteButton({
item,
onToggle,
}: {
item: BaseItemDto;
onToggle?: () => void
}) : React.JSX.Element {
item: BaseItemDto
onToggle?: () => void
}): React.JSX.Element {
const { nowPlaying, nowPlayingIsFavorite } = usePlayerContext()
const { nowPlaying, nowPlayingIsFavorite } = usePlayerContext();
const [isFavorite, setIsFavorite] = useState<boolean>(isFavoriteItem(item))
const [isFavorite, setIsFavorite] = useState<boolean>(isFavoriteItem(item));
const { data, isFetching, isFetched, refetch } = useQuery({
queryKey: [QueryKeys.UserData, item.Id!],
queryFn: () => fetchUserData(item.Id!),
})
const { data, isFetching, isFetched, refetch } = useQuery({
queryKey: [QueryKeys.UserData, item.Id!],
queryFn: () => fetchUserData(item.Id!),
});
const useSetFavorite = useMutation({
mutationFn: async (mutation: SetFavoriteMutation) => {
return getUserLibraryApi(Client.api!).markFavoriteItem({
itemId: mutation.item.Id!,
})
},
onSuccess: () => {
Burnt.alert({
title: `Added favorite`,
duration: 1,
preset: 'heart',
})
const useSetFavorite = useMutation({
mutationFn: async (mutation: SetFavoriteMutation) => {
return getUserLibraryApi(Client.api!)
.markFavoriteItem({
itemId: mutation.item.Id!
})
},
onSuccess: () => {
Burnt.alert({
title: `Added favorite`,
duration: 1,
preset: 'heart'
});
trigger('notificationSuccess')
trigger("notificationSuccess");
setIsFavorite(true)
onToggle ? onToggle() : {}
setIsFavorite(true);
onToggle ? onToggle() : {};
// Force refresh of track user data
queryClient.invalidateQueries({ queryKey: [QueryKeys.UserData, item.Id] })
},
})
// Force refresh of track user data
queryClient.invalidateQueries({ queryKey: [QueryKeys.UserData, item.Id] });
}
})
const useRemoveFavorite = useMutation({
mutationFn: async (mutation: SetFavoriteMutation) => {
return getUserLibraryApi(Client.api!)
.unmarkFavoriteItem({
itemId: mutation.item.Id!
})
},
onSuccess: () => {
Burnt.alert({
title: `Removed favorite`,
duration: 1,
preset: 'done'
});
const useRemoveFavorite = useMutation({
mutationFn: async (mutation: SetFavoriteMutation) => {
return getUserLibraryApi(Client.api!).unmarkFavoriteItem({
itemId: mutation.item.Id!,
})
},
onSuccess: () => {
Burnt.alert({
title: `Removed favorite`,
duration: 1,
preset: 'done',
})
trigger("notificationSuccess")
setIsFavorite(false);
onToggle ? onToggle(): {};
}
})
trigger('notificationSuccess')
setIsFavorite(false)
onToggle ? onToggle() : {}
},
})
const toggleFavorite = () => {
if (isFavorite)
useRemoveFavorite.mutate({ item })
else
useSetFavorite.mutate({ item })
}
const toggleFavorite = () => {
if (isFavorite) useRemoveFavorite.mutate({ item })
else useSetFavorite.mutate({ item })
}
useEffect(() => {
refetch();
}, [
item
]);
useEffect(() => {
refetch()
}, [item])
return (
isFetching && isUndefined(item.UserData) ? (
<Spinner />
) : (
<Icon
name={data?.IsFavorite ?? isFavorite ? "heart" : "heart-outline"}
color={getTokens().color.telemagenta.val}
onPress={toggleFavorite}
/>
)
)
return isFetching && isUndefined(item.UserData) ? (
<Spinner />
) : (
<Icon
name={data?.IsFavorite ?? isFavorite ? 'heart' : 'heart-outline'}
color={getTokens().color.telemagenta.val}
onPress={toggleFavorite}
/>
)
}
export function isFavoriteItem(item: BaseItemDto) : boolean {
return isUndefined(item.UserData) ? false
: isUndefined(item.UserData.IsFavorite) ? false
: item.UserData.IsFavorite
}
export function isFavoriteItem(item: BaseItemDto): boolean {
return isUndefined(item.UserData)
? false
: isUndefined(item.UserData.IsFavorite)
? false
: item.UserData.IsFavorite
}

View File

@@ -1,46 +1,31 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getToken, Spacer, YStack } from "tamagui";
import Icon from "../helpers/icon";
import { useQuery } from "@tanstack/react-query";
import { QueryKeys } from "../../../enums/query-keys";
import { fetchUserData } from "../../../api/queries/functions/favorites";
import { useEffect, useState } from "react";
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getToken, Spacer, YStack } from 'tamagui'
import Icon from '../helpers/icon'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../../enums/query-keys'
import { fetchUserData } from '../../../api/queries/functions/favorites'
import { useEffect, useState } from 'react'
export default function FavoriteIcon({
item
} : {
item: BaseItemDto
}) : React.JSX.Element {
export default function FavoriteIcon({ item }: { item: BaseItemDto }): React.JSX.Element {
const [isFavorite, setIsFavorite] = useState<boolean>(item.UserData?.IsFavorite ?? false)
const [isFavorite, setIsFavorite] = useState<boolean>(item.UserData?.IsFavorite ?? false);
const { data: userData } = useQuery({
queryKey: [QueryKeys.UserData, item.Id!],
queryFn: () => fetchUserData(item.Id!),
staleTime: 1000 * 60 * 5, // 5 minutes,
})
const { data: userData } = useQuery({
queryKey: [QueryKeys.UserData, item.Id!],
queryFn: () => fetchUserData(item.Id!),
staleTime: (1000 * 60 * 5) // 5 minutes,
});
useEffect(() => {
setIsFavorite(userData?.IsFavorite ?? false)
}, [userData])
useEffect(() => {
setIsFavorite(userData?.IsFavorite ?? false)
}, [
userData
])
return (
<YStack
alignContent="center"
justifyContent="center"
minWidth={24}
>
{ isFavorite ? (
<Icon
small
name="heart"
color={getToken("$color.telemagenta")}
/>
) : (
<Spacer />
)}
</YStack>
)
}
return (
<YStack alignContent='center' justifyContent='center' minWidth={24}>
{isFavorite ? (
<Icon small name='heart' color={getToken('$color.telemagenta')} />
) : (
<Spacer />
)}
</YStack>
)
}

View File

@@ -1,53 +1,48 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models/base-item-dto";
import React from "react";
import { FlatList, FlatListProps, ListRenderItem } from "react-native";
import IconCard from "../helpers/icon-card";
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import React from 'react'
import { FlatList, FlatListProps, ListRenderItem } from 'react-native'
import IconCard from '../helpers/icon-card'
interface HorizontalCardListProps extends FlatListProps<BaseItemDto> {
squared?: boolean | undefined;
/**
* The number of items that will be displayed before
* we cut it off and display a "Show More" card
*/
cutoff?: number | undefined;
onSeeMore?: () => void | undefined;
squared?: boolean | undefined
/**
* The number of items that will be displayed before
* we cut it off and display a "Show More" card
*/
cutoff?: number | undefined
onSeeMore?: () => void | undefined
}
/**
* Displays a Horizontal FlatList of 20 ItemCards
* then shows a "See More" button
* @param param0
* @returns
* @param param0
* @returns
*/
export default function HorizontalCardList({
onSeeMore,
squared = false,
...props
} : HorizontalCardListProps) : React.JSX.Element {
return (
<FlatList
horizontal
data={props.data}
renderItem={props.renderItem}
ListFooterComponent={() => {
return props.data && onSeeMore ? (
<IconCard
name={
squared
? "arrow-right-box"
: "arrow-right-circle"
}
circular={!squared}
caption="See More"
onPress={onSeeMore}
/>
) : undefined}
}
removeClippedSubviews
style={{
overflow: "hidden"
}}
/>
)
}
onSeeMore,
squared = false,
...props
}: HorizontalCardListProps): React.JSX.Element {
return (
<FlatList
horizontal
data={props.data}
renderItem={props.renderItem}
ListFooterComponent={() => {
return props.data && onSeeMore ? (
<IconCard
name={squared ? 'arrow-right-box' : 'arrow-right-circle'}
circular={!squared}
caption='See More'
onPress={onSeeMore}
/>
) : undefined
}}
removeClippedSubviews
style={{
overflow: 'hidden',
}}
/>
)
}

View File

@@ -1,42 +1,37 @@
import React from "react";
import type { CardProps as TamaguiCardProps } from "tamagui"
import { getToken, Card as TamaguiCard, View, YStack } from "tamagui";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Text } from "../helpers/text";
import { Image } from "expo-image";
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
import Client from "../../../api/client";
import React from 'react'
import type { CardProps as TamaguiCardProps } from 'tamagui'
import { getToken, Card as TamaguiCard, View, YStack } from 'tamagui'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { Text } from '../helpers/text'
import { Image } from 'expo-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../../../api/client'
interface CardProps extends TamaguiCardProps {
caption?: string | null | undefined;
subCaption?: string | null | undefined;
item: BaseItemDto;
squared?: boolean;
caption?: string | null | undefined
subCaption?: string | null | undefined
item: BaseItemDto
squared?: boolean
}
export function ItemCard(props: CardProps) {
return (
<View
alignItems="center"
margin={5}
>
<TamaguiCard
size={"$12"}
height={props.size}
width={props.size}
backgroundColor={getToken("$color.amethyst")}
circular={!props.squared}
borderRadius={props.squared ? 5 : 'unset'}
animation="bouncy"
hoverStyle={props.onPress ? { scale: 0.925 } : {}}
pressStyle={props.onPress ? { scale: 0.875 } : {}}
{...props}
>
<TamaguiCard.Header>
</TamaguiCard.Header>
<TamaguiCard.Footer padded>
{/* { props.item.Type === 'MusicArtist' && (
return (
<View alignItems='center' margin={5}>
<TamaguiCard
size={'$12'}
height={props.size}
width={props.size}
backgroundColor={getToken('$color.amethyst')}
circular={!props.squared}
borderRadius={props.squared ? 5 : 'unset'}
animation='bouncy'
hoverStyle={props.onPress ? { scale: 0.925 } : {}}
pressStyle={props.onPress ? { scale: 0.875 } : {}}
{...props}
>
<TamaguiCard.Header></TamaguiCard.Header>
<TamaguiCard.Footer padded>
{/* { props.item.Type === 'MusicArtist' && (
<BlurhashedImage
cornered
item={props.item}
@@ -45,49 +40,38 @@ export function ItemCard(props: CardProps) {
height={logoDimensions.height}
/>
)} */}
</TamaguiCard.Footer>
<TamaguiCard.Background>
<Image
source={getImageApi(Client.api!)
.getItemImageUrlById(
props.item.Type === 'Audio'
? props.item.AlbumId!
: props.item.Id!
)}
placeholder={props.item.ImageBlurHashes && props.item.ImageBlurHashes["Primary"] ? props.item.ImageBlurHashes["Primary"][0] : undefined}
style={{
width: '100%',
height: '100%',
borderRadius: props.squared ? 2 : 100
}}
/>
</TamaguiCard.Background>
</TamaguiCard>
{ props.caption && (
<YStack
alignContent="center"
alignItems="center"
maxWidth={props.size}
>
<Text
bold
lineBreakStrategyIOS="standard"
numberOfLines={1}
>
{ props.caption }
</Text>
{ props.subCaption && (
<Text
lineBreakStrategyIOS="standard"
numberOfLines={1}
textAlign="center"
>
{ props.subCaption }
</Text>
)}
</YStack>
)}
</View>
)
</TamaguiCard.Footer>
<TamaguiCard.Background>
<Image
source={getImageApi(Client.api!).getItemImageUrlById(
props.item.Type === 'Audio' ? props.item.AlbumId! : props.item.Id!,
)}
placeholder={
props.item.ImageBlurHashes && props.item.ImageBlurHashes['Primary']
? props.item.ImageBlurHashes['Primary'][0]
: undefined
}
style={{
width: '100%',
height: '100%',
borderRadius: props.squared ? 2 : 100,
}}
/>
</TamaguiCard.Background>
</TamaguiCard>
{props.caption && (
<YStack alignContent='center' alignItems='center' maxWidth={props.size}>
<Text bold lineBreakStrategyIOS='standard' numberOfLines={1}>
{props.caption}
</Text>
{props.subCaption && (
<Text lineBreakStrategyIOS='standard' numberOfLines={1} textAlign='center'>
{props.subCaption}
</Text>
)}
</YStack>
)}
</View>
)
}

View File

@@ -1,138 +1,122 @@
import { usePlayerContext } from "../../../player/provider";
import { StackParamList } from "../../../components/types";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { getTokens, Separator, Spacer, View, XStack, YStack } from "tamagui";
import { Text } from "../helpers/text";
import { useSafeAreaFrame } from "react-native-safe-area-context";
import BlurhashedImage from "./blurhashed-image";
import Icon from "../helpers/icon";
import { QueuingType } from "../../../enums/queuing-type";
import { RunTimeTicks } from "../helpers/time-codes";
import { usePlayerContext } from '../../../player/provider'
import { StackParamList } from '../../../components/types'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { getTokens, Separator, Spacer, View, XStack, YStack } from 'tamagui'
import { Text } from '../helpers/text'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import BlurhashedImage from './blurhashed-image'
import Icon from '../helpers/icon'
import { QueuingType } from '../../../enums/queuing-type'
import { RunTimeTicks } from '../helpers/time-codes'
export default function Item({
item,
queueName,
navigation,
} : {
item: BaseItemDto,
queueName: string,
navigation: NativeStackNavigationProp<StackParamList>
}) : React.JSX.Element {
export default function Item({
item,
queueName,
navigation,
}: {
item: BaseItemDto
queueName: string
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { usePlayNewQueue } = usePlayerContext()
const { usePlayNewQueue } = usePlayerContext();
const { width } = useSafeAreaFrame()
const { width } = useSafeAreaFrame();
return (
<View flex={1}>
<Separator />
return (
<View flex={1}>
<Separator />
<XStack
alignContent='center'
flex={1}
minHeight={width / 9}
onPress={() => {
switch (item.Type) {
case 'MusicArtist': {
navigation.navigate('Artist', {
artist: item,
})
break
}
<XStack
alignContent="center"
flex={1}
minHeight={width / 9}
onPress={() => {
switch (item.Type) {
case ("MusicArtist") : {
navigation.navigate("Artist", {
artist: item
})
break;
}
case 'MusicAlbum': {
navigation.navigate('Album', {
album: item,
})
break
}
case ("MusicAlbum") : {
navigation.navigate("Album", {
album: item
})
break;
}
case 'Audio': {
usePlayNewQueue.mutate({
track: item,
tracklist: [item],
queue: 'Search',
queuingType: QueuingType.FromSelection,
})
break
}
}
}}
onLongPress={() => {
navigation.navigate('Details', {
item,
isNested: false,
})
}}
paddingVertical={'$2'}
marginHorizontal={'$1'}
>
<BlurhashedImage
item={item}
width={width / 9}
borderRadius={item.Type === 'MusicArtist' ? width / 9 : 2}
/>
case ("Audio") : {
usePlayNewQueue.mutate({
track: item,
tracklist: [item],
queue: "Search",
queuingType: QueuingType.FromSelection
})
break;
}
}
<YStack
marginLeft={'$1'}
alignContent='center'
justifyContent='flex-start'
flex={3}
>
<Text bold lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.Name ?? ''}
</Text>
{(item.Type === 'Audio' || item.Type === 'MusicAlbum') && (
<Text lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.AlbumArtist ?? 'Untitled Artist'}
</Text>
)}
</YStack>
}}
onLongPress={() => {
navigation.navigate("Details", {
item,
isNested: false
})
}}
paddingVertical={"$2"}
marginHorizontal={"$1"}
>
<BlurhashedImage item={item} width={width / 9} borderRadius={item.Type === 'MusicArtist' ? width / 9 : 2}/>
<XStack justifyContent='space-between' alignItems='center' flex={1}>
{item.UserData?.IsFavorite ? (
<Icon small color={getTokens().color.telemagenta.val} name='heart' />
) : (
<Spacer />
)}
{/* Runtime ticks for Songs */}
{item.Type === 'Audio' ? (
<RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
) : (
<Spacer />
)}
<YStack
marginLeft={"$1"}
alignContent="center"
justifyContent="flex-start"
flex={3}
>
<Text
bold
lineBreakStrategyIOS="standard"
numberOfLines={1}
>
{ item.Name ?? ""}
</Text>
{ (item.Type === 'Audio' || item.Type === 'MusicAlbum') && (
<Text
lineBreakStrategyIOS="standard"
numberOfLines={1}
>
{ item.AlbumArtist ?? "Untitled Artist" }
</Text>
)}
</YStack>
<XStack
justifyContent="space-between"
alignItems="center"
flex={1}
>
{ item.UserData?.IsFavorite ? (
<Icon
small
color={getTokens().color.telemagenta.val}
name="heart"
/>
) : (
<Spacer />
)}
{/* Runtime ticks for Songs */}
{ item.Type ==='Audio' ? (
<RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
) : (
<Spacer />
)}
{(item.Type === 'Audio' || item.Type === 'MusicAlbum') ? (
<Icon
name="dots-horizontal"
onPress={() => {
navigation.navigate("Details", {
item,
isNested: false
})
}}
/>
) : (
<Spacer />
)}
</XStack>
</XStack>
</View>
)
}
{item.Type === 'Audio' || item.Type === 'MusicAlbum' ? (
<Icon
name='dots-horizontal'
onPress={() => {
navigation.navigate('Details', {
item,
isNested: false,
})
}}
/>
) : (
<Spacer />
)}
</XStack>
</XStack>
</View>
)
}

View File

@@ -1,9 +1,7 @@
import { ToastViewport } from '@tamagui/toast'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
export default function SafeToastViewport() : React.JSX.Element {
const { left, top, right } = useSafeAreaInsets()
return (
<ToastViewport flexDirection="column-reverse" top={top} left={left} right={right} />
)
}
export default function SafeToastViewport(): React.JSX.Element {
const { left, top, right } = useSafeAreaInsets()
return <ToastViewport flexDirection='column-reverse' top={top} left={left} right={right} />
}

View File

@@ -1,28 +1,28 @@
import {Toast as TamaguiToast, useToastState} from "@tamagui/toast"
import { YStack } from "tamagui"
import { Toast as TamaguiToast, useToastState } from '@tamagui/toast'
import { YStack } from 'tamagui'
export default function Toast() : React.JSX.Element | null {
const currentToast = useToastState()
if (!currentToast || currentToast.isHandledNatively) return null
return (
<TamaguiToast
key={currentToast.id}
duration={currentToast.duration}
enterStyle={{ opacity: 0, scale: 0.5, y: -25 }}
exitStyle={{ opacity: 0, scale: 1, y: -20 }}
y={0}
opacity={1}
scale={1}
animation="200ms"
viewportName={currentToast.viewportName}
>
<YStack>
<TamaguiToast.Title>{currentToast.title}</TamaguiToast.Title>
{!!currentToast.message && (
<TamaguiToast.Description>{currentToast.message}</TamaguiToast.Description>
)}
</YStack>
</TamaguiToast>
)
}
export default function Toast(): React.JSX.Element | null {
const currentToast = useToastState()
if (!currentToast || currentToast.isHandledNatively) return null
return (
<TamaguiToast
key={currentToast.id}
duration={currentToast.duration}
enterStyle={{ opacity: 0, scale: 0.5, y: -25 }}
exitStyle={{ opacity: 0, scale: 1, y: -20 }}
y={0}
opacity={1}
scale={1}
animation='200ms'
viewportName={currentToast.viewportName}
>
<YStack>
<TamaguiToast.Title>{currentToast.title}</TamaguiToast.Title>
{!!currentToast.message && (
<TamaguiToast.Description>{currentToast.message}</TamaguiToast.Description>
)}
</YStack>
</TamaguiToast>
)
}

View File

@@ -1,186 +1,163 @@
import { usePlayerContext } from "../../../player/provider";
import React from "react";
import { getToken, getTokens, Spacer, Theme, useTheme, XStack, YStack } from "tamagui";
import { Text } from "../helpers/text";
import { RunTimeTicks } from "../helpers/time-codes";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
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 { QueuingType } from "../../../enums/queuing-type";
import { Queue } from "../../../player/types/queue-item";
import FavoriteIcon from "./favorite-icon";
import { Image } from "expo-image";
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
import Client from "../../../api/client";
import { usePlayerContext } from '../../../player/provider'
import React from 'react'
import { getToken, getTokens, Spacer, Theme, useTheme, XStack, YStack } from 'tamagui'
import { Text } from '../helpers/text'
import { RunTimeTicks } from '../helpers/time-codes'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
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 { QueuingType } from '../../../enums/queuing-type'
import { Queue } from '../../../player/types/queue-item'
import FavoriteIcon from './favorite-icon'
import { Image } from 'expo-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../../../api/client'
interface TrackProps {
track: BaseItemDto;
navigation: NativeStackNavigationProp<StackParamList>;
tracklist?: BaseItemDto[] | undefined;
index?: number | undefined;
queue: Queue;
showArtwork?: boolean | undefined;
onPress?: () => void | undefined;
onLongPress?: () => void | undefined;
isNested?: boolean | undefined;
invertedColors?: boolean | undefined;
prependElement?: React.JSX.Element | undefined;
showRemove?: boolean | undefined;
onRemove?: () => void | undefined;
track: BaseItemDto
navigation: NativeStackNavigationProp<StackParamList>
tracklist?: BaseItemDto[] | undefined
index?: number | undefined
queue: Queue
showArtwork?: boolean | undefined
onPress?: () => void | undefined
onLongPress?: () => void | undefined
isNested?: boolean | undefined
invertedColors?: boolean | undefined
prependElement?: React.JSX.Element | undefined
showRemove?: boolean | undefined
onRemove?: () => void | undefined
}
export default function Track({
track,
tracklist,
navigation,
index,
queue,
showArtwork,
onPress,
onLongPress,
isNested,
invertedColors,
prependElement,
showRemove,
onRemove
} : TrackProps) : React.JSX.Element {
track,
tracklist,
navigation,
index,
queue,
showArtwork,
onPress,
onLongPress,
isNested,
invertedColors,
prependElement,
showRemove,
onRemove,
}: TrackProps): React.JSX.Element {
const theme = useTheme()
const { width } = useSafeAreaFrame()
const { nowPlaying, playQueue, usePlayNewQueue } = usePlayerContext()
const theme = useTheme();
const { width } = useSafeAreaFrame();
const { nowPlaying, playQueue, usePlayNewQueue } = usePlayerContext();
const isPlaying = nowPlaying?.item.Id === track.Id
const isPlaying = nowPlaying?.item.Id === track.Id;
return (
<Theme name={invertedColors ? 'inverted_purple' : undefined}>
<XStack
alignContent='center'
alignItems='center'
flex={1}
onPress={() => {
if (onPress) {
onPress()
} else {
usePlayNewQueue.mutate({
track,
index,
tracklist: tracklist ?? playQueue.map((track) => track.item),
queue,
queuingType: QueuingType.FromSelection,
})
}
}}
onLongPress={
onLongPress
? () => onLongPress()
: () => {
navigation.navigate('Details', {
item: track,
isNested: isNested,
})
}
}
paddingVertical={'$2'}
marginHorizontal={'$1'}
>
{prependElement && (
<YStack alignContent='center' justifyContent='center' flex={1}>
{prependElement}
</YStack>
)}
return (
<Theme name={invertedColors ? "inverted_purple" : undefined}>
<XStack
alignContent="center"
alignItems="center"
flex={1}
onPress={() => {
if (onPress) {
onPress();
} else {
usePlayNewQueue.mutate({
track,
index,
tracklist: tracklist ?? playQueue.map((track) => track.item),
queue,
queuingType: QueuingType.FromSelection
});
}
}}
onLongPress={
onLongPress ? () => onLongPress()
: () => {
navigation.navigate("Details", {
item: track,
isNested: isNested
})
}
}
paddingVertical={"$2"}
marginHorizontal={"$1"}
>
<XStack
alignContent='center'
justifyContent='center'
flex={showArtwork ? 2 : 1}
minHeight={showArtwork ? width / 9 : 'unset'}
>
{showArtwork ? (
<Image
source={getImageApi(Client.api!).getItemImageUrlById(track.AlbumId!)}
style={{
width: getToken('$12'),
height: getToken('$12'),
borderRadius: getToken('$1'),
}}
/>
) : (
<Text color={isPlaying ? getTokens().color.telemagenta : theme.color}>
{track.IndexNumber?.toString() ?? ''}
</Text>
)}
</XStack>
{ prependElement && (
<YStack
alignContent="center"
justifyContent="center"
flex={1}
>
{ prependElement }
</YStack>
)}
<YStack alignContent='center' justifyContent='flex-start' flex={6}>
<Text
bold
color={isPlaying ? getTokens().color.telemagenta : theme.color}
lineBreakStrategyIOS='standard'
numberOfLines={1}
>
{track.Name ?? 'Untitled Track'}
</Text>
<XStack
alignContent="center"
justifyContent="center"
flex={showArtwork ? 2 : 1}
minHeight={showArtwork ? width / 9 : "unset"}
>
{ showArtwork ? (
<Image
source={getImageApi(Client.api!).getItemImageUrlById(track.AlbumId!)}
style={{
width: getToken("$12"),
height: getToken("$12"),
borderRadius: getToken("$1")
}}
/>
) : (
<Text
color={isPlaying ? getTokens().color.telemagenta : theme.color}
>
{ track.IndexNumber?.toString() ?? "" }
</Text>
)}
</XStack>
{(showArtwork || (track.ArtistCount ?? 0 > 1)) && (
<Text lineBreakStrategyIOS='standard' numberOfLines={1}>
{track.Artists?.join(', ') ?? ''}
</Text>
)}
</YStack>
<YStack
alignContent="center"
justifyContent="flex-start"
flex={6}
>
<Text
bold
color={isPlaying ? getTokens().color.telemagenta : theme.color}
lineBreakStrategyIOS="standard"
numberOfLines={1}
>
{ track.Name ?? "Untitled Track" }
</Text>
<XStack
alignItems='center'
justifyContent='space-between'
alignContent='center'
flex={3}
>
<FavoriteIcon item={track} />
{ (showArtwork || (track.ArtistCount ?? 0 > 1)) && (
<Text
lineBreakStrategyIOS="standard"
numberOfLines={1}
>
{ track.Artists?.join(", ") ?? "" }
</Text>
)}
</YStack>
<YStack alignContent='center' justifyContent='space-around'>
<RunTimeTicks>{track.RunTimeTicks}</RunTimeTicks>
</YStack>
<XStack
alignItems="center"
justifyContent="space-between"
alignContent="center"
flex={3}
>
<FavoriteIcon item={track} />
<YStack
alignContent="center"
justifyContent="space-around"
>
<RunTimeTicks>{ track.RunTimeTicks }</RunTimeTicks>
</YStack>
<YStack
alignContent="center"
justifyContent="center"
>
<Icon
name={showRemove ? "close" : "dots-horizontal"}
onPress={() => {
if (showRemove) {
if (onRemove)
onRemove()
}
else {
navigation.navigate("Details", {
item: track,
isNested: isNested
});
}
}}
/>
</YStack>
</XStack>
</XStack>
</Theme>
)
}
<YStack alignContent='center' justifyContent='center'>
<Icon
name={showRemove ? 'close' : 'dots-horizontal'}
onPress={() => {
if (showRemove) {
if (onRemove) onRemove()
} else {
navigation.navigate('Details', {
item: track,
isNested: isNested,
})
}
}}
/>
</YStack>
</XStack>
</XStack>
</Theme>
)
}

View File

@@ -1,20 +1,14 @@
import { Button as TamaguiButton, ButtonProps as TamaguiButtonProps } from 'tamagui';
import { Button as TamaguiButton, ButtonProps as TamaguiButtonProps } from 'tamagui'
interface ButtonProps extends TamaguiButtonProps {
children?: Element | string | undefined;
onPress?: () => void | undefined;
disabled?: boolean | undefined;
danger?: boolean | undefined;
children?: Element | string | undefined
onPress?: () => void | undefined
disabled?: boolean | undefined
danger?: boolean | undefined
}
export default function Button(props: ButtonProps): React.JSX.Element {
return (
<TamaguiButton
bordered
opacity={props.disabled ? 0.5 : 1}
marginVertical={30}
{...props}
/>
)
}
return (
<TamaguiButton bordered opacity={props.disabled ? 0.5 : 1} marginVertical={30} {...props} />
)
}

View File

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

View File

@@ -1,56 +1,48 @@
import React from "react";
import { Square, Theme } from "tamagui";
import Icon from "./icon";
import { TouchableOpacity } from "react-native";
import { Text } from "./text";
import React from 'react'
import { Square, Theme } from 'tamagui'
import Icon from './icon'
import { TouchableOpacity } from 'react-native'
import { Text } from './text'
interface IconButtonProps {
onPress: () => void;
name: string;
title?: string | undefined;
circular?: boolean | undefined;
size?: number;
largeIcon?: boolean | undefined;
onPress: () => void
name: string
title?: string | undefined
circular?: boolean | undefined
size?: number
largeIcon?: boolean | undefined
}
export default function IconButton({
name,
onPress,
title,
circular,
size,
largeIcon
} : IconButtonProps) : React.JSX.Element {
name,
onPress,
title,
circular,
size,
largeIcon,
}: IconButtonProps): React.JSX.Element {
return (
<Theme name={'inverted_purple'}>
<TouchableOpacity>
<Square
animation={'bouncy'}
borderRadius={!circular ? '$4' : undefined}
circular={circular}
elevate
hoverStyle={{ scale: 0.925 }}
pressStyle={{ scale: 0.875 }}
onPress={onPress}
width={size}
height={size}
alignContent='center'
justifyContent='center'
backgroundColor={'$background'}
>
<Icon large={largeIcon} small={!largeIcon} name={name} color={'$color'} />
return (
<Theme name={"inverted_purple"}>
<TouchableOpacity>
<Square
animation={"bouncy"}
borderRadius={!circular ? "$4" : undefined}
circular={circular}
elevate
hoverStyle={{ scale: 0.925 }}
pressStyle={{ scale: 0.875 }}
onPress={onPress}
width={size}
height={size}
alignContent="center"
justifyContent="center"
backgroundColor={"$background"}
>
<Icon
large={largeIcon}
small={!largeIcon}
name={name}
color={"$color"}
/>
{ title && (
<Text>{ title }</Text>
)}
</Square>
</TouchableOpacity>
</Theme>
)
}
{title && <Text>{title}</Text>}
</Square>
</TouchableOpacity>
</Theme>
)
}

View File

@@ -1,57 +1,50 @@
import { Card, getTokens, View } from "tamagui";
import { H2, H4, H5 } from "./text";
import Icon from "./icon";
import { Card, getTokens, View } from 'tamagui'
import { H2, H4, H5 } from './text'
import Icon from './icon'
interface IconCardProps {
name: string;
circular?: boolean | undefined;
onPress: () => void;
width?: number | undefined
caption?: string | undefined;
largeIcon?: boolean | undefined
name: string
circular?: boolean | undefined
onPress: () => void
width?: number | undefined
caption?: string | undefined
largeIcon?: boolean | undefined
}
export default function IconCard({
name,
circular = false,
onPress,
width,
caption,
largeIcon
}: IconCardProps) : React.JSX.Element {
return (
<View
alignItems="center"
margin={5}
>
<Card
animation="bouncy"
borderRadius={circular ? 300 : 5}
hoverStyle={{ scale: 0.925 }}
pressStyle={{ scale: 0.875 }}
width={width ? width : "$12"}
height={width ? width : "$12"}
onPress={onPress}
>
<Card.Header>
<H5 color={getTokens().color.purpleDark}>{ caption ?? "" }</H5>
<Icon
color={getTokens().color.purpleDark.val}
name={name}
large={largeIcon}
small={!largeIcon}
/>
</Card.Header>
<Card.Footer padded>
</Card.Footer>
<Card.Background
backgroundColor={getTokens().color.telemagenta}
borderRadius={circular ? 300 : 5}
>
</Card.Background>
</Card>
</View>
)
}
export default function IconCard({
name,
circular = false,
onPress,
width,
caption,
largeIcon,
}: IconCardProps): React.JSX.Element {
return (
<View alignItems='center' margin={5}>
<Card
animation='bouncy'
borderRadius={circular ? 300 : 5}
hoverStyle={{ scale: 0.925 }}
pressStyle={{ scale: 0.875 }}
width={width ? width : '$12'}
height={width ? width : '$12'}
onPress={onPress}
>
<Card.Header>
<H5 color={getTokens().color.purpleDark}>{caption ?? ''}</H5>
<Icon
color={getTokens().color.purpleDark.val}
name={name}
large={largeIcon}
small={!largeIcon}
/>
</Card.Header>
<Card.Footer padded></Card.Footer>
<Card.Background
backgroundColor={getTokens().color.telemagenta}
borderRadius={circular ? 300 : 5}
></Card.Background>
</Card>
</View>
)
}

View File

@@ -1,42 +1,39 @@
import React from "react"
import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityIcons"
import { useTheme } from "tamagui";
import React from 'react'
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'
import { useTheme } from 'tamagui'
const smallSize = 24;
const smallSize = 24
const regularSize = 36;
const regularSize = 36
const largeSize = 48
const extraLargeSize = 96
export default function Icon({
name,
onPress,
small,
large,
extraLarge,
color
}: {
name: string,
onPress?: () => void,
small?: boolean,
large?: boolean,
extraLarge?: boolean,
color?: string | undefined
}) : React.JSX.Element {
const theme = useTheme();
const size = extraLarge? extraLargeSize : large ? largeSize : small ? smallSize : regularSize
return (
<MaterialCommunityIcons
color={color ? color
: theme.color.val
}
name={name}
onPress={onPress}
size={size}
/>
)
}
export default function Icon({
name,
onPress,
small,
large,
extraLarge,
color,
}: {
name: string
onPress?: () => void
small?: boolean
large?: boolean
extraLarge?: boolean
color?: string | undefined
}): React.JSX.Element {
const theme = useTheme()
const size = extraLarge ? extraLargeSize : large ? largeSize : small ? smallSize : regularSize
return (
<MaterialCommunityIcons
color={color ? color : theme.color.val}
name={name}
onPress={onPress}
size={size}
/>
)
}

View File

@@ -1,32 +1,20 @@
import React from 'react';
import { Input as TamaguiInput, InputProps as TamaguiInputProps, XStack, YStack} from 'tamagui';
import React from 'react'
import { Input as TamaguiInput, InputProps as TamaguiInputProps, XStack, YStack } from 'tamagui'
interface InputProps extends TamaguiInputProps {
prependElement?: React.JSX.Element | undefined;
prependElement?: React.JSX.Element | undefined
}
export default function Input(props: InputProps): React.JSX.Element {
return (
<XStack>
{props.prependElement && (
<YStack flex={1} alignItems='center' justifyContent='center'>
{props.prependElement}
</YStack>
)}
return (
<XStack>
{ props.prependElement && (
<YStack
flex={1}
alignItems='center'
justifyContent='center'
>
{ props.prependElement }
</YStack>
)}
<TamaguiInput
flex={props.prependElement ? 8 : 1}
{...props}
clearButtonMode="always"
/>
</XStack>
)
}
<TamaguiInput flex={props.prependElement ? 8 : 1} {...props} clearButtonMode='always' />
</XStack>
)
}

View File

@@ -1,22 +1,17 @@
import { SizeTokens, XStack, RadioGroup } from "tamagui"
import { Label } from "./text"
import { SizeTokens, XStack, RadioGroup } from 'tamagui'
import { Label } from './text'
export function RadioGroupItemWithLabel(props: {
size: SizeTokens
value: string
label: string
}) {
const id = `radiogroup-${props.value}`
return (
<XStack width={300} alignItems="center" space="$4">
<RadioGroup.Item value={props.value} id={id} size={props.size}>
<RadioGroup.Indicator />
</RadioGroup.Item>
<Label size={props.size} htmlFor={id}>
{props.label}
</Label>
</XStack>
)
}
export function RadioGroupItemWithLabel(props: { size: SizeTokens; value: string; label: string }) {
const id = `radiogroup-${props.value}`
return (
<XStack width={300} alignItems='center' space='$4'>
<RadioGroup.Item value={props.value} id={id} size={props.size}>
<RadioGroup.Indicator />
</RadioGroup.Item>
<Label size={props.size} htmlFor={id}>
{props.label}
</Label>
</XStack>
)
}

View File

@@ -1,56 +1,57 @@
import React from "react";
import { SliderProps as TamaguiSliderProps, Slider as TamaguiSlider, styled, Slider, getTokens, getToken } from "tamagui";
import React from 'react'
import {
SliderProps as TamaguiSliderProps,
Slider as TamaguiSlider,
styled,
Slider,
getTokens,
getToken,
} from 'tamagui'
interface SliderProps {
value?: number | undefined;
max: number;
width?: number | undefined
props: TamaguiSliderProps
value?: number | undefined
max: number
width?: number | undefined
props: TamaguiSliderProps
}
const JellifyActiveSliderTrack = styled(Slider.TrackActive, {
backgroundColor: getTokens().color.$telemagenta
backgroundColor: getTokens().color.$telemagenta,
})
const JellifySliderThumb = styled(Slider.Thumb, {
backgroundColor: getToken("$color.purpleDark"),
borderColor: getToken("$color.amethyst"),
backgroundColor: getToken('$color.purpleDark'),
borderColor: getToken('$color.amethyst'),
})
const JellifySliderTrack = styled(Slider.Track, {
backgroundColor: getToken("$color.amethyst")
});
export function HorizontalSlider({
value,
max,
width,
props
}: SliderProps) : React.JSX.Element {
return (
<TamaguiSlider
width={width}
value={value ? [value] : []}
max={max}
step={1}
orientation="horizontal"
marginHorizontal={10}
{ ...props }
>
<JellifySliderTrack size="$4">
<JellifyActiveSliderTrack size={"$4"} />
</JellifySliderTrack>
<JellifySliderThumb
circular
index={0}
size={"$1"}
hitSlop={{
top: 35,
right: 70,
bottom: 70,
left: 70
}}
/>
</TamaguiSlider>
)
}
backgroundColor: getToken('$color.amethyst'),
})
export function HorizontalSlider({ value, max, width, props }: SliderProps): React.JSX.Element {
return (
<TamaguiSlider
width={width}
value={value ? [value] : []}
max={max}
step={1}
orientation='horizontal'
marginHorizontal={10}
{...props}
>
<JellifySliderTrack size='$4'>
<JellifyActiveSliderTrack size={'$4'} />
</JellifySliderTrack>
<JellifySliderThumb
circular
index={0}
size={'$1'}
hitSlop={{
top: 35,
right: 70,
bottom: 70,
left: 70,
}}
/>
</TamaguiSlider>
)
}

View File

@@ -1,46 +1,44 @@
import { SizeTokens, XStack, Separator, Switch, Theme, styled, getToken } from "tamagui";
import { Label } from "./text";
import { useColorScheme } from "react-native";
import { SizeTokens, XStack, Separator, Switch, Theme, styled, getToken } from 'tamagui'
import { Label } from './text'
import { useColorScheme } from 'react-native'
interface SwitchWithLabelProps {
onCheckedChange: (value: boolean) => void,
size: SizeTokens
checked: boolean;
label: string;
width?: number | undefined;
onCheckedChange: (value: boolean) => void
size: SizeTokens
checked: boolean
label: string
width?: number | undefined
}
const JellifySliderThumb = styled(Switch.Thumb, {
borderColor: getToken("$color.amethyst"),
backgroundColor: getToken("$color.purpleDark")
borderColor: getToken('$color.amethyst'),
backgroundColor: getToken('$color.purpleDark'),
})
export function SwitchWithLabel(props: SwitchWithLabelProps) {
const isDarkMode = useColorScheme() === 'dark'
const isDarkMode = useColorScheme() === 'dark'
const id = `switch-${props.size.toString().slice(1)}-${props.checked ?? ''}}`
return (
<XStack alignItems="center" gap="$3">
<Label
size={props.size}
htmlFor={id}
>
{props.label}
</Label>
<Separator minHeight={20} vertical />
<Switch
id={id}
size={props.size}
checked={props.checked}
onCheckedChange={(checked: boolean) => props.onCheckedChange(checked)}
backgroundColor={props.checked ? getToken("$color.telemagenta") : getToken("$color.purpleGray")}
borderColor={isDarkMode ? getToken("$color.amethyst") : getToken("$color.purpleDark")}
>
<JellifySliderThumb animation="bouncy" />
</Switch>
</XStack>
)
}
const id = `switch-${props.size.toString().slice(1)}-${props.checked ?? ''}}`
return (
<XStack alignItems='center' gap='$3'>
<Label size={props.size} htmlFor={id}>
{props.label}
</Label>
<Separator minHeight={20} vertical />
<Switch
id={id}
size={props.size}
checked={props.checked}
onCheckedChange={(checked: boolean) => props.onCheckedChange(checked)}
backgroundColor={
props.checked ? getToken('$color.telemagenta') : getToken('$color.purpleGray')
}
borderColor={
isDarkMode ? getToken('$color.amethyst') : getToken('$color.purpleDark')
}
>
<JellifySliderThumb animation='bouncy' />
</Switch>
</XStack>
)
}

View File

@@ -1,101 +1,84 @@
import {
H1 as TamaguiH1,
H2 as TamaguiH2,
H3 as TamaguiH3,
H4 as TamaguiH4,
H5 as TamaguiH5,
Label as TamaguiLabel,
SizeTokens,
Paragraph,
TextProps as TamaguiTextProps
} from "tamagui"
import {
H1 as TamaguiH1,
H2 as TamaguiH2,
H3 as TamaguiH3,
H4 as TamaguiH4,
H5 as TamaguiH5,
Label as TamaguiLabel,
SizeTokens,
Paragraph,
TextProps as TamaguiTextProps,
} from 'tamagui'
interface LabelProps {
htmlFor: string,
children: string,
size: SizeTokens
htmlFor: string
children: string
size: SizeTokens
}
export function Label(props: LabelProps): React.JSX.Element {
return (
<TamaguiLabel fontWeight={600} htmlFor={props.htmlFor} justifyContent="flex-end">{ props.children }</TamaguiLabel>
)
return (
<TamaguiLabel fontWeight={600} htmlFor={props.htmlFor} justifyContent='flex-end'>
{props.children}
</TamaguiLabel>
)
}
export function H1({ children }: { children: string }): React.JSX.Element {
return (
<TamaguiH1
fontWeight={900}
marginBottom={10}
>
{ children }
</TamaguiH1>
)
return (
<TamaguiH1 fontWeight={900} marginBottom={10}>
{children}
</TamaguiH1>
)
}
export function H2(props: TamaguiTextProps): React.JSX.Element {
return (
<TamaguiH2
fontWeight={800}
marginVertical={7}
{...props}
>
{ props.children }
</TamaguiH2>
)
return (
<TamaguiH2 fontWeight={800} marginVertical={7} {...props}>
{props.children}
</TamaguiH2>
)
}
export function H3(props: TamaguiTextProps): React.JSX.Element {
return (
<TamaguiH3
fontWeight={800}
marginVertical={5}
{...props}
>
{ props.children }
</TamaguiH3>
)
return (
<TamaguiH3 fontWeight={800} marginVertical={5} {...props}>
{props.children}
</TamaguiH3>
)
}
export function H4(props: TamaguiTextProps): React.JSX.Element {
return (
<TamaguiH4
fontWeight={800}
marginVertical={3}
{...props}
>
{ props.children }
</TamaguiH4>
)
return (
<TamaguiH4 fontWeight={800} marginVertical={3} {...props}>
{props.children}
</TamaguiH4>
)
}
export function H5(props: TamaguiTextProps): React.JSX.Element {
return (
<TamaguiH5
{...props}
fontWeight={800}
marginVertical={2}
>
{ props.children }
</TamaguiH5>
)
return (
<TamaguiH5 {...props} fontWeight={800} marginVertical={2}>
{props.children}
</TamaguiH5>
)
}
interface TextProps extends TamaguiTextProps {
bold?: boolean | undefined
children: string;
bold?: boolean | undefined
children: string
}
export function Text(props: TextProps): React.JSX.Element {
return (
<Paragraph
fontWeight={props.bold ? 800 : 600}
fontSize="$4"
lineBreakMode="clip"
userSelect="none"
{...props}
>
{ props.children }
</Paragraph>
)
}
return (
<Paragraph
fontWeight={props.bold ? 800 : 600}
fontSize='$4'
lineBreakMode='clip'
userSelect='none'
{...props}
>
{props.children}
</Paragraph>
)
}

View File

@@ -1,48 +1,47 @@
import { convertRunTimeTicksToSeconds } from "../../../helpers/runtimeticks";
import { Text } from "./text";
import React from "react";
import { convertRunTimeTicksToSeconds } from '../../../helpers/runtimeticks'
import { Text } from './text'
import React from 'react'
export function RunTimeSeconds({ children }: { children: number }) : React.JSX.Element {
return <Text bold>{ calculateRunTimeFromSeconds(children) }</Text>
export function RunTimeSeconds({ children }: { children: number }): React.JSX.Element {
return <Text bold>{calculateRunTimeFromSeconds(children)}</Text>
}
export function RunTimeTicks({ children } : { children?: number | null | undefined }) : React.JSX.Element {
if (!children)
return <Text>0:00</Text>
export function RunTimeTicks({
children,
}: {
children?: number | null | undefined
}): React.JSX.Element {
if (!children) return <Text>0:00</Text>
const time = calculateRunTimeFromTicks(children);
const time = calculateRunTimeFromTicks(children)
return (
<Text
style={{display: "block"}}
color="$borderColor"
>
{ time }
</Text>
)
return (
<Text style={{ display: 'block' }} color='$borderColor'>
{time}
</Text>
)
}
function calculateRunTimeFromSeconds(seconds: number) : string {
const runTimeHours = Math.floor(seconds / 3600);
const runTimeMinutes = Math.floor((seconds % 3600) / 60)
const runTimeSeconds = Math.floor(seconds % 60);
function calculateRunTimeFromSeconds(seconds: number): string {
const runTimeHours = Math.floor(seconds / 3600)
const runTimeMinutes = Math.floor((seconds % 3600) / 60)
const runTimeSeconds = Math.floor(seconds % 60)
return (runTimeHours != 0 ? `${padRunTimeNumber(runTimeHours)}:` : "") +
(runTimeHours != 0 ? `${padRunTimeNumber(runTimeMinutes)}:` : `${runTimeMinutes}:`) +
(padRunTimeNumber(runTimeSeconds));
return (
(runTimeHours != 0 ? `${padRunTimeNumber(runTimeHours)}:` : '') +
(runTimeHours != 0 ? `${padRunTimeNumber(runTimeMinutes)}:` : `${runTimeMinutes}:`) +
padRunTimeNumber(runTimeSeconds)
)
}
function calculateRunTimeFromTicks(runTimeTicks: number) : string {
function calculateRunTimeFromTicks(runTimeTicks: number): string {
const runTimeTotalSeconds = convertRunTimeTicksToSeconds(runTimeTicks)
const runTimeTotalSeconds = convertRunTimeTicksToSeconds(runTimeTicks);
return calculateRunTimeFromSeconds(runTimeTotalSeconds);
return calculateRunTimeFromSeconds(runTimeTotalSeconds)
}
function padRunTimeNumber(number: number) : string {
if (number >= 10)
return `${number}`
function padRunTimeNumber(number: number): string {
if (number >= 10) return `${number}`
return `0${number}`;
}
return `0${number}`
}

View File

@@ -1,50 +1,44 @@
import { StackParamList } from "../types";
import { ScrollView, RefreshControl } from "react-native";
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 "../Global/helpers/text";
import Client from "../../api/client";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { StackParamList } from '../types'
import { ScrollView, RefreshControl } from 'react-native'
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 '../Global/helpers/text'
import Client from '../../api/client'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
export function ProvidedHome({
navigation
} : {
navigation: NativeStackNavigationProp<StackParamList>
export function ProvidedHome({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { refreshing: refetching, onRefresh } = useHomeContext()
const { refreshing: refetching, onRefresh } = useHomeContext()
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
refreshControl={<RefreshControl refreshing={refetching} onRefresh={onRefresh} />}
removeClippedSubviews // Save memory usage
>
<YStack alignContent='flex-start'>
<XStack margin={'$2'}>
<H3>{`Hi, ${Client.user!.name}`}</H3>
</XStack>
return (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl
refreshing={refetching}
onRefresh={onRefresh}
/>
}
removeClippedSubviews // Save memory usage
>
<YStack alignContent='flex-start'>
<XStack margin={"$2"}>
<H3>{`Hi, ${Client.user!.name}`}</H3>
</XStack>
<Separator marginVertical={'$2'} />
<Separator marginVertical={"$2"} />
<RecentArtists navigation={navigation} />
<RecentArtists navigation={navigation} />
<Separator marginVertical={'$3'} />
<Separator marginVertical={"$3"} />
<RecentlyPlayed navigation={navigation} />
<RecentlyPlayed navigation={navigation} />
<Separator marginVertical={'$3'} />
<Separator marginVertical={"$3"} />
<Playlists navigation={navigation}/>
</YStack>
</ScrollView>
);
}
<Playlists navigation={navigation} />
</YStack>
</ScrollView>
)
}

View File

@@ -1,42 +1,46 @@
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { ItemCard } from "../../Global/components/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, XStack } from "tamagui";
import { useQuery } from "@tanstack/react-query";
import { QueryKeys } from "../../../enums/query-keys";
import { fetchUserPlaylists } from "../../../api/queries/functions/playlists";
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { ItemCard } from '../../Global/components/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, XStack } from 'tamagui'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../../enums/query-keys'
import { fetchUserPlaylists } from '../../../api/queries/functions/playlists'
export default function Playlists({ navigation }: { navigation: NativeStackNavigationProp<StackParamList>}) : React.JSX.Element {
export default function Playlists({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { data: playlists } = useQuery({
queryKey: [QueryKeys.UserPlaylists],
queryFn: () => fetchUserPlaylists(),
})
const { data: playlists } = useQuery({
queryKey: [QueryKeys.UserPlaylists],
queryFn: () => fetchUserPlaylists()
});
return (
<View>
<XStack alignContent="center" marginHorizontal={"$2"}>
<H2 textAlign="left">Your Playlists</H2>
</XStack>
<FlatList horizontal
data={playlists}
renderItem={({ item: playlist }) =>
<ItemCard
item={playlist}
size={"$11"}
squared
caption={playlist.Name ?? "Untitled Playlist"}
onPress={() => {
navigation.navigate('Playlist', {
playlist
})
}}
/>
}
/>
</View>
)
}
return (
<View>
<XStack alignContent='center' marginHorizontal={'$2'}>
<H2 textAlign='left'>Your Playlists</H2>
</XStack>
<FlatList
horizontal
data={playlists}
renderItem={({ item: playlist }) => (
<ItemCard
item={playlist}
size={'$11'}
squared
caption={playlist.Name ?? 'Untitled Playlist'}
onPress={() => {
navigation.navigate('Playlist', {
playlist,
})
}}
/>
)}
/>
</View>
)
}

View File

@@ -1,42 +1,44 @@
import React, { useMemo } from "react";
import { View } from "tamagui";
import { useHomeContext } from "../provider";
import { H2 } from "../../Global/helpers/text";
import { StackParamList } from "../../types";
import { ItemCard } from "../../Global/components/item-card";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import HorizontalCardList from "../../../components/Global/components/horizontal-list";
import { QueryKeys } from "../../../enums/query-keys";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useMemo } from 'react'
import { View } from 'tamagui'
import { useHomeContext } from '../provider'
import { H2 } from '../../Global/helpers/text'
import { StackParamList } from '../../types'
import { ItemCard } from '../../Global/components/item-card'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import HorizontalCardList from '../../../components/Global/components/horizontal-list'
import { QueryKeys } from '../../../enums/query-keys'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
export default function RecentArtists({ navigation }: { navigation: NativeStackNavigationProp<StackParamList>}): React.JSX.Element {
export default function RecentArtists({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { recentArtists } = useHomeContext()
const { recentArtists } = useHomeContext();
return (
<View>
<H2 marginLeft={'$2'}>Recent Artists</H2>
return (
<View>
<H2 marginLeft={"$2"}>Recent Artists</H2>
<HorizontalCardList
data={recentArtists}
onSeeMore={() => {
navigation.navigate("Artists", {
query: QueryKeys.RecentlyPlayedArtists
})
}}
renderItem={({ item: recentArtist}) =>
<ItemCard
item={recentArtist}
caption={recentArtist.Name ?? "Unknown Artist"}
onPress={() => {
navigation.navigate('Artist',
{
artist: recentArtist,
}
)}
}>
</ItemCard>
}/>
</View>
)
}
<HorizontalCardList
data={recentArtists}
onSeeMore={() => {
navigation.navigate('Artists', {
query: QueryKeys.RecentlyPlayedArtists,
})
}}
renderItem={({ item: recentArtist }) => (
<ItemCard
item={recentArtist}
caption={recentArtist.Name ?? 'Unknown Artist'}
onPress={() => {
navigation.navigate('Artist', {
artist: recentArtist,
})
}}
></ItemCard>
)}
/>
</View>
)
}

View File

@@ -1,70 +1,64 @@
import React, { useMemo } from "react";
import { View } from "tamagui";
import { useHomeContext } from "../provider";
import { H2 } from "../../Global/helpers/text";
import { ItemCard } from "../../Global/components/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";
import { QueuingType } from "../../../enums/queuing-type";
import HorizontalCardList from "../../../components/Global/components/horizontal-list";
import { QueryKeys } from "../../../enums/query-keys";
import React, { useMemo } from 'react'
import { View } from 'tamagui'
import { useHomeContext } from '../provider'
import { H2 } from '../../Global/helpers/text'
import { ItemCard } from '../../Global/components/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'
import { QueuingType } from '../../../enums/queuing-type'
import HorizontalCardList from '../../../components/Global/components/horizontal-list'
import { QueryKeys } from '../../../enums/query-keys'
export default function RecentlyPlayed({
navigation
} : {
navigation: NativeStackNavigationProp<StackParamList>
export default function RecentlyPlayed({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { nowPlaying, usePlayNewQueue } = usePlayerContext()
const { recentTracks } = useHomeContext()
const { nowPlaying, usePlayNewQueue } = usePlayerContext();
const { recentTracks } = useHomeContext();
return useMemo(() => {
return (
<View>
<H2 marginLeft={'$2'}>Play it again</H2>
return (
useMemo(() => {
return (
<View>
<H2 marginLeft={"$2"}>Play it again</H2>
<HorizontalCardList
squared
data={recentTracks}
onSeeMore={() => {
navigation.navigate("Tracks", {
query: QueryKeys.RecentlyPlayed
})
}}
renderItem={({ index, item: recentlyPlayedTrack }) =>
<ItemCard
size={"$12"}
caption={recentlyPlayedTrack.Name}
subCaption={`${recentlyPlayedTrack.Artists?.join(", ")}`}
squared
item={recentlyPlayedTrack}
onPress={() => {
usePlayNewQueue.mutate({
track: recentlyPlayedTrack,
index: index,
tracklist: recentTracks ?? [recentlyPlayedTrack],
queue: "Recently Played",
queuingType: QueuingType.FromSelection
});
}}
onLongPress={() => {
trigger("impactMedium");
navigation.navigate("Details", {
item: recentlyPlayedTrack,
isNested: false
})
}}
/>
}
/>
</View>
)
}, [
recentTracks,
nowPlaying
])
)
}
<HorizontalCardList
squared
data={recentTracks}
onSeeMore={() => {
navigation.navigate('Tracks', {
query: QueryKeys.RecentlyPlayed,
})
}}
renderItem={({ index, item: recentlyPlayedTrack }) => (
<ItemCard
size={'$12'}
caption={recentlyPlayedTrack.Name}
subCaption={`${recentlyPlayedTrack.Artists?.join(', ')}`}
squared
item={recentlyPlayedTrack}
onPress={() => {
usePlayNewQueue.mutate({
track: recentlyPlayedTrack,
index: index,
tracklist: recentTracks ?? [recentlyPlayedTrack],
queue: 'Recently Played',
queuingType: QueuingType.FromSelection,
})
}}
onLongPress={() => {
trigger('impactMedium')
navigation.navigate('Details', {
item: recentlyPlayedTrack,
isNested: false,
})
}}
/>
)}
/>
</View>
)
}, [recentTracks, nowPlaying])
}

View File

@@ -1,89 +1,91 @@
import React, { createContext, ReactNode, useContext, useState } from "react";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { QueryKeys } from "../../enums/query-keys";
import { fetchRecentlyPlayed, fetchRecentlyPlayedArtists } from "../../api/queries/functions/recents";
import { queryClient } from "../../constants/query-client";
import { QueryConfig } from "../../api/queries/query.config";
import React, { createContext, ReactNode, useContext, useState } from 'react'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../enums/query-keys'
import {
fetchRecentlyPlayed,
fetchRecentlyPlayedArtists,
} from '../../api/queries/functions/recents'
import { queryClient } from '../../constants/query-client'
import { QueryConfig } from '../../api/queries/query.config'
interface HomeContext {
refreshing: boolean;
onRefresh: () => void;
recentArtists: BaseItemDto[] | undefined;
recentTracks: BaseItemDto[] | undefined;
refreshing: boolean
onRefresh: () => void
recentArtists: BaseItemDto[] | undefined
recentTracks: BaseItemDto[] | undefined
}
const HomeContextInitializer = () => {
const [refreshing, setRefreshing] = useState<boolean>(false);
const [refreshing, setRefreshing] = useState<boolean>(false)
const { data : recentTracks, refetch : refetchRecentTracks } = useQuery({
queryKey: [QueryKeys.RecentlyPlayed],
queryFn: () => fetchRecentlyPlayed()
});
const { data : recentArtists, refetch : refetchRecentArtists } = useQuery({
queryKey: [QueryKeys.RecentlyPlayedArtists],
queryFn: () => fetchRecentlyPlayedArtists()
});
const { data: recentTracks, refetch: refetchRecentTracks } = useQuery({
queryKey: [QueryKeys.RecentlyPlayed],
queryFn: () => fetchRecentlyPlayed(),
})
const { data: recentArtists, refetch: refetchRecentArtists } = useQuery({
queryKey: [QueryKeys.RecentlyPlayedArtists],
queryFn: () => fetchRecentlyPlayedArtists(),
})
const onRefresh = async () => {
setRefreshing(true);
const onRefresh = async () => {
setRefreshing(true)
queryClient.invalidateQueries({
queryKey: [QueryKeys.RecentlyPlayedArtists, QueryConfig.limits.recents * 4, QueryConfig.limits.recents]
});
queryClient.invalidateQueries({
queryKey: [
QueryKeys.RecentlyPlayedArtists,
QueryConfig.limits.recents * 4,
QueryConfig.limits.recents,
],
})
queryClient.invalidateQueries({
queryKey: [QueryKeys.RecentlyPlayed, QueryConfig.limits.recents * 4, QueryConfig.limits.recents]
});
await Promise.all([
refetchRecentTracks(),
refetchRecentArtists()
])
queryClient.invalidateQueries({
queryKey: [
QueryKeys.RecentlyPlayed,
QueryConfig.limits.recents * 4,
QueryConfig.limits.recents,
],
})
setRefreshing(false);
}
await Promise.all([refetchRecentTracks(), refetchRecentArtists()])
return {
refreshing,
onRefresh,
recentArtists,
recentTracks,
};
setRefreshing(false)
}
return {
refreshing,
onRefresh,
recentArtists,
recentTracks,
}
}
const HomeContext = createContext<HomeContext>({
refreshing: false,
onRefresh: () => {},
recentArtists: undefined,
recentTracks: undefined
});
refreshing: false,
onRefresh: () => {},
recentArtists: undefined,
recentTracks: undefined,
})
export const HomeProvider: ({ children }: {
children: ReactNode;
}) => React.JSX.Element = ({
children
} : {
children: ReactNode
export const HomeProvider: ({ children }: { children: ReactNode }) => React.JSX.Element = ({
children,
}: {
children: ReactNode
}) => {
const { refreshing, onRefresh, recentTracks, recentArtists } = HomeContextInitializer()
const {
refreshing,
onRefresh,
recentTracks,
recentArtists,
} = HomeContextInitializer();
return (
<HomeContext.Provider value={{
refreshing,
onRefresh,
recentTracks,
recentArtists
}}>
{ children }
</HomeContext.Provider>
)
return (
<HomeContext.Provider
value={{
refreshing,
onRefresh,
recentTracks,
recentArtists,
}}
>
{children}
</HomeContext.Provider>
)
}
export const useHomeContext = () => useContext(HomeContext);
export const useHomeContext = () => useContext(HomeContext)

View File

@@ -1,90 +1,76 @@
import _ from "lodash";
import { HomeProvider } from "./provider";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { StackParamList } from "../types";
import { AlbumScreen } from "../Album";
import { PlaylistScreen } from "../Playlist/screens";
import { ProvidedHome } from "./component";
import DetailsScreen from "../ItemDetail/screen";
import AddPlaylist from "../Library/components/add-playlist";
import ArtistsScreen from "../Artists/screen";
import TracksScreen from "../Tracks/screen";
import { ArtistScreen } from "../Artist";
import _ from 'lodash'
import { HomeProvider } from './provider'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { StackParamList } from '../types'
import { AlbumScreen } from '../Album'
import { PlaylistScreen } from '../Playlist/screens'
import { ProvidedHome } from './component'
import DetailsScreen from '../ItemDetail/screen'
import AddPlaylist from '../Library/components/add-playlist'
import ArtistsScreen from '../Artists/screen'
import TracksScreen from '../Tracks/screen'
import { ArtistScreen } from '../Artist'
const Stack = createNativeStackNavigator<StackParamList>();
const Stack = createNativeStackNavigator<StackParamList>()
export default function Home(): React.JSX.Element {
return (
<HomeProvider>
<Stack.Navigator initialRouteName='Home' screenOptions={{}}>
<Stack.Group>
<Stack.Screen name='Home' component={ProvidedHome} />
return (
<HomeProvider>
<Stack.Navigator
initialRouteName="Home"
screenOptions={{
}}
>
<Stack.Group>
<Stack.Screen
name="Home"
component={ProvidedHome}
/>
<Stack.Screen
name='Artist'
component={ArtistScreen}
options={({ route }) => ({
title: route.params.artist.Name ?? 'Unknown Artist',
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold',
},
})}
/>
<Stack.Screen
name="Artist"
component={ArtistScreen}
options={({ route }) => ({
title: route.params.artist.Name ?? "Unknown Artist",
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold'
}
})}
/>
<Stack.Screen name='Artists' component={ArtistsScreen} />
<Stack.Screen
name="Artists"
component={ArtistsScreen}
/>
<Stack.Screen
name='Tracks'
component={TracksScreen}
options={{
title: 'Recent Tracks',
}}
/>
<Stack.Screen
name="Tracks"
component={TracksScreen}
options={{
title: "Recent Tracks"
}}
/>
<Stack.Screen
name='Album'
component={AlbumScreen}
options={({ route }) => ({
title: route.params.album.Name ?? 'Untitled Album',
headerTitle: '',
})}
/>
<Stack.Screen
name="Album"
component={AlbumScreen}
options={({ route }) => ({
title: route.params.album.Name ?? "Untitled Album",
headerTitle: ""
})}
/>
<Stack.Screen
name='Playlist'
component={PlaylistScreen}
options={({ route }) => ({
headerShown: true,
headerTitle: '',
})}
/>
</Stack.Group>
<Stack.Screen
name="Playlist"
component={PlaylistScreen}
options={({ route }) => ({
headerShown: true,
headerTitle: ""
})}
/>
</Stack.Group>
<Stack.Group screenOptions={{ presentation: 'modal' }}>
<Stack.Screen
name="Details"
component={DetailsScreen}
options={{
headerShown: false,
}}
/>
</Stack.Group>
</Stack.Navigator>
</HomeProvider>
);
}
<Stack.Group screenOptions={{ presentation: 'modal' }}>
<Stack.Screen
name='Details'
component={DetailsScreen}
options={{
headerShown: false,
}}
/>
</Stack.Group>
</Stack.Navigator>
</HomeProvider>
)
}

View File

@@ -1,180 +1,153 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useSafeAreaFrame } from "react-native-safe-area-context";
import { StackParamList } from "../types";
import TrackOptions from "./helpers/TrackOptions";
import { getToken, getTokens, ScrollView, Spacer, View, XStack, YStack } from "tamagui";
import { Text } from "../Global/helpers/text";
import FavoriteButton from "../Global/components/favorite-button";
import { useEffect } from "react";
import { trigger } from "react-native-haptic-feedback";
import TextTicker from "react-native-text-ticker";
import { TextTickerConfig } from "../Player/component.config";
import { Image } from "expo-image";
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
import Client from "../../api/client";
import Icon from "../Global/helpers/icon";
import { Platform } from "react-native";
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { StackParamList } from '../types'
import TrackOptions from './helpers/TrackOptions'
import { getToken, getTokens, ScrollView, Spacer, View, XStack, YStack } from 'tamagui'
import { Text } from '../Global/helpers/text'
import FavoriteButton from '../Global/components/favorite-button'
import { useEffect } from 'react'
import { trigger } from 'react-native-haptic-feedback'
import TextTicker from 'react-native-text-ticker'
import { TextTickerConfig } from '../Player/component.config'
import { Image } from 'expo-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../../api/client'
import Icon from '../Global/helpers/icon'
import { Platform } from 'react-native'
export default function ItemDetail({
item,
navigation,
isNested
} : {
item: BaseItemDto,
navigation: NativeStackNavigationProp<StackParamList>,
isNested?: boolean | undefined
}) : React.JSX.Element {
export default function ItemDetail({
item,
navigation,
isNested,
}: {
item: BaseItemDto
navigation: NativeStackNavigationProp<StackParamList>
isNested?: boolean | undefined
}): React.JSX.Element {
let options: React.JSX.Element | undefined = undefined
let options: React.JSX.Element | undefined = undefined;
useEffect(() => {
trigger('impactMedium')
}, [item])
useEffect(() => {
trigger("impactMedium");
}, [
item
]);
const { width } = useSafeAreaFrame()
const { width } = useSafeAreaFrame();
switch (item.Type) {
case 'Audio': {
options = TrackOptions({ track: item, navigation, isNested })
break
}
switch (item.Type) {
case "Audio": {
options = TrackOptions({ track: item, navigation, isNested });
break;
}
case 'MusicAlbum': {
break
}
case "MusicAlbum" : {
case 'MusicArtist': {
break
}
break;
}
case 'Playlist': {
break
}
case "MusicArtist" : {
default: {
break
}
}
break;
}
return (
<ScrollView contentInsetAdjustmentBehavior='automatic' removeClippedSubviews>
<YStack alignItems='center' flex={1} marginTop={'$4'}>
<XStack
justifyContent='center'
alignItems='flex-start'
minHeight={width / 1.5}
minWidth={width / 1.5}
>
{/**
* Android needs a dismiss chevron here
*/}
{Platform.OS === 'android' ? (
<Icon
name='chevron-down'
onPress={() => {
navigation.goBack()
}}
small
/>
) : (
<Spacer />
)}
case "Playlist" : {
<Spacer />
break;
}
<Image
source={getImageApi(Client.api!).getItemImageUrlById(
item.Type === 'Audio' ? item.AlbumId! : item.Id!,
)}
style={{
width: width / 1.5,
height: width / 1.5,
borderRadius:
item.Type === 'MusicArtist' ? width / 1.5 : getToken('$5'),
}}
/>
default : {
break;
}
}
<Spacer />
<Spacer />
</XStack>
return (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
removeClippedSubviews
>
<YStack
alignItems="center"
flex={1}
marginTop={"$4"}
>
{/* Item Name, Artist, Album, and Favorite Button */}
<XStack maxWidth={width / 1.5}>
<YStack
marginLeft={'$0.5'}
alignItems='flex-start'
alignContent='flex-start'
justifyContent='flex-start'
flex={3}
>
<TextTicker {...TextTickerConfig}>
<Text bold fontSize={'$6'}>
{item.Name ?? 'Untitled Track'}
</Text>
</TextTicker>
<XStack
justifyContent="center"
alignItems="flex-start"
minHeight={width / 1.5}
minWidth={width / 1.5}
>
{/**
* Android needs a dismiss chevron here
*/}
{ Platform.OS === 'android' ? (
<Icon
name="chevron-down"
onPress={() => {
navigation.goBack();
}}
small
/>
<TextTicker {...TextTickerConfig}>
<Text
fontSize={'$6'}
color={getTokens().color.telemagenta}
onPress={() => {
if (item.ArtistItems) {
if (isNested) navigation.getParent()!.goBack()
) : (
<Spacer />
)}
navigation.goBack()
navigation.navigate('Artist', {
artist: item.ArtistItems[0],
})
}
}}
>
{item.Artists?.join(', ') ?? 'Unknown Artist'}
</Text>
</TextTicker>
<Spacer />
<TextTicker {...TextTickerConfig}>
<Text fontSize={'$6'} color={'$borderColor'}>
{item.Album ?? ''}
</Text>
</TextTicker>
</YStack>
<Image
source={getImageApi(Client.api!)
.getItemImageUrlById(
item.Type === 'Audio'
? item.AlbumId!
: item.Id!
)
}
style={{
width: width / 1.5,
height: width / 1.5,
borderRadius: item.Type === "MusicArtist" ? width / 1.5 : getToken("$5")
}}
/>
<YStack flex={1} alignItems='flex-end' justifyContent='center'>
<FavoriteButton item={item} />
</YStack>
</XStack>
<Spacer />
<Spacer />
</XStack>
<Spacer />
{/* Item Name, Artist, Album, and Favorite Button */}
<XStack maxWidth={width / 1.5}>
<YStack
marginLeft={"$0.5"}
alignItems="flex-start"
alignContent="flex-start"
justifyContent="flex-start"
flex={3}
>
<TextTicker {...TextTickerConfig}>
<Text bold fontSize={"$6"}>
{ item.Name ?? "Untitled Track" }
</Text>
</TextTicker>
<TextTicker {...TextTickerConfig}>
<Text
fontSize={"$6"}
color={getTokens().color.telemagenta}
onPress={() => {
if (item.ArtistItems) {
if (isNested)
navigation.getParent()!.goBack();
navigation.goBack();
navigation.navigate("Artist", {
artist: item.ArtistItems[0]
});
}
}}>
{ item.Artists?.join(", ") ?? "Unknown Artist"}
</Text>
</TextTicker>
<TextTicker {...TextTickerConfig}>
<Text
fontSize={"$6"}
color={"$borderColor"}
>
{ item.Album ?? "" }
</Text>
</TextTicker>
</YStack>
<YStack
flex={1}
alignItems="flex-end"
justifyContent="center"
>
<FavoriteButton item={item} />
</YStack>
</XStack>
<Spacer />
{ options ?? <View /> }
</YStack>
</ScrollView>
)
}
{options ?? <View />}
</YStack>
</ScrollView>
)
}

View File

@@ -1,216 +1,217 @@
import { usePlayerContext } from "../../../player/provider";
import { StackParamList } from "../../../components/types";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { getToken, getTokens, ListItem, Separator, Spacer, Spinner, XStack, YGroup, YStack } from "tamagui";
import { QueuingType } from "../../../enums/queuing-type";
import { useSafeAreaFrame } from "react-native-safe-area-context";
import IconButton from "../../../components/Global/helpers/icon-button";
import { Text } from "../../../components/Global/helpers/text";
import React from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AddToPlaylistMutation } from "../types";
import { addToPlaylist } from "../../../api/mutations/functions/playlists";
import { trigger } from "react-native-haptic-feedback";
import { queryClient } from "../../../constants/query-client";
import { QueryKeys } from "../../../enums/query-keys";
import { fetchItem } from "../../../api/queries/functions/item";
import { fetchUserPlaylists } from "../../../api/queries/functions/playlists";
import { usePlayerContext } from '../../../player/provider'
import { StackParamList } from '../../../components/types'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import {
getToken,
getTokens,
ListItem,
Separator,
Spacer,
Spinner,
XStack,
YGroup,
YStack,
} from 'tamagui'
import { QueuingType } from '../../../enums/queuing-type'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import IconButton from '../../../components/Global/helpers/icon-button'
import { Text } from '../../../components/Global/helpers/text'
import React from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { AddToPlaylistMutation } from '../types'
import { addToPlaylist } from '../../../api/mutations/functions/playlists'
import { trigger } from 'react-native-haptic-feedback'
import { queryClient } from '../../../constants/query-client'
import { QueryKeys } from '../../../enums/query-keys'
import { fetchItem } from '../../../api/queries/functions/item'
import { fetchUserPlaylists } from '../../../api/queries/functions/playlists'
import * as Burnt from "burnt";
import { Image } from "expo-image";
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
import Client from "../../../api/client";
import * as Burnt from 'burnt'
import { Image } from 'expo-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../../../api/client'
interface TrackOptionsProps {
track: BaseItemDto;
navigation: NativeStackNavigationProp<StackParamList>;
/**
* Whether this is nested in the player modal
*/
isNested: boolean | undefined;
track: BaseItemDto
navigation: NativeStackNavigationProp<StackParamList>
/**
* Whether this is nested in the player modal
*/
isNested: boolean | undefined
}
export default function TrackOptions({
track,
navigation,
isNested
} : TrackOptionsProps) : React.JSX.Element {
export default function TrackOptions({
track,
navigation,
isNested,
}: TrackOptionsProps): React.JSX.Element {
const { data: album, isSuccess: albumFetchSuccess } = useQuery({
queryKey: [QueryKeys.Item, track.AlbumId!],
queryFn: () => fetchItem(track.AlbumId!),
})
const { data: album, isSuccess: albumFetchSuccess } = useQuery({
queryKey: [QueryKeys.Item, track.AlbumId!],
queryFn: () => fetchItem(track.AlbumId!)
});
const {
data: playlists,
isPending: playlistsFetchPending,
isSuccess: playlistsFetchSuccess,
refetch,
} = useQuery({
queryKey: [QueryKeys.UserPlaylists],
queryFn: () => fetchUserPlaylists(),
})
const { data: playlists, isPending : playlistsFetchPending, isSuccess: playlistsFetchSuccess, refetch } = useQuery({
queryKey: [QueryKeys.UserPlaylists],
queryFn: () => fetchUserPlaylists()
});
const { useAddToQueue } = usePlayerContext()
const { useAddToQueue } = usePlayerContext();
const { width } = useSafeAreaFrame()
const { width } = useSafeAreaFrame();
const useAddToPlaylist = useMutation({
mutationFn: ({ track, playlist }: AddToPlaylistMutation) => {
trigger('impactLight')
return addToPlaylist(track, playlist)
},
onSuccess: (data, { playlist }) => {
Burnt.alert({
title: `Added to playlist`,
duration: 1,
preset: 'done',
})
const useAddToPlaylist = useMutation({
mutationFn: ({ track, playlist }: AddToPlaylistMutation) => {
trigger("impactLight");
return addToPlaylist(track, playlist)
},
onSuccess: (data, { playlist }) => {
trigger('notificationSuccess')
Burnt.alert({
title: `Added to playlist`,
duration: 1,
preset: 'done'
});
queryClient.invalidateQueries({
queryKey: [QueryKeys.UserPlaylists],
})
trigger("notificationSuccess");
queryClient.invalidateQueries({
queryKey: [QueryKeys.ItemTracks, playlist.Id!],
})
},
onError: () => {
Burnt.alert({
title: `Unable to add`,
duration: 1,
preset: 'error',
})
queryClient.invalidateQueries({
queryKey: [QueryKeys.UserPlaylists]
});
queryClient.invalidateQueries({
queryKey: [QueryKeys.ItemTracks, playlist.Id!],
});
},
onError: () => {
Burnt.alert({
title: `Unable to add`,
duration: 1,
preset: 'error'
});
trigger('notificationError')
},
})
trigger("notificationError")
}
})
return (
<YStack width={width}>
return (
<YStack width={width}>
<XStack justifyContent='space-evenly'>
{albumFetchSuccess && album ? (
<IconButton
name='music-box'
title='Go to Album'
onPress={() => {
if (isNested) navigation.goBack()
<XStack justifyContent="space-evenly">
{ albumFetchSuccess && album ? (
<IconButton
name="music-box"
title="Go to Album"
onPress={() => {
if (isNested)
navigation.goBack();
navigation.goBack();
navigation.goBack()
if (isNested)
navigation.navigate('Tabs', {
screen: 'Home',
params: {
screen: 'Album',
params: {
album
}
}
});
else
navigation.navigate('Album', {
album
});
}}
size={width / 6}
/>
) : (
<Spacer />
)}
if (isNested)
navigation.navigate('Tabs', {
screen: 'Home',
params: {
screen: 'Album',
params: {
album,
},
},
})
else
navigation.navigate('Album', {
album,
})
}}
size={width / 6}
/>
) : (
<Spacer />
)}
<IconButton
circular
name="table-column-plus-before"
title="Play Next"
onPress={() => {
useAddToQueue.mutate({
track: track,
queuingType: QueuingType.PlayingNext
})
}}
size={width / 6}
/>
<IconButton
circular
name='table-column-plus-before'
title='Play Next'
onPress={() => {
useAddToQueue.mutate({
track: track,
queuingType: QueuingType.PlayingNext,
})
}}
size={width / 6}
/>
<IconButton
circular
name="table-column-plus-after"
title="Queue"
onPress={() => {
useAddToQueue.mutate({
track: track
})
}}
size={width / 6}
/>
</XStack>
<IconButton
circular
name='table-column-plus-after'
title='Queue'
onPress={() => {
useAddToQueue.mutate({
track: track,
})
}}
size={width / 6}
/>
</XStack>
<Spacer />
<Spacer />
{ playlistsFetchPending && (
<Spinner />
)}
{playlistsFetchPending && <Spinner />}
{ !playlistsFetchPending && playlistsFetchSuccess && (
<>
<Text
bold
fontSize={"$6"}
>
Add to Playlist
</Text>
{!playlistsFetchPending && playlistsFetchSuccess && (
<>
<Text bold fontSize={'$6'}>
Add to Playlist
</Text>
<YGroup separator={(<Separator />)}>
{ playlists.map(playlist => {
<YGroup separator={<Separator />}>
{playlists.map((playlist) => {
return (
<YGroup.Item key={playlist.Id!}>
<ListItem
hoverTheme
onPress={() => {
useAddToPlaylist.mutate({
track,
playlist,
})
}}
>
<XStack alignItems='center'>
<YStack flex={1}>
<Image
source={getImageApi(
Client.api!,
).getItemImageUrlById(playlist.Id!)}
style={{
borderRadius: getToken('$1.5'),
width: getToken('$12'),
height: getToken('$12'),
}}
/>
</YStack>
return (
<YGroup.Item key={playlist.Id!}>
<ListItem
hoverTheme
onPress={() => {
useAddToPlaylist.mutate({
track,
playlist
})
}}
>
<XStack alignItems="center">
<YStack
flex={1}
>
<YStack alignItems='flex-start' flex={5}>
<Text bold fontSize={'$6'}>
{playlist.Name ?? 'Untitled Playlist'}
</Text>
<Image
source={getImageApi(Client.api!).getItemImageUrlById(playlist.Id!)}
style={{
borderRadius: getToken("$1.5"),
width: getToken("$12"),
height: getToken("$12")
}}
/>
</YStack>
<YStack
alignItems="flex-start"
flex={5}
>
<Text bold fontSize={"$6"}>{playlist.Name ?? "Untitled Playlist"}</Text>
<Text color={getTokens().color.amethyst}>{`${playlist.ChildCount ?? 0} tracks`}</Text>
</YStack>
</XStack>
</ListItem>
</YGroup.Item>
)
})}
</YGroup>
</>
)}
</YStack>
)
}
<Text color={getTokens().color.amethyst}>{`${
playlist.ChildCount ?? 0
} tracks`}</Text>
</YStack>
</XStack>
</ListItem>
</YGroup.Item>
)
})}
</YGroup>
</>
)}
</YStack>
)
}

View File

@@ -1,21 +1,21 @@
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";
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}
isNested={route.params.isNested}
/>
)
}
route,
navigation,
}: {
route: RouteProp<StackParamList, 'Details'>
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
return (
<ItemDetail
item={route.params.item}
navigation={navigation}
isNested={route.params.isNested}
/>
)
}

View File

@@ -1,6 +1,6 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
export interface AddToPlaylistMutation {
track: BaseItemDto;
playlist: BaseItemDto;
}
track: BaseItemDto
playlist: BaseItemDto
}

View File

@@ -1,18 +1,22 @@
import { QueryKeys } from "../../enums/query-keys";
import { QueryKeys } from '../../enums/query-keys'
interface CategoryRoute {
name: any; // ¯\_(ツ)_/¯
iconName: string;
params?: {
query: QueryKeys
};
name: any // ¯\_(ツ)_/¯
iconName: string
params?: {
query: QueryKeys
}
}
const Categories : CategoryRoute[] = [
{ name: "Artists", iconName: "microphone-variant", params: { query: QueryKeys.FavoriteArtists } },
{ name: "Albums", iconName: "music-box-multiple", params: { query: QueryKeys.FavoriteAlbums} },
{ name: "Tracks", iconName: "music-note", params: { query: QueryKeys.FavoriteTracks } },
{ name: "Playlists", iconName: "playlist-music" },
];
const Categories: CategoryRoute[] = [
{
name: 'Artists',
iconName: 'microphone-variant',
params: { query: QueryKeys.FavoriteArtists },
},
{ name: 'Albums', iconName: 'music-box-multiple', params: { query: QueryKeys.FavoriteAlbums } },
{ name: 'Tracks', iconName: 'music-note', params: { query: QueryKeys.FavoriteTracks } },
{ name: 'Playlists', iconName: 'playlist-music' },
]
export default Categories;
export default Categories

View File

@@ -1,37 +1,36 @@
import { FlatList } from "react-native";
import { 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";
import { FlatList } from 'react-native'
import { 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 Library({
route,
navigation
} : {
route: RouteProp<StackParamList, "Library">,
navigation: NativeStackNavigationProp<StackParamList>
export default function Library({
route,
navigation,
}: {
route: RouteProp<StackParamList, 'Library'>
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { width } = useSafeAreaFrame()
const { width } = useSafeAreaFrame();
return (
<FlatList
contentInsetAdjustmentBehavior="automatic"
data={Categories}
numColumns={2}
renderItem={({ index, item }) =>
<IconCard
name={item.iconName}
caption={item.name}
width={width / 2.1}
onPress={() => {
navigation.navigate(item.name, item.params)
}}
largeIcon
/>
}
/>
)
}
return (
<FlatList
contentInsetAdjustmentBehavior='automatic'
data={Categories}
numColumns={2}
renderItem={({ index, item }) => (
<IconCard
name={item.iconName}
caption={item.name}
width={width / 2.1}
onPress={() => {
navigation.navigate(item.name, item.params)
}}
largeIcon
/>
)}
/>
)
}

View File

@@ -1,58 +1,61 @@
import { Label } from "../../Global/helpers/text";
import Input from "../../Global/helpers/input";
import React, { useState } from "react";
import { View, XStack } from "tamagui";
import Button from "../../Global/helpers/button";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { StackParamList } from "../../types";
import { useMutation } from "@tanstack/react-query";
import { createPlaylist } from "../../../api/mutations/functions/playlists";
import { trigger } from "react-native-haptic-feedback";
import { queryClient } from "../../../constants/query-client";
import { QueryKeys } from "../../../enums/query-keys";
import { Label } from '../../Global/helpers/text'
import Input from '../../Global/helpers/input'
import React, { useState } from 'react'
import { View, XStack } from 'tamagui'
import Button from '../../Global/helpers/button'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { StackParamList } from '../../types'
import { useMutation } from '@tanstack/react-query'
import { createPlaylist } from '../../../api/mutations/functions/playlists'
import { trigger } from 'react-native-haptic-feedback'
import { queryClient } from '../../../constants/query-client'
import { QueryKeys } from '../../../enums/query-keys'
import * as Burnt from "burnt";
import * as Burnt from 'burnt'
export default function AddPlaylist({
navigation
}: {
navigation : NativeStackNavigationProp<StackParamList, 'AddPlaylist'>
}) : React.JSX.Element {
export default function AddPlaylist({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList, 'AddPlaylist'>
}): React.JSX.Element {
const [name, setName] = useState<string>('')
const [name, setName] = useState<string>("");
const useAddPlaylist = useMutation({
mutationFn: ({ name }: { name: string }) => createPlaylist(name),
onSuccess: (data, { name }) => {
trigger('notificationSuccess')
const useAddPlaylist = useMutation({
mutationFn: ({ name } : { name : string}) => createPlaylist(name),
onSuccess: (data, { name }) => {
trigger("notificationSuccess");
Burnt.alert({
title: `Playlist created`,
message: `Created playlist ${name}`,
duration: 1,
preset: 'done',
})
Burnt.alert({
title: `Playlist created`,
message: `Created playlist ${name}`,
duration: 1,
preset: 'done'
});
navigation.goBack()
navigation.goBack();
// Refresh user playlists component in library
queryClient.invalidateQueries({
queryKey: [QueryKeys.UserPlaylists],
})
},
onError: () => {
trigger('notificationError')
},
})
// Refresh user playlists component in library
queryClient.invalidateQueries({
queryKey: [QueryKeys.UserPlaylists]
});
},
onError: () => {
trigger("notificationError");
}
})
return (
<View marginHorizontal={"$2"}>
<Label size="$2" htmlFor="name">Name</Label>
<Input id="name" onChangeText={setName} />
<XStack justifyContent="space-evenly">
<Button danger onPress={() => navigation.goBack()}>Cancel</Button>
<Button onPress={() => useAddPlaylist.mutate({ name })}>Create</Button>
</XStack>
</View>
)
}
return (
<View marginHorizontal={'$2'}>
<Label size='$2' htmlFor='name'>
Name
</Label>
<Input id='name' onChangeText={setName} />
<XStack justifyContent='space-evenly'>
<Button danger onPress={() => navigation.goBack()}>
Cancel
</Button>
<Button onPress={() => useAddPlaylist.mutate({ name })}>Create</Button>
</XStack>
</View>
)
}

View File

@@ -1,60 +1,60 @@
import { View, XStack } from "tamagui";
import { DeletePlaylistProps } from "../../../components/types";
import Button from "../../../components/Global/helpers/button";
import { Text } from "../../../components/Global/helpers/text";
import { useMutation } from "@tanstack/react-query";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { deletePlaylist } from "../../../api/mutations/functions/playlists";
import { trigger } from "react-native-haptic-feedback";
import { queryClient } from "../../../constants/query-client";
import { QueryKeys } from "../../../enums/query-keys";
import { View, XStack } from 'tamagui'
import { DeletePlaylistProps } from '../../../components/types'
import Button from '../../../components/Global/helpers/button'
import { Text } from '../../../components/Global/helpers/text'
import { useMutation } from '@tanstack/react-query'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { deletePlaylist } from '../../../api/mutations/functions/playlists'
import { trigger } from 'react-native-haptic-feedback'
import { queryClient } from '../../../constants/query-client'
import { QueryKeys } from '../../../enums/query-keys'
import * as Burnt from "burnt";
import * as Burnt from 'burnt'
export default function DeletePlaylist(
{
navigation,
route
}: DeletePlaylistProps) : React.JSX.Element {
export default function DeletePlaylist({
navigation,
route,
}: DeletePlaylistProps): React.JSX.Element {
const useDeletePlaylist = useMutation({
mutationFn: (playlist: BaseItemDto) => deletePlaylist(playlist.Id!),
onSuccess: (data, playlist) => {
trigger('notificationSuccess')
navigation.goBack()
navigation.goBack()
Burnt.alert({
title: `Playlist deleted`,
message: `Deleted ${playlist.Name ?? 'Untitled Playlist'}`,
duration: 1,
preset: 'done',
})
const useDeletePlaylist = useMutation({
mutationFn: (playlist: BaseItemDto) => deletePlaylist(playlist.Id!),
onSuccess: (data, playlist) => {
trigger("notificationSuccess");
// Refresh favorite playlists component in library
queryClient.invalidateQueries({
queryKey: [QueryKeys.FavoritePlaylists],
})
navigation.goBack();
navigation.goBack();
Burnt.alert({
title: `Playlist deleted`,
message: `Deleted ${playlist.Name ?? "Untitled Playlist"}`,
duration: 1,
preset: 'done'
});
// Refresh home screen user playlists
queryClient.invalidateQueries({
queryKey: [QueryKeys.UserPlaylists],
})
},
onError: () => {
trigger('notificationError')
},
})
// Refresh favorite playlists component in library
queryClient.invalidateQueries({
queryKey: [QueryKeys.FavoritePlaylists]
});
// Refresh home screen user playlists
queryClient.invalidateQueries({
queryKey: [QueryKeys.UserPlaylists]
});
},
onError: () => {
trigger("notificationError");
}
})
return (
<View marginHorizontal={"$2"}>
<Text bold textAlign="center">{`Delete playlist ${route.params.playlist.Name ?? "Untitled Playlist"}?`}</Text>
<XStack justifyContent="space-evenly">
<Button onPress={() => navigation.goBack()}>Cancel</Button>
<Button danger onPress={() => useDeletePlaylist.mutate(route.params.playlist)}>Delete</Button>
</XStack>
</View>
)
}
return (
<View marginHorizontal={'$2'}>
<Text bold textAlign='center'>{`Delete playlist ${
route.params.playlist.Name ?? 'Untitled Playlist'
}?`}</Text>
<XStack justifyContent='space-evenly'>
<Button onPress={() => navigation.goBack()}>Cancel</Button>
<Button danger onPress={() => useDeletePlaylist.mutate(route.params.playlist)}>
Delete
</Button>
</XStack>
</View>
)
}

View File

@@ -1,127 +1,106 @@
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import React from "react";
import { StackParamList } from "../types";
import Library from "./component";
import { AlbumScreen } from "../Album";
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";
import PlaylistsScreen from "../Playlists/screen";
import AddPlaylist from "./components/add-playlist";
import DeletePlaylist from "./components/delete-playlist";
import { ArtistScreen } from "../Artist";
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import React from 'react'
import { StackParamList } from '../types'
import Library from './component'
import { AlbumScreen } from '../Album'
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'
import PlaylistsScreen from '../Playlists/screen'
import AddPlaylist from './components/add-playlist'
import DeletePlaylist from './components/delete-playlist'
import { ArtistScreen } from '../Artist'
const Stack = createNativeStackNavigator<StackParamList>();
const Stack = createNativeStackNavigator<StackParamList>()
export default function LibraryStack(): React.JSX.Element {
return (
<Stack.Navigator
initialRouteName="Library"
>
<Stack.Screen
name="Library"
component={Library}
options={{
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold'
}
}}
/>
return (
<Stack.Navigator initialRouteName='Library'>
<Stack.Screen
name='Library'
component={Library}
options={{
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold',
},
}}
/>
<Stack.Screen
name="Artist"
component={ArtistScreen}
options={({ route }) => ({
title: route.params.artist.Name ?? "Unknown Artist",
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold'
}
})}
/>
<Stack.Screen
name='Artist'
component={ArtistScreen}
options={({ route }) => ({
title: route.params.artist.Name ?? 'Unknown Artist',
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold',
},
})}
/>
<Stack.Screen
name="Artists"
component={ArtistsScreen}
options={({ route }) => ({
})}
/>
<Stack.Screen name='Artists' component={ArtistsScreen} options={({ route }) => ({})} />
<Stack.Screen
name="Album"
component={AlbumScreen}
options={({ route }) => ({
headerShown: true,
headerTitle: ""
})}
/>
<Stack.Screen
name='Album'
component={AlbumScreen}
options={({ route }) => ({
headerShown: true,
headerTitle: '',
})}
/>
<Stack.Screen
name="Albums"
component={AlbumsScreen}
options={{
}}
/>
<Stack.Screen name='Albums' component={AlbumsScreen} options={{}} />
<Stack.Screen
name="Tracks"
component={TracksScreen}
options={{
}}
/>
<Stack.Screen name='Tracks' component={TracksScreen} options={{}} />
<Stack.Screen
name="Playlists"
component={PlaylistsScreen}
options={{
}}
/>
<Stack.Screen name='Playlists' component={PlaylistsScreen} options={{}} />
<Stack.Screen
name="Playlist"
component={PlaylistScreen}
options={({ route }) => ({
headerShown: true,
headerTitle: ""
})}
/>
<Stack.Screen
name='Playlist'
component={PlaylistScreen}
options={({ route }) => ({
headerShown: true,
headerTitle: '',
})}
/>
<Stack.Group screenOptions={{ presentation: 'modal' }}>
<Stack.Screen
name="Details"
component={DetailsScreen}
options={{
headerShown: false,
}}
/>
</Stack.Group>
<Stack.Group screenOptions={{ presentation: 'modal' }}>
<Stack.Screen
name='Details'
component={DetailsScreen}
options={{
headerShown: false,
}}
/>
</Stack.Group>
{/* https://www.reddit.com/r/reactnative/comments/1dgktbn/comment/lxd23sj/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button */}
<Stack.Group screenOptions={{
presentation: 'formSheet',
sheetInitialDetentIndex: 0,
sheetAllowedDetents: [0.35]
}}>
<Stack.Screen
name="AddPlaylist"
component={AddPlaylist}
options={{
title: "Add Playlist",
}}
/>
{/* https://www.reddit.com/r/reactnative/comments/1dgktbn/comment/lxd23sj/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button */}
<Stack.Group
screenOptions={{
presentation: 'formSheet',
sheetInitialDetentIndex: 0,
sheetAllowedDetents: [0.35],
}}
>
<Stack.Screen
name='AddPlaylist'
component={AddPlaylist}
options={{
title: 'Add Playlist',
}}
/>
<Stack.Screen
name="DeletePlaylist"
component={DeletePlaylist}
options={{
title: "Delete Playlist"
}}
/>
</Stack.Group>
</Stack.Navigator>
)
}
<Stack.Screen
name='DeletePlaylist'
component={DeletePlaylist}
options={{
title: 'Delete Playlist',
}}
/>
</Stack.Group>
</Stack.Navigator>
)
}

View File

@@ -1,56 +1,54 @@
import _, { isUndefined } from "lodash"
import ServerAuthentication from "./screens/server-authentication";
import ServerAddress from "./screens/server-address";
import { createStackNavigator } from "@react-navigation/stack";
import ServerLibrary from "./screens/server-library";
import { useAuthenticationContext } from "./provider";
import { useEffect } from "react";
import _, { isUndefined } from 'lodash'
import ServerAuthentication from './screens/server-authentication'
import ServerAddress from './screens/server-address'
import { createStackNavigator } from '@react-navigation/stack'
import ServerLibrary from './screens/server-library'
import { useAuthenticationContext } from './provider'
import { useEffect } from 'react'
export default function Login(): React.JSX.Element {
const { user, server, setTriggerAuth } = useAuthenticationContext()
const { user, server, setTriggerAuth } = useAuthenticationContext();
const Stack = createStackNavigator()
const Stack = createStackNavigator();
useEffect(() => {
setTriggerAuth(false)
})
useEffect(() => {
setTriggerAuth(false);
});
return (
<Stack.Navigator
initialRouteName={
isUndefined(server)
? 'ServerAddress'
: isUndefined(user)
? 'ServerAuthentication'
: 'LibrarySelection'
}
screenOptions={{ headerShown: false }}>
<Stack.Screen
name='ServerAddress'
options={{
headerShown: false,
}}
component={ServerAddress}
/>
return (
<Stack.Navigator
initialRouteName={
isUndefined(server)
? "ServerAddress"
: isUndefined(user)
? "ServerAuthentication"
: "LibrarySelection"
}
screenOptions={{ headerShown: false }}
>
<Stack.Screen
name="ServerAddress"
options={{
headerShown: false,
}}
component={ServerAddress}
/>
<Stack.Screen
name="ServerAuthentication"
options={{
headerShown: false,
}}
initialParams={{ server }}
//@ts-ignore
component={ServerAuthentication}
/>
<Stack.Screen
name="LibrarySelection"
options={{
headerShown: false,
}}
component={ServerLibrary}
/>
</Stack.Navigator>
);
}
<Stack.Screen
name='ServerAuthentication'
options={{
headerShown: false,
}}
initialParams={{ server }}
//@ts-expect-error TOOD: Explain why this exists
component={ServerAuthentication}
/>
<Stack.Screen
name='LibrarySelection'
options={{
headerShown: false,
}}
component={ServerLibrary}
/>
</Stack.Navigator>
)
}

View File

@@ -1,82 +1,75 @@
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";
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 {
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>>;
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 [server, setServer] = useState<JellifyServer | undefined>(Client.server)
const [user, setUser] = useState<JellifyUser | undefined>(Client.user)
const [library, setLibrary] = useState<JellifyLibrary | undefined>(Client.library)
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)
const [triggerAuth, setTriggerAuth] = useState<boolean>(true);
return {
user,
setUser,
server,
setServer,
library,
setLibrary,
triggerAuth,
setTriggerAuth,
};
return {
user,
setUser,
server,
setServer,
library,
setLibrary,
triggerAuth,
setTriggerAuth,
}
}
const JellyfinAuthenticationContext =
createContext<JellyfinAuthenticationContext>({
user: undefined,
setUser: () => {},
server: undefined,
setServer: () => {},
library: undefined,
setLibrary: () => {},
triggerAuth: true,
setTriggerAuth: () => {},
});
const JellyfinAuthenticationContext = createContext<JellyfinAuthenticationContext>({
user: undefined,
setUser: () => {},
server: undefined,
setServer: () => {},
library: undefined,
setLibrary: () => {},
triggerAuth: true,
setTriggerAuth: () => {},
})
export const JellyfinAuthenticationProvider: ({ children }: {
children: ReactNode;
export const JellyfinAuthenticationProvider: ({
children,
}: {
children: ReactNode
}) => React.JSX.Element = ({ children }: { children: ReactNode }) => {
const { user, setUser, server, setServer, library, setLibrary, triggerAuth, setTriggerAuth } =
JellyfinAuthenticationContextInitializer()
const {
user,
setUser,
server,
setServer,
library,
setLibrary,
triggerAuth,
setTriggerAuth,
} = JellyfinAuthenticationContextInitializer();
return (
<JellyfinAuthenticationContext.Provider
value={{
user,
setUser,
server,
setServer,
library,
setLibrary,
triggerAuth,
setTriggerAuth,
}}
>
{children}
</JellyfinAuthenticationContext.Provider>
)
}
return (
<JellyfinAuthenticationContext.Provider value={{
user,
setUser,
server,
setServer,
library,
setLibrary,
triggerAuth,
setTriggerAuth,
}}>
{ children }
</JellyfinAuthenticationContext.Provider>
);
};
export const useAuthenticationContext = () => useContext(JellyfinAuthenticationContext)
export const useAuthenticationContext = () => useContext(JellyfinAuthenticationContext)

View File

@@ -1,122 +1,119 @@
import React, { useState } from "react";
import _ from "lodash";
import { useMutation } from "@tanstack/react-query";
import { JellifyServer } from "../../../types/JellifyServer";
import { Input, Spinner, XStack, YStack } from "tamagui";
import { SwitchWithLabel } from "../../Global/helpers/switch-with-label";
import { H2 } from "../../Global/helpers/text";
import Button from "../../Global/helpers/button";
import { http, https } from "../utils/constants";
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";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { StackParamList } from "../../../components/types";
import React, { useState } from 'react'
import _ from 'lodash'
import { useMutation } from '@tanstack/react-query'
import { JellifyServer } from '../../../types/JellifyServer'
import { Input, Spinner, XStack, YStack } from 'tamagui'
import { SwitchWithLabel } from '../../Global/helpers/switch-with-label'
import { H2 } from '../../Global/helpers/text'
import Button from '../../Global/helpers/button'
import { http, https } from '../utils/constants'
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'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { StackParamList } from '../../../components/types'
import * as Burnt from "burnt";
import { Image } from "react-native";
import * as Burnt from 'burnt'
import { Image } from 'react-native'
export default function ServerAddress({
navigation
export default function ServerAddress({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
navigation.setOptions({
animationTypeForReplace: 'push',
})
navigation.setOptions({
animationTypeForReplace: 'push'
})
const [useHttps, setUseHttps] = useState<boolean>(true)
const [serverAddress, setServerAddress] = useState<string | undefined>(undefined)
const [useHttps, setUseHttps] = useState<boolean>(true);
const [serverAddress, setServerAddress] = useState<string | undefined>(undefined);
const { server, setServer } = useAuthenticationContext()
const { server, setServer } = useAuthenticationContext();
const useServerMutation = useMutation({
mutationFn: () => {
console.debug(`Connecting to ${useHttps ? https : http}${serverAddress}`)
const useServerMutation = useMutation({
mutationFn: () => {
const jellyfin = new Jellyfin(JellyfinInfo)
console.debug(`Connecting to ${useHttps ? https : http}${serverAddress}`);
if (!serverAddress) throw new Error('Server address was empty')
const jellyfin = new Jellyfin(JellyfinInfo);
const api = jellyfin.createApi(`${useHttps ? https : http}${serverAddress}`)
if (!serverAddress)
throw new Error("Server address was empty");
return getSystemApi(api).getPublicSystemInfo()
},
onSuccess: (publicSystemInfoResponse) => {
if (!publicSystemInfoResponse.data.Version)
throw new Error('Jellyfin instance did not respond')
const api = jellyfin.createApi(`${useHttps ? https : http}${serverAddress}`);
console.log(`Connected to Jellyfin ${publicSystemInfoResponse.data.Version!}`)
return getSystemApi(api).getPublicSystemInfo();
},
onSuccess: (publicSystemInfoResponse) => {
if (!publicSystemInfoResponse.data.Version)
throw new Error("Jellyfin instance did not respond");
console.log(`Connected to Jellyfin ${publicSystemInfoResponse.data.Version!}`);
const server: JellifyServer = {
url: `${useHttps ? https : http}${serverAddress!}`,
address: serverAddress!,
name: publicSystemInfoResponse.data.ServerName!,
version: publicSystemInfoResponse.data.Version!,
startUpComplete: publicSystemInfoResponse.data.StartupWizardCompleted!
}
const server: JellifyServer = {
url: `${useHttps ? https : http}${serverAddress!}`,
address: serverAddress!,
name: publicSystemInfoResponse.data.ServerName!,
version: publicSystemInfoResponse.data.Version!,
startUpComplete: publicSystemInfoResponse.data.StartupWizardCompleted!,
}
Client.setPublicApiClient(server);
setServer(server);
Client.setPublicApiClient(server)
setServer(server)
navigation.navigate("ServerAuthentication", { server });
},
onError: async (error: Error) => {
console.error("An error occurred connecting to the Jellyfin instance", error);
Client.signOut();
setServer(undefined);
navigation.navigate('ServerAuthentication', { server })
},
onError: async (error: Error) => {
console.error('An error occurred connecting to the Jellyfin instance', error)
Client.signOut()
setServer(undefined)
Burnt.toast({
title: "Unable to connect",
preset: "error",
// message: `Unable to connect to Jellyfin at ${useHttps ? https : http}${serverAddress}`,
});
}
});
Burnt.toast({
title: 'Unable to connect',
preset: 'error',
// message: `Unable to connect to Jellyfin at ${useHttps ? https : http}${serverAddress}`,
})
},
})
return (
<SafeAreaView style={{flex:1}}>
<YStack maxHeight={"$19"} flex={1} justifyContent="center">
<H2 marginHorizontal={"$10"} textAlign="center">
Connect to Jellyfin
</H2>
</YStack>
<YStack marginHorizontal={"$2"}>
<SwitchWithLabel
checked={useHttps}
onCheckedChange={(checked) => setUseHttps(checked)}
label="Use HTTPS"
size="$2"
width={100}
/>
return (
<SafeAreaView style={{ flex: 1 }}>
<YStack maxHeight={'$19'} flex={1} justifyContent='center'>
<H2 marginHorizontal={'$10'} textAlign='center'>
Connect to Jellyfin
</H2>
</YStack>
<Input
onChangeText={setServerAddress}
autoCapitalize="none"
autoCorrect={false}
placeholder="jellyfin.org"
/>
{ useServerMutation.isPending ? (
<Spinner />
) :
<Button
disabled={_.isEmpty(serverAddress)}
onPress={() => {
useServerMutation.mutate();
}}>
Connect
</Button>}
</YStack>
</SafeAreaView>
)
}
<YStack marginHorizontal={'$2'}>
<SwitchWithLabel
checked={useHttps}
onCheckedChange={(checked) => setUseHttps(checked)}
label='Use HTTPS'
size='$2'
width={100}
/>
<Input
onChangeText={setServerAddress}
autoCapitalize='none'
autoCorrect={false}
placeholder='jellyfin.org'
/>
{useServerMutation.isPending ? (
<Spinner />
) : (
<Button
disabled={_.isEmpty(serverAddress)}
onPress={() => {
useServerMutation.mutate()
}}
>
Connect
</Button>
)}
</YStack>
</SafeAreaView>
)
}

View File

@@ -1,131 +1,140 @@
import React, { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import _ from "lodash";
import { JellyfinCredentials } from "../../../api/types/jellyfin-credentials";
import { getToken, Spacer, Spinner, XStack, YStack } from "tamagui";
import { useAuthenticationContext } from "../provider";
import { H2 } from "../../Global/helpers/text";
import Button from "../../Global/helpers/button";
import { SafeAreaView } from "react-native-safe-area-context";
import Client from "../../../api/client";
import { JellifyUser } from "../../../types/JellifyUser";
import { ServerAuthenticationProps } from "../../../components/types";
import Input from "../../../components/Global/helpers/input";
import Icon from "../../../components/Global/helpers/icon";
import { useToastController } from "@tamagui/toast";
import Toast from "../../../components/Global/components/toast";
import React, { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import _ from 'lodash'
import { JellyfinCredentials } from '../../../api/types/jellyfin-credentials'
import { getToken, Spacer, Spinner, XStack, YStack } from 'tamagui'
import { useAuthenticationContext } from '../provider'
import { H2 } from '../../Global/helpers/text'
import Button from '../../Global/helpers/button'
import { SafeAreaView } from 'react-native-safe-area-context'
import Client from '../../../api/client'
import { JellifyUser } from '../../../types/JellifyUser'
import { ServerAuthenticationProps } from '../../../components/types'
import Input from '../../../components/Global/helpers/input'
import Icon from '../../../components/Global/helpers/icon'
import { useToastController } from '@tamagui/toast'
import Toast from '../../../components/Global/components/toast'
export default function ServerAuthentication({
route,
navigation,
route,
navigation,
}: ServerAuthenticationProps): React.JSX.Element {
const toast = useToastController()
const toast = useToastController()
const [username, setUsername] = useState<string | undefined>(undefined)
const [password, setPassword] = React.useState<string | undefined>(undefined)
const [username, setUsername] = useState<string | undefined>(undefined);
const [password, setPassword] = React.useState<string | undefined>(undefined);
const { setUser, setServer } = useAuthenticationContext()
const { setUser, setServer } = useAuthenticationContext();
const useApiMutation = useMutation({
mutationFn: async (credentials: JellyfinCredentials) => {
return await Client.api!.authenticateUserByName(
credentials.username,
credentials.password,
)
},
onSuccess: async (authResult) => {
console.log(`Received auth response from server`)
if (_.isUndefined(authResult))
return Promise.reject(new Error('Authentication result was empty'))
const useApiMutation = useMutation({
mutationFn: async (credentials: JellyfinCredentials) => {
return await Client.api!.authenticateUserByName(credentials.username, credentials.password);
},
onSuccess: async (authResult) => {
console.log(`Received auth response from server`)
if (_.isUndefined(authResult))
return Promise.reject(new Error("Authentication result was empty"))
if (authResult.status >= 400 || _.isEmpty(authResult.data.AccessToken))
return Promise.reject(new Error('Invalid credentials'))
if (authResult.status >= 400 || _.isEmpty(authResult.data.AccessToken))
return Promise.reject(new Error("Invalid credentials"))
if (_.isUndefined(authResult.data.User))
return Promise.reject(new Error('Unable to login'))
if (_.isUndefined(authResult.data.User))
return Promise.reject(new Error("Unable to login"));
console.log(`Successfully signed in to server`)
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,
}
const user : JellifyUser = {
id: authResult.data.User!.Id!,
name: authResult.data.User!.Name!,
accessToken: (authResult.data.AccessToken as string)
}
Client.setUser(user)
setUser(user)
Client.setUser(user);
setUser(user);
navigation.navigate('LibrarySelection', { user })
},
onError: async (error: Error) => {
console.error('An error occurred connecting to the Jellyfin instance', error)
navigation.navigate("LibrarySelection", { user });
},
onError: async (error: Error) => {
console.error("An error occurred connecting to the Jellyfin instance", error);
toast.show('Sign in failed', {})
return Promise.reject(`An error occured signing into ${Client.server!.name}`)
},
})
toast.show("Sign in failed", {
return (
<SafeAreaView style={{ flex: 1 }}>
<YStack maxHeight={'$19'} flex={1} justifyContent='center'>
<H2 marginHorizontal={'$2'} textAlign='center'>
{`Sign in to ${route.params.server.name}`}
</H2>
</YStack>
<YStack marginHorizontal={'$2'}>
<Input
prependElement={
<Icon
small
name='human-greeting-variant'
color={getToken('$color.amethyst')}
/>
}
placeholder='Username'
value={username}
onChangeText={(value: string | undefined) => setUsername(value)}
autoCapitalize='none'
autoCorrect={false}
/>
});
return Promise.reject(`An error occured signing into ${Client.server!.name}`);
}
});
<Spacer />
return (
<SafeAreaView style={{flex:1}}>
<YStack maxHeight={"$19"} flex={1} justifyContent="center">
<H2 marginHorizontal={"$2"} textAlign="center">
{ `Sign in to ${route.params.server.name}`}
</H2>
</YStack>
<YStack marginHorizontal={"$2"}>
<Input
prependElement={(<Icon small name="human-greeting-variant" color={getToken("$color.amethyst")} />)}
placeholder="Username"
value={username}
onChangeText={(value : string | undefined) => setUsername(value)}
autoCapitalize="none"
autoCorrect={false}
/>
<Input
prependElement={
<Icon small name='lock-outline' color={getToken('$color.amethyst')} />
}
placeholder='Password'
value={password}
onChangeText={(value: string | undefined) => setPassword(value)}
autoCapitalize='none'
autoCorrect={false}
secureTextEntry
/>
<Spacer />
<Spacer />
<Input
prependElement={(<Icon small name="lock-outline" color={getToken("$color.amethyst")} />)}
placeholder="Password"
value={password}
onChangeText={(value : string | undefined) => setPassword(value)}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
/>
<Spacer />
<XStack justifyContent="space-between">
<Button
marginVertical={0}
icon={() => <Icon name="chevron-left" small />}
bordered={0}
onPress={() => {
Client.switchServer()
navigation.push("ServerAddress");
}}>
Switch Server
</Button>
{ useApiMutation.isPending ? (
<Spinner />
) :
<Button
marginVertical={0}
disabled={_.isEmpty(username) || useApiMutation.isPending}
onPress={() => {
if (!_.isUndefined(username)) {
console.log(`Signing in...`);
useApiMutation.mutate({ username, password });
}
}}
>
Sign in
</Button>}
</XStack>
<Toast />
</YStack>
</SafeAreaView>
);
}
<XStack justifyContent='space-between'>
<Button
marginVertical={0}
icon={() => <Icon name='chevron-left' small />}
bordered={0}
onPress={() => {
Client.switchServer()
navigation.push('ServerAddress')
}}
>
Switch Server
</Button>
{useApiMutation.isPending ? (
<Spinner />
) : (
<Button
marginVertical={0}
disabled={_.isEmpty(username) || useApiMutation.isPending}
onPress={() => {
if (!_.isUndefined(username)) {
console.log(`Signing in...`)
useApiMutation.mutate({ username, password })
}
}}
>
Sign in
</Button>
)}
</XStack>
<Toast />
</YStack>
</SafeAreaView>
)
}

View File

@@ -1,95 +1,104 @@
import React, { useEffect, useState } from "react";
import { getToken, Spinner, ToggleGroup } from "tamagui";
import { useAuthenticationContext } from "../provider";
import { H1, H2, Label, Text } from "../../Global/helpers/text";
import Button from "../../Global/helpers/button";
import _ from "lodash";
import { SafeAreaView } from "react-native-safe-area-context";
import Client from "../../../api/client";
import { useJellifyContext } from "../../provider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { QueryKeys } from "../../../enums/query-keys";
import { fetchUserViews } from "../../../api/queries/functions/libraries";
import { useQuery } from "@tanstack/react-query";
import React, { useEffect, useState } from 'react'
import { getToken, Spinner, ToggleGroup } from 'tamagui'
import { useAuthenticationContext } from '../provider'
import { H1, H2, Label, Text } from '../../Global/helpers/text'
import Button from '../../Global/helpers/button'
import _ from 'lodash'
import { SafeAreaView } from 'react-native-safe-area-context'
import Client from '../../../api/client'
import { useJellifyContext } from '../../provider'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { QueryKeys } from '../../../enums/query-keys'
import { fetchUserViews } from '../../../api/queries/functions/libraries'
import { useQuery } from '@tanstack/react-query'
export default function ServerLibrary(): React.JSX.Element {
const { setUser } = useAuthenticationContext()
const { setUser } = useAuthenticationContext();
const { setLoggedIn } = useJellifyContext()
const { setLoggedIn } = useJellifyContext();
const [libraryId, setLibraryId] = useState<string | undefined>(undefined)
const [playlistLibrary, setPlaylistLibrary] = useState<BaseItemDto | undefined>(undefined)
const [libraryId, setLibraryId] = useState<string | undefined>(undefined);
const [playlistLibrary, setPlaylistLibrary] = useState<BaseItemDto | undefined>(undefined);
const {
data: libraries,
isError,
isPending,
isSuccess,
refetch,
} = useQuery({
queryKey: [QueryKeys.UserViews],
queryFn: () => fetchUserViews(),
})
const { data : libraries, isError, isPending, isSuccess, refetch } = useQuery({
queryKey: [QueryKeys.UserViews],
queryFn: () => fetchUserViews()
});
useEffect(() => {
if (!isPending && isSuccess)
setPlaylistLibrary(
libraries.filter((library) => library.CollectionType === 'playlists')[0],
)
}, [isPending, isSuccess])
useEffect(() => {
if (!isPending && isSuccess)
setPlaylistLibrary(libraries.filter(library => library.CollectionType === 'playlists')[0])
}, [
isPending,
isSuccess
])
return (
<SafeAreaView>
<H2>Select Music Library</H2>
return (
<SafeAreaView>
<H2>Select Music Library</H2>
{isPending ? (
<Spinner size='large' />
) : (
<ToggleGroup
orientation='vertical'
type='single'
disableDeactivation={true}
value={libraryId}
onValueChange={setLibraryId}>
{libraries!
.filter((library) => library.CollectionType === 'music')
.map((library) => {
return (
<ToggleGroup.Item
key={library.Id}
value={library.Id!}
aria-label={library.Name!}
backgroundColor={
libraryId == library.Id!
? getToken('$color.purpleGray')
: 'unset'
}>
<Text>{library.Name ?? 'Unnamed Library'}</Text>
</ToggleGroup.Item>
)
})}
</ToggleGroup>
)}
{ isPending ? (
<Spinner size="large" />
) : (
<ToggleGroup
orientation="vertical"
type="single"
disableDeactivation={true}
value={libraryId}
onValueChange={setLibraryId}
>
{
libraries!.filter(library => library.CollectionType === 'music')
.map((library) => {
return (
<ToggleGroup.Item
key={library.Id}
value={library.Id!}
aria-label={library.Name!}
backgroundColor={libraryId == library.Id! ? getToken("$color.purpleGray") : 'unset'}
>
<Text>{library.Name ?? "Unnamed Library"}</Text>
</ToggleGroup.Item>
)
})
}
</ToggleGroup>
)}
{isError && <Text>Unable to load libraries</Text>}
{ isError && (
<Text>Unable to load libraries</Text>
)}
<Button
disabled={!libraryId}
onPress={() => {
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 disabled={!libraryId}
onPress={() => {
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={() => {
Client.switchUser();
setUser(undefined);
}}>
Switch User
</Button>
</SafeAreaView>
)
}
<Button
onPress={() => {
Client.switchUser()
setUser(undefined)
}}>
Switch User
</Button>
</SafeAreaView>
)
}

View File

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

View File

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

View File

@@ -0,0 +1 @@
export const CarPlay = null

View File

@@ -0,0 +1,2 @@
import { CarPlay as CarPlayInterface } from 'react-native-carplay'
export const CarPlay = CarPlayInterface

View File

@@ -1,19 +1,19 @@
import { TextTickerProps } from "react-native-text-ticker";
import { TextTickerProps } from 'react-native-text-ticker'
export const TextTickerConfig : TextTickerProps = {
duration: 5000,
loop: true,
repeatSpacer: 20,
marqueeDelay: 1000
export const TextTickerConfig: TextTickerProps = {
duration: 5000,
loop: true,
repeatSpacer: 20,
marqueeDelay: 1000,
}
/**
* RNTP (React Native Track Player) holds a high significant figure
* number for the progress.
*
*
* Tamagui Sliders only support whole integers
*
*
* We're going to move the decimal place over so that Tamagui's slider
* can be more precise
*/
export const ProgressMultiplier = 10 ^ 5
export const ProgressMultiplier = 10 ^ 5

View File

@@ -1,52 +1,55 @@
import { State } from "react-native-track-player";
import { Colors } from "react-native/Libraries/NewAppScreen";
import { Spinner, View } from "tamagui";
import { usePlayerContext } from "../../../player/provider";
import IconButton from "../../../components/Global/helpers/icon-button";
import { State } from 'react-native-track-player'
import { Colors } from 'react-native/Libraries/NewAppScreen'
import { Spinner, View } from 'tamagui'
import { usePlayerContext } from '../../../player/provider'
import IconButton from '../../../components/Global/helpers/icon-button'
export default function PlayPauseButton({ size }: { size?: number | undefined }) : React.JSX.Element {
export default function PlayPauseButton({
size,
}: {
size?: number | undefined
}): React.JSX.Element {
const { playbackState, useTogglePlayback } = usePlayerContext()
const { playbackState, useTogglePlayback } = usePlayerContext();
let button: React.JSX.Element
let button : React.JSX.Element;
switch (playbackState) {
case State.Playing: {
button = (
<IconButton
circular
largeIcon
size={size}
name='pause'
onPress={() => useTogglePlayback.mutate(undefined)}
/>
)
break
}
switch (playbackState) {
case (State.Playing) : {
button = (
<IconButton
circular
largeIcon
size={size}
name="pause"
onPress={() => useTogglePlayback.mutate(undefined)}
/>
);
break;
}
case (State.Buffering) :
case (State.Loading) : {
button = <Spinner marginHorizontal={10} size="small" color={Colors.Primary}/>;
break;
}
default : {
button = (
<IconButton
circular
largeIcon
size={size}
name="play"
onPress={() => useTogglePlayback.mutate(undefined)}
/>
);
break;
}
}
case State.Buffering:
case State.Loading: {
button = <Spinner marginHorizontal={10} size='small' color={Colors.Primary} />
break
}
return (
<View justifyContent="center" alignItems="center">
{ button }
</View>
);
}
default: {
button = (
<IconButton
circular
largeIcon
size={size}
name='play'
onPress={() => useTogglePlayback.mutate(undefined)}
/>
)
break
}
}
return (
<View justifyContent='center' alignItems='center'>
{button}
</View>
)
}

View File

@@ -1,64 +1,52 @@
import React from "react";
import { XStack, getToken } from "tamagui";
import PlayPauseButton from "./buttons";
import Icon from "../../../components/Global/helpers/icon";
import { getProgress, seekBy, skipToNext } from "react-native-track-player/lib/src/trackPlayer";
import { usePlayerContext } from "../../../player/provider";
import { useSafeAreaFrame } from "react-native-safe-area-context";
import React from 'react'
import { XStack, getToken } from 'tamagui'
import PlayPauseButton from './buttons'
import Icon from '../../../components/Global/helpers/icon'
import { getProgress, seekBy, skipToNext } from 'react-native-track-player/lib/src/trackPlayer'
import { usePlayerContext } from '../../../player/provider'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
export default function Controls(): React.JSX.Element {
const { width } = useSafeAreaFrame()
const { width } = useSafeAreaFrame();
const {
usePrevious,
useSeekTo
} = usePlayerContext();
const { usePrevious, useSeekTo } = usePlayerContext()
return (
return (
<XStack alignItems='center' justifyContent='space-evenly' marginVertical={'$2'}>
<Icon
color={getToken('$color.amethyst')}
name='rewind-15'
onPress={() => seekBy(-15)}
/>
<XStack
alignItems="center"
justifyContent="space-evenly"
marginVertical={"$2"}
>
<Icon
color={getToken("$color.amethyst")}
name="rewind-15"
onPress={() => seekBy(-15)}
/>
<Icon
color={getToken("$color.amethyst")}
name="skip-previous"
onPress={async () => {
<Icon
color={getToken('$color.amethyst')}
name='skip-previous'
onPress={async () => {
const progress = await getProgress()
if (progress.position < 3) usePrevious.mutate()
else {
useSeekTo.mutate(0)
}
}}
large
/>
const progress = await getProgress()
if (progress.position < 3)
usePrevious.mutate()
else {
useSeekTo.mutate(0);
}
}}
large
/>
{/* I really wanted a big clunky play button */}
<PlayPauseButton size={width / 5} />
{/* I really wanted a big clunky play button */}
<PlayPauseButton size={width / 5} />
<Icon
color={getToken('$color.amethyst')}
name='skip-next'
onPress={() => skipToNext()}
large
/>
<Icon
color={getToken("$color.amethyst")}
name="skip-next"
onPress={() => skipToNext()}
large
/>
<Icon
color={getToken("$color.amethyst")}
name="fast-forward-15"
onPress={() => seekBy(15)}
/>
</XStack>
)
}
<Icon
color={getToken('$color.amethyst')}
name='fast-forward-15'
onPress={() => seekBy(15)}
/>
</XStack>
)
}

View File

@@ -1,3 +1 @@
export function handlePlayerError() {
}
export function handlePlayerError() {}

View File

@@ -1,116 +1,98 @@
import React, { useEffect, useMemo, useState } from "react";
import { useProgress } from "react-native-track-player";
import { HorizontalSlider } from "../../../components/Global/helpers/slider";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { trigger } from "react-native-haptic-feedback";
import { getToken, XStack, YStack } from "tamagui";
import { useSafeAreaFrame } from "react-native-safe-area-context";
import { usePlayerContext } from "../../../player/provider";
import { RunTimeSeconds } from "../../../components/Global/helpers/time-codes";
import { UPDATE_INTERVAL } from "../../../player/config";
import { ProgressMultiplier } from "../component.config";
import Icon from "../../../components/Global/helpers/icon";
import PlayPauseButton from "./buttons";
import React, { useEffect, useMemo, useState } from 'react'
import { useProgress } from 'react-native-track-player'
import { HorizontalSlider } from '../../../components/Global/helpers/slider'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { trigger } from 'react-native-haptic-feedback'
import { getToken, XStack, YStack } from 'tamagui'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { usePlayerContext } from '../../../player/provider'
import { RunTimeSeconds } from '../../../components/Global/helpers/time-codes'
import { UPDATE_INTERVAL } from '../../../player/config'
import { ProgressMultiplier } from '../component.config'
import Icon from '../../../components/Global/helpers/icon'
import PlayPauseButton from './buttons'
const scrubGesture = Gesture.Pan();
const scrubGesture = Gesture.Pan()
export default function Scrubber() : React.JSX.Element {
export default function Scrubber(): React.JSX.Element {
const { useSeekTo, useSkip, usePrevious } = usePlayerContext()
const {
useSeekTo,
useSkip,
usePrevious,
} = usePlayerContext();
const { width } = useSafeAreaFrame()
const { width } = useSafeAreaFrame();
const [seeking, setSeeking] = useState<boolean>(false)
const [seeking, setSeeking] = useState<boolean>(false);
const progress = useProgress(UPDATE_INTERVAL)
const progress = useProgress(UPDATE_INTERVAL);
const [position, setPosition] = useState<number>(progress && progress.position ?
Math.floor(progress.position * ProgressMultiplier)
: 0
);
const [position, setPosition] = useState<number>(
progress && progress.position ? Math.floor(progress.position * ProgressMultiplier) : 0,
)
/**
* Update position in the scrubber if the user isn't interacting
*/
useEffect(() => {
if (
!seeking
&& !useSkip.isPending
&& !usePrevious.isPending
&& !useSeekTo.isPending
&& progress.position
)
setPosition(
Math.floor(
progress.position * ProgressMultiplier
)
);
}, [
progress.position
]);
/**
* Update position in the scrubber if the user isn't interacting
*/
useEffect(() => {
if (
!seeking &&
!useSkip.isPending &&
!usePrevious.isPending &&
!useSeekTo.isPending &&
progress.position
)
setPosition(Math.floor(progress.position * ProgressMultiplier))
}, [progress.position])
return (
<YStack>
return (
<YStack>
<GestureDetector gesture={scrubGesture}>
<HorizontalSlider
value={position}
max={
progress && progress.duration > 0
? progress.duration * ProgressMultiplier
: 1
}
width={width / 1.125}
props={{
// If user swipes off of the slider we should seek to the spot
onPressOut: () => {
trigger('notificationSuccess')
useSeekTo.mutate(Math.floor(position / ProgressMultiplier))
setSeeking(false)
},
onSlideStart: (event, value) => {
setSeeking(true)
trigger('impactLight')
setPosition(value)
},
onSlideMove: (event, value) => {
trigger('clockTick')
setPosition(value)
},
onSlideEnd: (event, value) => {
trigger('notificationSuccess')
setPosition(value)
useSeekTo.mutate(Math.floor(value / ProgressMultiplier))
setSeeking(false)
},
}}
/>
</GestureDetector>
<GestureDetector gesture={scrubGesture}>
<HorizontalSlider
value={position}
max={
progress && progress.duration > 0
? progress.duration * ProgressMultiplier
: 1
}
width={width / 1.125}
props={{
// If user swipes off of the slider we should seek to the spot
onPressOut: () => {
trigger("notificationSuccess")
useSeekTo.mutate(Math.floor(position / ProgressMultiplier));
setSeeking(false);
},
onSlideStart: (event, value) => {
setSeeking(true);
trigger("impactLight");
setPosition(value)
},
onSlideMove: (event, value) => {
trigger("clockTick")
setPosition(value);
},
onSlideEnd: (event, value) => {
trigger("notificationSuccess")
setPosition(value)
useSeekTo.mutate(Math.floor(value / ProgressMultiplier));
setSeeking(false);
}
}}
/>
</GestureDetector>
<XStack margin={'$2'} marginTop={'$3'}>
<YStack flex={1} alignItems='flex-start'>
<RunTimeSeconds>{Math.floor(position / ProgressMultiplier)}</RunTimeSeconds>
</YStack>
<XStack margin={"$2"} marginTop={"$3"}>
<YStack flex={1} alignItems="flex-start">
<RunTimeSeconds>{Math.floor(position / ProgressMultiplier)}</RunTimeSeconds>
</YStack>
<YStack flex={1} alignItems='center'>
{/** Track metadata can go here */}
</YStack>
<YStack flex={1} alignItems="center">
{ /** Track metadata can go here */}
</YStack>
<YStack flex={1} alignItems="flex-end">
<RunTimeSeconds>
{
progress && progress.duration
? Math.ceil(progress.duration)
: 0
}
</RunTimeSeconds>
</YStack>
</XStack>
</YStack>
)
}
<YStack flex={1} alignItems='flex-end'>
<RunTimeSeconds>
{progress && progress.duration ? Math.ceil(progress.duration) : 0}
</RunTimeSeconds>
</YStack>
</XStack>
</YStack>
)
}

View File

@@ -1,100 +1,97 @@
import React, { useMemo } from "react";
import { getToken, getTokens, useTheme, 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 Icon from "../Global/helpers/icon";
import { Text } from "../Global/helpers/text";
import TextTicker from 'react-native-text-ticker';
import PlayPauseButton from "./helpers/buttons";
import { useSafeAreaFrame } from "react-native-safe-area-context";
import { TextTickerConfig } from "./component.config";
import { Image } from "expo-image";
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
import Client from "../../api/client";
import React, { useMemo } from 'react'
import { getToken, getTokens, useTheme, 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 Icon from '../Global/helpers/icon'
import { Text } from '../Global/helpers/text'
import TextTicker from 'react-native-text-ticker'
import PlayPauseButton from './helpers/buttons'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { TextTickerConfig } from './component.config'
import { Image } from 'expo-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../../api/client'
export function Miniplayer({
navigation
} : {
navigation : NavigationHelpers<ParamListBase, BottomTabNavigationEventMap>
}) : React.JSX.Element {
export function Miniplayer({
navigation,
}: {
navigation: NavigationHelpers<ParamListBase, BottomTabNavigationEventMap>
}): React.JSX.Element {
const theme = useTheme()
const theme = useTheme();
const { nowPlaying, useSkip } = usePlayerContext()
const { nowPlaying, useSkip } = usePlayerContext();
const { width } = useSafeAreaFrame()
const { width } = useSafeAreaFrame();
return (
<View
style={{
backgroundColor: theme.background.val,
borderColor: theme.borderColor.val,
}}
>
{nowPlaying &&
useMemo(() => {
return (
<XStack
alignItems='center'
margin={0}
padding={0}
height={'$6'}
onPress={() => navigation.navigate('Player')}
>
<YStack
justify='center'
alignItems='flex-start'
flex={1}
minHeight={'$12'}
>
<Image
source={getImageApi(Client.api!).getItemImageUrlById(
nowPlaying!.item.AlbumId!,
)}
placeholder={
nowPlaying!.item.ImageBlurHashes?.Primary![0] ?? undefined
}
style={{
width: getToken('$12'),
height: getToken('$12'),
borderRadius: getToken('$1'),
}}
/>
</YStack>
return (
<View style={{
backgroundColor: theme.background.val,
borderColor: theme.borderColor.val
}}>
{ nowPlaying && (
<YStack
alignContent='flex-start'
marginLeft={'$2'}
flex={4}
maxWidth={'$20'}
>
<TextTicker {...TextTickerConfig}>
<Text bold>{nowPlaying?.title ?? 'Nothing Playing'}</Text>
</TextTicker>
useMemo(() => {
return (
<XStack
alignItems="center"
margin={0}
padding={0}
height={"$6"}
onPress={() => navigation.navigate("Player")}
>
<YStack
justify="center"
alignItems="flex-start"
flex={1}
minHeight={"$12"}
>
<Image
source={getImageApi(Client.api!).getItemImageUrlById(nowPlaying!.item.AlbumId!)}
placeholder={nowPlaying!.item.ImageBlurHashes?.Primary![0] ?? undefined}
style={{
width: getToken("$12"),
height: getToken("$12"),
borderRadius: getToken("$1")
}}
/>
<TextTicker {...TextTickerConfig}>
<Text color={getTokens().color.telemagenta}>
{nowPlaying?.artist ?? ''}
</Text>
</TextTicker>
</YStack>
</YStack>
<XStack justifyContent='flex-end' flex={2}>
<PlayPauseButton />
<YStack
alignContent="flex-start"
marginLeft={"$2"}
flex={4}
maxWidth={"$20"}
>
<TextTicker {...TextTickerConfig}>
<Text bold>{nowPlaying?.title ?? "Nothing Playing"}</Text>
</TextTicker>
<TextTicker {...TextTickerConfig}>
<Text color={getTokens().color.telemagenta}>{nowPlaying?.artist ?? ""}</Text>
</TextTicker>
</YStack>
<XStack
justifyContent="flex-end"
flex={2}
>
<PlayPauseButton />
<Icon
large
color={theme.borderColor.val}
name="skip-next"
onPress={() => useSkip.mutate(undefined)}
/>
</XStack>
</XStack>
)
}, [
nowPlaying
])
)
}
</View>
)
}
<Icon
large
color={theme.borderColor.val}
name='skip-next'
onPress={() => useSkip.mutate(undefined)}
/>
</XStack>
</XStack>
)
}, [nowPlaying])}
</View>
)
}

View File

@@ -1,209 +1,183 @@
import { StackParamList } from "../../../components/types";
import { usePlayerContext } from "../../../player/provider";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import React, { useMemo } from "react";
import { SafeAreaView, useSafeAreaFrame } from "react-native-safe-area-context";
import { YStack, XStack, Spacer, getTokens } from "tamagui";
import { Text } from "../../../components/Global/helpers/text";
import Icon from "../../../components/Global/helpers/icon";
import FavoriteButton from "../../Global/components/favorite-button";
import TextTicker from "react-native-text-ticker";
import { TextTickerConfig } from "../component.config";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import Scrubber from "../helpers/scrubber";
import Controls from "../helpers/controls";
import { Image } from "expo-image";
import { getImageApi } from "@jellyfin/sdk/lib/utils/api";
import Client from "../../../api/client";
import { StackParamList } from '../../../components/types'
import { usePlayerContext } from '../../../player/provider'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import React, { useMemo } from 'react'
import { SafeAreaView, useSafeAreaFrame } from 'react-native-safe-area-context'
import { YStack, XStack, Spacer, getTokens } from 'tamagui'
import { Text } from '../../../components/Global/helpers/text'
import Icon from '../../../components/Global/helpers/icon'
import FavoriteButton from '../../Global/components/favorite-button'
import TextTicker from 'react-native-text-ticker'
import { TextTickerConfig } from '../component.config'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import Scrubber from '../helpers/scrubber'
import Controls from '../helpers/controls'
import { Image } from 'expo-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../../../api/client'
export default function PlayerScreen({
navigation
} : {
navigation: NativeStackNavigationProp<StackParamList>
}) : React.JSX.Element {
export default function PlayerScreen({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { nowPlayingIsFavorite, setNowPlayingIsFavorite, nowPlaying, queue } = usePlayerContext()
const {
nowPlayingIsFavorite,
setNowPlayingIsFavorite,
nowPlaying,
queue
} = usePlayerContext();
const { width } = useSafeAreaFrame()
const { width } = useSafeAreaFrame();
return (
<SafeAreaView edges={['right', 'left']}>
{nowPlaying && (
<>
<YStack>
{useMemo(() => {
return (
<>
<XStack marginBottom={'$2'} marginHorizontal={'$2'}>
<YStack
alignContent='flex-end'
flex={1}
justifyContent='center'
>
<Icon
name='chevron-down'
onPress={() => {
navigation.goBack()
}}
small
/>
</YStack>
return (
<SafeAreaView edges={["right", "left"]}>
{ nowPlaying && (
<>
<YStack>
<YStack alignItems='center' alignContent='center' flex={6}>
<Text>Playing from</Text>
<Text
bold
numberOfLines={1}
lineBreakStrategyIOS='standard'
>
{
// If the Queue is a BaseItemDto, display the name of it
typeof queue === 'object'
? (queue as BaseItemDto).Name ?? 'Untitled'
: queue
}
</Text>
</YStack>
{ useMemo(() => {
return (
<>
<XStack
marginBottom={"$2"}
marginHorizontal={"$2"}
>
<Spacer flex={1} />
</XStack>
<YStack
alignContent="flex-end"
flex={1}
justifyContent="center"
>
<Icon
name="chevron-down"
onPress={() => {
navigation.goBack();
}}
small
/>
</YStack>
<XStack
justifyContent='center'
alignContent='center'
minHeight={width / 1.1}
>
<Image
source={getImageApi(Client.api!).getItemImageUrlById(
nowPlaying!.item.AlbumId!,
)}
placeholder={
nowPlaying!.item.ImageBlurHashes?.Primary![0] ??
undefined
}
style={{
borderRadius: 2,
width: width / 1.1,
}}
/>
</XStack>
</>
)
}, [nowPlaying, queue])}
<YStack
alignItems="center"
alignContent="center"
flex={6}
>
<XStack marginHorizontal={20} paddingVertical={5}>
{/** Memoize TextTickers otherwise they won't animate due to the progress being updated in the PlayerContext */}
{useMemo(() => {
return (
<YStack justifyContent='flex-start' flex={5}>
<TextTicker {...TextTickerConfig}>
<Text bold fontSize={'$6'}>
{nowPlaying!.title ?? 'Untitled Track'}
</Text>
</TextTicker>
<Text>Playing from</Text>
<Text
bold
numberOfLines={1}
lineBreakStrategyIOS="standard"
>
{
// If the Queue is a BaseItemDto, display the name of it
typeof(queue) === 'object'
? (queue as BaseItemDto).Name ?? "Untitled"
: queue
}
</Text>
</YStack>
<TextTicker {...TextTickerConfig}>
<Text
fontSize={'$6'}
color={getTokens().color.telemagenta}
onPress={() => {
if (nowPlaying!.item.ArtistItems) {
navigation.goBack() // Dismiss player modal
navigation.navigate('Tabs', {
screen: 'Home',
params: {
screen: 'Artist',
params: {
artist: nowPlaying!.item
.ArtistItems![0],
},
},
})
}
}}
>
{nowPlaying.artist ?? 'Unknown Artist'}
</Text>
</TextTicker>
<Spacer flex={1} />
</XStack>
<TextTicker {...TextTickerConfig}>
<Text fontSize={'$6'} color={'$borderColor'}>
{nowPlaying!.album ?? ''}
</Text>
</TextTicker>
</YStack>
)
}, [nowPlaying])}
<XStack
justifyContent="center"
alignContent="center"
minHeight={width / 1.1}
>
<Image
source={getImageApi(Client.api!).getItemImageUrlById(nowPlaying!.item.AlbumId!)}
placeholder={nowPlaying!.item.ImageBlurHashes?.Primary![0] ?? undefined}
style={{
borderRadius: 2,
width: width / 1.1
}}
/>
</XStack>
</>
)
}, [
nowPlaying,
queue
])}
<XStack justifyContent='flex-end' alignItems='center' flex={2}>
{/* Buttons for favorites, song menu go here */}
<XStack marginHorizontal={20} paddingVertical={5}>
<Icon
name='dots-horizontal-circle-outline'
onPress={() => {
navigation.navigate('Details', {
item: nowPlaying!.item,
isNested: true,
})
}}
/>
{/** Memoize TextTickers otherwise they won't animate due to the progress being updated in the PlayerContext */}
{ useMemo(() => {
return (
<YStack justifyContent="flex-start" flex={5}>
<TextTicker {...TextTickerConfig}>
<Text
bold
fontSize={"$6"}
>
{nowPlaying!.title ?? "Untitled Track"}
</Text>
</TextTicker>
<Spacer />
<TextTicker {...TextTickerConfig}>
<Text
fontSize={"$6"}
color={getTokens().color.telemagenta}
onPress={() => {
if (nowPlaying!.item.ArtistItems) {
navigation.goBack(); // Dismiss player modal
navigation.navigate('Tabs', {
screen: 'Home',
params: {
screen: 'Artist',
params: {
artist: nowPlaying!.item.ArtistItems![0],
}
}
});
}
}}
>
{nowPlaying.artist ?? "Unknown Artist"}
</Text>
</TextTicker>
<FavoriteButton
item={nowPlaying!.item}
onToggle={() => setNowPlayingIsFavorite(!nowPlayingIsFavorite)}
/>
</XStack>
</XStack>
<TextTicker {...TextTickerConfig}>
<Text
fontSize={"$6"}
color={"$borderColor"}
>
{ nowPlaying!.album ?? "" }
</Text>
</TextTicker>
</YStack>
)}, [
nowPlaying
])}
<XStack justifyContent='center' marginTop={'$3'}>
{/* playback progress goes here */}
<Scrubber />
</XStack>
<XStack
justifyContent="flex-end"
alignItems="center"
flex={2}
>
{/* Buttons for favorites, song menu go here */}
<Controls />
<Icon
name="dots-horizontal-circle-outline"
onPress={() => {
navigation.navigate("Details", {
item: nowPlaying!.item,
isNested: true
});
}}
/>
<XStack justifyContent='space-evenly' marginVertical={'$7'}>
<Icon name='speaker-multiple' />
<Spacer />
<Spacer />
<FavoriteButton
item={nowPlaying!.item}
onToggle={() => setNowPlayingIsFavorite(!nowPlayingIsFavorite)}
/>
</XStack>
</XStack>
<XStack justifyContent="center" marginTop={"$3"}>
{/* playback progress goes here */}
<Scrubber />
</XStack>
<Controls />
<XStack justifyContent="space-evenly" marginVertical={"$7"}>
<Icon name="speaker-multiple"
/>
<Spacer />
<Icon
name="playlist-music"
onPress={() => {
navigation.navigate("Queue");
}}
/>
</XStack>
</YStack>
</>
)}
</SafeAreaView>
);
}
<Icon
name='playlist-music'
onPress={() => {
navigation.navigate('Queue')
}}
/>
</XStack>
</YStack>
</>
)}
</SafeAreaView>
)
}

View File

@@ -1,84 +1,91 @@
import Icon from "../../../components/Global/helpers/icon";
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 { useSafeAreaFrame } from "react-native-safe-area-context";
import DraggableFlatList from "react-native-draggable-flatlist";
import { trigger } from "react-native-haptic-feedback";
import { Separator } from "tamagui";
import Icon from '../../../components/Global/helpers/icon'
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 { useSafeAreaFrame } from 'react-native-safe-area-context'
import DraggableFlatList from 'react-native-draggable-flatlist'
import { trigger } from 'react-native-haptic-feedback'
import { Separator } from 'tamagui'
export default function Queue({ navigation }: { navigation: NativeStackNavigationProp<StackParamList>}): React.JSX.Element {
export default function Queue({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { width } = useSafeAreaFrame()
const {
playQueue,
queue,
useClearQueue,
useRemoveFromQueue,
useReorderQueue,
useSkip,
nowPlaying,
} = usePlayerContext()
const { width } = useSafeAreaFrame();
const {
playQueue,
queue,
useClearQueue,
useRemoveFromQueue,
useReorderQueue,
useSkip,
nowPlaying
} = usePlayerContext();
navigation.setOptions({
headerRight: () => {
return (
<Icon
name='notification-clear-all'
onPress={() => {
useClearQueue.mutate()
}}
/>
)
},
})
navigation.setOptions({
headerRight: () => {
return (
<Icon name="notification-clear-all" onPress={() => {
useClearQueue.mutate()
}}/>
)
}
});
const scrollIndex = playQueue.findIndex(
(queueItem) => queueItem.item.Id! === nowPlaying!.item.Id!,
)
const scrollIndex = playQueue.findIndex(queueItem => queueItem.item.Id! === nowPlaying!.item.Id!)
return (
<DraggableFlatList
contentInsetAdjustmentBehavior="automatic"
data={playQueue}
dragHitSlop={{ left: -50 }} // https://github.com/computerjazz/react-native-draggable-flatlist/issues/336
extraData={nowPlaying}
// enableLayoutAnimationExperimental
getItemLayout={(data, index) => (
{ length: width / 9, offset: width / 9 * index, index}
)}
initialScrollIndex={scrollIndex !== -1 ? scrollIndex: 0}
ItemSeparatorComponent={() => <Separator />}
// itemEnteringAnimation={FadeIn}
// itemExitingAnimation={FadeOut}
// itemLayoutAnimation={SequencedTransition}
keyExtractor={({ item }, index) => {
return `${index}-${item.Id}`
}}
numColumns={1}
onDragEnd={({ data, from, to}) => {
useReorderQueue.mutate({ newOrder: data, from, to });
}}
renderItem={({ item: queueItem, getIndex, drag, isActive }) =>
<Track
queue={queue}
navigation={navigation}
track={queueItem.item}
index={getIndex()}
showArtwork
onPress={() => {
useSkip.mutate(getIndex());
}}
onLongPress={() => {
trigger('impactLight');
drag();
}}
isNested
showRemove
onRemove={() => {
if (getIndex())
useRemoveFromQueue.mutate(getIndex()!)
}}
/>
}
/>
)
}
return (
<DraggableFlatList
contentInsetAdjustmentBehavior='automatic'
data={playQueue}
dragHitSlop={{ left: -50 }} // https://github.com/computerjazz/react-native-draggable-flatlist/issues/336
extraData={nowPlaying}
// enableLayoutAnimationExperimental
getItemLayout={(data, index) => ({
length: width / 9,
offset: (width / 9) * index,
index,
})}
initialScrollIndex={scrollIndex !== -1 ? scrollIndex : 0}
ItemSeparatorComponent={() => <Separator />}
// itemEnteringAnimation={FadeIn}
// itemExitingAnimation={FadeOut}
// itemLayoutAnimation={SequencedTransition}
keyExtractor={({ item }, index) => {
return `${index}-${item.Id}`
}}
numColumns={1}
onDragEnd={({ data, from, to }) => {
useReorderQueue.mutate({ newOrder: data, from, to })
}}
renderItem={({ item: queueItem, getIndex, drag, isActive }) => (
<Track
queue={queue}
navigation={navigation}
track={queueItem.item}
index={getIndex()}
showArtwork
onPress={() => {
useSkip.mutate(getIndex())
}}
onLongPress={() => {
trigger('impactLight')
drag()
}}
isNested
showRemove
onRemove={() => {
if (getIndex()) useRemoveFromQueue.mutate(getIndex()!)
}}
/>
)}
/>
)
}

View File

@@ -1,45 +1,40 @@
import React from "react";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { StackParamList } from "../types";
import PlayerScreen from "./screens";
import Queue from "./screens/queue";
import DetailsScreen from "../ItemDetail/screen";
import { AlbumScreen } from "../Album";
import React from 'react'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { StackParamList } from '../types'
import PlayerScreen from './screens'
import Queue from './screens/queue'
import DetailsScreen from '../ItemDetail/screen'
import { AlbumScreen } from '../Album'
export const PlayerStack = createNativeStackNavigator<StackParamList>();
export const PlayerStack = createNativeStackNavigator<StackParamList>()
export default function Player() : React.JSX.Element {
return (
<PlayerStack.Navigator
initialRouteName="Player"
screenOptions={{}}
>
<PlayerStack.Screen
name="Player"
component={PlayerScreen}
options={{
headerShown: false,
headerTitle: "",
}}
/>
export default function Player(): React.JSX.Element {
return (
<PlayerStack.Navigator initialRouteName='Player' screenOptions={{}}>
<PlayerStack.Screen
name='Player'
component={PlayerScreen}
options={{
headerShown: false,
headerTitle: '',
}}
/>
<PlayerStack.Screen
name="Queue"
component={Queue}
options={{
headerTitle: ""
}}
/>
<PlayerStack.Screen
name='Queue'
component={Queue}
options={{
headerTitle: '',
}}
/>
<PlayerStack.Screen
name="Details"
component={DetailsScreen}
options={{
headerTitle: ""
}}
/>
</PlayerStack.Navigator>
);
<PlayerStack.Screen
name='Details'
component={DetailsScreen}
options={{
headerTitle: '',
}}
/>
</PlayerStack.Navigator>
)
}

View File

@@ -1,9 +1,9 @@
import { QueuingType } from "../../enums/queuing-type";
import { JellifyTrack } from "../../types/JellifyTrack";
import { QueuingType } from '../../enums/queuing-type'
import { JellifyTrack } from '../../types/JellifyTrack'
export type Item = JellifyTrack;
export type Item = JellifyTrack
export type Section = {
title: QueuingType,
data: Item[]
}
title: QueuingType
data: Item[]
}

View File

@@ -1,211 +1,199 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { StackParamList } from "../types";
import { getToken, Separator, Spacer, XStack, YStack } from "tamagui";
import { RunTimeTicks } from "../Global/helpers/time-codes";
import { H4, H5, Text } from "../Global/helpers/text";
import Track from "../Global/components/track";
import DraggableFlatList from "react-native-draggable-flatlist";
import { removeFromPlaylist, updatePlaylist } from "../../api/mutations/functions/playlists";
import { useEffect, useState } from "react";
import Icon from "../Global/helpers/icon";
import { useMutation, useQuery } from "@tanstack/react-query";
import { trigger } from "react-native-haptic-feedback";
import { queryClient } from "../../constants/query-client";
import { QueryKeys } from "../../enums/query-keys";
import { getImageApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import Client from "../../api/client";
import { RefreshControl } from "react-native";
import { Image } from "expo-image";
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { StackParamList } from '../types'
import { getToken, Separator, Spacer, XStack, YStack } from 'tamagui'
import { RunTimeTicks } from '../Global/helpers/time-codes'
import { H4, H5, Text } from '../Global/helpers/text'
import Track from '../Global/components/track'
import DraggableFlatList from 'react-native-draggable-flatlist'
import { removeFromPlaylist, updatePlaylist } from '../../api/mutations/functions/playlists'
import { useEffect, useState } from 'react'
import Icon from '../Global/helpers/icon'
import { useMutation, useQuery } from '@tanstack/react-query'
import { trigger } from 'react-native-haptic-feedback'
import { queryClient } from '../../constants/query-client'
import { QueryKeys } from '../../enums/query-keys'
import { getImageApi, getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../../api/client'
import { RefreshControl } from 'react-native'
import { Image } from 'expo-image'
interface PlaylistProps {
playlist: BaseItemDto;
navigation: NativeStackNavigationProp<StackParamList>
interface PlaylistProps {
playlist: BaseItemDto
navigation: NativeStackNavigationProp<StackParamList>
}
interface PlaylistOrderMutation {
playlist: BaseItemDto;
track: BaseItemDto;
to: number
playlist: BaseItemDto
track: BaseItemDto
to: number
}
interface RemoveFromPlaylistMutation {
playlist: BaseItemDto;
track: BaseItemDto;
index: number;
playlist: BaseItemDto
track: BaseItemDto
index: number
}
export default function Playlist({
playlist,
navigation
}: PlaylistProps): React.JSX.Element {
export default function Playlist({ playlist, navigation }: PlaylistProps): React.JSX.Element {
const [editing, setEditing] = useState<boolean>(false)
const [playlistTracks, setPlaylistTracks] = useState<BaseItemDto[]>([])
const {
data: tracks,
isPending,
isSuccess,
refetch,
} = useQuery({
queryKey: [QueryKeys.ItemTracks, playlist.Id!],
queryFn: () => {
return getItemsApi(Client.api!)
.getItems({
parentId: playlist.Id!,
})
.then((response) => {
return response.data.Items ? response.data.Items! : []
})
},
staleTime: 1000 * 60 * 1 * 1 * 1, // 1 minute, since these are mutable by nature
})
const [editing, setEditing] = useState<boolean>(false);
const [playlistTracks, setPlaylistTracks] = useState<BaseItemDto[]>([]);
const { data: tracks, isPending, isSuccess, refetch } = useQuery({
queryKey: [QueryKeys.ItemTracks, playlist.Id!],
queryFn: () => {
return getItemsApi(Client.api!).getItems({
parentId: playlist.Id!,
})
.then((response) => {
return response.data.Items ? response.data.Items! : [];
})
},
staleTime: (1000 * 60 * 1 * 1) * 1 // 1 minute, since these are mutable by nature
});
navigation.setOptions({
headerRight: () => {
return (
<XStack justifyContent='space-between'>
{editing && (
<Icon
color={getToken('$color.danger')}
name='delete-sweep-outline' // otherwise use "delete-circle"
onPress={() => navigation.navigate('DeletePlaylist', { playlist })}
/>
)}
navigation.setOptions({
headerRight: () => {
return (
<Spacer />
<XStack justifyContent="space-between">
<Icon
color={getToken('$color.amethyst')}
name={editing ? 'content-save-outline' : 'pencil'}
onPress={() => setEditing(!editing)}
/>
</XStack>
)
},
})
{ editing && (
<Icon
color={getToken("$color.danger")}
name="delete-sweep-outline" // otherwise use "delete-circle"
onPress={() => navigation.navigate("DeletePlaylist", { playlist })}
/>
// If we've got the playlist tracks, set our component state
useEffect(() => {
if (!isPending && isSuccess) setPlaylistTracks(tracks)
}, [isPending, isSuccess])
)}
// Refresh playlist tracks if we've finished editing
useEffect(() => {
if (!editing) refetch()
}, [editing])
<Spacer />
const useUpdatePlaylist = useMutation({
mutationFn: ({ playlist, tracks }: { playlist: BaseItemDto; tracks: BaseItemDto[] }) => {
return updatePlaylist(
playlist.Id!,
playlist.Name!,
tracks.map((track) => track.Id!),
)
},
onSuccess: () => {
trigger('notificationSuccess')
<Icon
color={getToken("$color.amethyst")}
name={editing ? 'content-save-outline' : 'pencil'}
onPress={() => setEditing(!editing)}
/>
</XStack>
)
}
});
// Refresh playlist component data
queryClient.invalidateQueries({
queryKey: [QueryKeys.ItemTracks, playlist.Id],
})
},
onError: () => {
trigger('notificationError')
// If we've got the playlist tracks, set our component state
useEffect(() => {
if (!isPending && isSuccess)
setPlaylistTracks(tracks);
}, [
isPending,
isSuccess
]);
setPlaylistTracks(tracks ?? [])
},
})
// Refresh playlist tracks if we've finished editing
useEffect(() => {
if (!editing)
refetch();
}, [
editing
]);
const useRemoveFromPlaylist = useMutation({
mutationFn: ({ playlist, track, index }: RemoveFromPlaylistMutation) => {
return removeFromPlaylist(track, playlist)
},
onSuccess: (data, { index }) => {
trigger('notificationSuccess')
setPlaylistTracks(
playlistTracks
.slice(0, index)
.concat(playlistTracks.slice(index + 1, playlistTracks.length - 1)),
)
},
onError: () => {
trigger('notificationError')
},
})
const useUpdatePlaylist = useMutation({
mutationFn: ({ playlist, tracks }: { playlist: BaseItemDto, tracks: BaseItemDto[] }) => {
return updatePlaylist(playlist.Id!, playlist.Name!, tracks.map(track => track.Id!))
},
onSuccess: () => {
trigger('notificationSuccess');
return (
<DraggableFlatList
refreshControl={<RefreshControl refreshing={isPending} onRefresh={refetch} />}
contentInsetAdjustmentBehavior='automatic'
data={playlistTracks}
dragHitSlop={{ left: -50 }} // https://github.com/computerjazz/react-native-draggable-flatlist/issues/336
keyExtractor={({ Id }, index) => {
return `${index}-${Id}`
}}
ItemSeparatorComponent={() => <Separator />}
ListHeaderComponent={
<YStack alignItems='center' marginTop={'$4'}>
<Image
source={getImageApi(Client.api!).getItemImageUrlById(playlist.Id!)}
style={{
borderRadius: getToken('$5'),
width: getToken('$20') + getToken('$15'),
height: getToken('$20') + getToken('$15'),
}}
/>
// Refresh playlist component data
queryClient.invalidateQueries({
queryKey: [QueryKeys.ItemTracks, playlist.Id]
})
},
onError: () => {
trigger('notificationError');
<H4>{playlist.Name ?? 'Untitled Playlist'}</H4>
<H5>{playlist.ProductionYear?.toString() ?? ''}</H5>
</YStack>
}
numColumns={1}
onDragBegin={() => {
trigger('impactMedium')
}}
onDragEnd={({ data, from, to }) => {
console.debug(`Moving playlist item from ${from} to ${to}`)
setPlaylistTracks(tracks ?? []);
}
});
const useRemoveFromPlaylist = useMutation({
mutationFn: ({ playlist, track, index } : RemoveFromPlaylistMutation) => {
return removeFromPlaylist(track, playlist);
},
onSuccess: (data, { index }) => {
trigger("notificationSuccess");
setPlaylistTracks(playlistTracks.slice(0, index).concat(playlistTracks.slice(index + 1, playlistTracks.length -1)))
},
onError: () => {
trigger("notificationError")
}
});
return (
<DraggableFlatList
refreshControl={
<RefreshControl
refreshing={isPending}
onRefresh={refetch}
/>
}
contentInsetAdjustmentBehavior="automatic"
data={playlistTracks}
dragHitSlop={{ left: -50 }} // https://github.com/computerjazz/react-native-draggable-flatlist/issues/336
keyExtractor={({ Id }, index) => {
return `${index}-${Id}`
}}
ItemSeparatorComponent={() => <Separator />}
ListHeaderComponent={(
<YStack
alignItems="center"
marginTop={"$4"}
>
<Image
source={getImageApi(Client.api!).getItemImageUrlById(playlist.Id!)}
style={{
borderRadius: getToken("$5"),
width: getToken("$20") + getToken("$15"),
height: getToken("$20") + getToken("$15")
}}
/>
<H4>{ playlist.Name ?? "Untitled Playlist" }</H4>
<H5>{ playlist.ProductionYear?.toString() ?? "" }</H5>
</YStack>
)}
numColumns={1}
onDragBegin={() => {
trigger("impactMedium");
}}
onDragEnd={({ data, from, to }) => {
console.debug(`Moving playlist item from ${from} to ${to}`);
setPlaylistTracks(data);
useUpdatePlaylist.mutate({
playlist,
tracks: data
});
}}
refreshing={isPending}
renderItem={({ item: track, getIndex, drag }) =>
<Track
navigation={navigation}
track={track}
tracklist={tracks!}
index={getIndex()}
queue={playlist}
showArtwork
onLongPress={editing ? drag : undefined}
showRemove={editing}
onRemove={() => useRemoveFromPlaylist.mutate({ playlist, track, index: getIndex()! })}
/>
}
ListFooterComponent={(
<XStack justifyContent="flex-end">
<Text
color={"$borderColor"}
style={{ display: "block"}}
>
Total Runtime:
</Text>
<RunTimeTicks>{ playlist.RunTimeTicks }</RunTimeTicks>
</XStack>
)}
/>
)
}
setPlaylistTracks(data)
useUpdatePlaylist.mutate({
playlist,
tracks: data,
})
}}
refreshing={isPending}
renderItem={({ item: track, getIndex, drag }) => (
<Track
navigation={navigation}
track={track}
tracklist={tracks!}
index={getIndex()}
queue={playlist}
showArtwork
onLongPress={editing ? drag : undefined}
showRemove={editing}
onRemove={() =>
useRemoveFromPlaylist.mutate({ playlist, track, index: getIndex()! })
}
/>
)}
ListFooterComponent={
<XStack justifyContent='flex-end'>
<Text color={'$borderColor'} style={{ display: 'block' }}>
Total Runtime:
</Text>
<RunTimeTicks>{playlist.RunTimeTicks}</RunTimeTicks>
</XStack>
}
/>
)
}

View File

@@ -1,17 +1,15 @@
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";
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 PlaylistScreen({ route, navigation }: {
route: RouteProp<StackParamList, "Playlist">,
navigation: NativeStackNavigationProp<StackParamList>
}) : React.JSX.Element {
return (
<Playlist
playlist={route.params.playlist}
navigation={navigation}
/>
)
}
export function PlaylistScreen({
route,
navigation,
}: {
route: RouteProp<StackParamList, 'Playlist'>
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
return <Playlist playlist={route.params.playlist} navigation={navigation} />
}

View File

@@ -1,54 +1,56 @@
import { FlatList, RefreshControl } from "react-native-gesture-handler";
import { useSafeAreaFrame } from "react-native-safe-area-context";
import { ItemCard } from "../Global/components/item-card";
import { FavoritePlaylistsProps } from "../types";
import Icon from "../Global/helpers/icon";
import { getToken } from "tamagui";
import { fetchFavoritePlaylists } from "../../api/queries/functions/favorites";
import { QueryKeys } from "../../enums/query-keys";
import { useQuery } from "@tanstack/react-query";
import { FlatList, RefreshControl } from 'react-native-gesture-handler'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { ItemCard } from '../Global/components/item-card'
import { FavoritePlaylistsProps } from '../types'
import Icon from '../Global/helpers/icon'
import { getToken } from 'tamagui'
import { fetchFavoritePlaylists } from '../../api/queries/functions/favorites'
import { QueryKeys } from '../../enums/query-keys'
import { useQuery } from '@tanstack/react-query'
export default function FavoritePlaylists({ navigation }: FavoritePlaylistsProps) : React.JSX.Element {
export default function FavoritePlaylists({
navigation,
}: FavoritePlaylistsProps): React.JSX.Element {
navigation.setOptions({
headerRight: () => {
return (
<Icon
name='plus-circle-outline'
color={getToken('$color.telemagenta')}
onPress={() => navigation.navigate('AddPlaylist')}
/>
)
},
})
navigation.setOptions({
headerRight: () => {
return <Icon
name="plus-circle-outline"
color={getToken("$color.telemagenta")}
onPress={() => navigation.navigate('AddPlaylist')}
/>
}
});
const {
data: playlists,
isPending,
refetch,
} = useQuery({
queryKey: [QueryKeys.UserPlaylists],
queryFn: () => fetchFavoritePlaylists(),
})
const { data: playlists, isPending, refetch } = useQuery({
queryKey: [QueryKeys.UserPlaylists],
queryFn: () => fetchFavoritePlaylists()
});
const { width } = useSafeAreaFrame()
const { width } = useSafeAreaFrame();
return (
<FlatList
contentInsetAdjustmentBehavior="automatic"
numColumns={2}
data={playlists}
refreshControl={
<RefreshControl
refreshing={isPending}
onRefresh={refetch}
/>
}
renderItem={({ index, item: playlist }) =>
<ItemCard
item={playlist}
caption={playlist.Name ?? "Untitled Playlist"}
onPress={() => {
navigation.navigate("Playlist", { playlist })
}}
width={width / 2.1}
squared
/>
}
/>
)
}
return (
<FlatList
contentInsetAdjustmentBehavior='automatic'
numColumns={2}
data={playlists}
refreshControl={<RefreshControl refreshing={isPending} onRefresh={refetch} />}
renderItem={({ index, item: playlist }) => (
<ItemCard
item={playlist}
caption={playlist.Name ?? 'Untitled Playlist'}
onPress={() => {
navigation.navigate('Playlist', { playlist })
}}
width={width / 2.1}
squared
/>
)}
/>
)
}

View File

@@ -1,12 +1,10 @@
import { NativeStackScreenProps } from "@react-navigation/native-stack";
import { StackParamList } from "../types";
import FavoritePlaylists from "./component";
import React from "react";
import { NativeStackScreenProps } from '@react-navigation/native-stack'
import { StackParamList } from '../types'
import FavoritePlaylists from './component'
import React from 'react'
export default function PlaylistsScreen(
props: NativeStackScreenProps<StackParamList, 'Playlists'>
) : React.JSX.Element {
return (
<FavoritePlaylists {...props} />
)
}
props: NativeStackScreenProps<StackParamList, 'Playlists'>,
): React.JSX.Element {
return <FavoritePlaylists {...props} />
}

View File

@@ -1,114 +1,116 @@
import React, { useCallback, useState } from "react";
import Input from "../Global/helpers/input";
import Item from "../Global/components/item";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { StackParamList } from "../types";
import { QueryKeys } from "../../enums/query-keys";
import { fetchSearchResults } from "../../api/queries/functions/search";
import { useQuery } from "@tanstack/react-query";
import { FlatList } from "react-native";
import { H3 } from "../Global/helpers/text";
import { fetchSearchSuggestions } from "../../api/queries/functions/suggestions";
import { Spinner, YStack } from "tamagui";
import Suggestions from "./suggestions";
import { isEmpty } from "lodash";
import HorizontalCardList from "../Global/components/horizontal-list";
import { ItemCard } from "../Global/components/item-card";
import React, { useCallback, useState } from 'react'
import Input from '../Global/helpers/input'
import Item from '../Global/components/item'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { StackParamList } from '../types'
import { QueryKeys } from '../../enums/query-keys'
import { fetchSearchResults } from '../../api/queries/functions/search'
import { useQuery } from '@tanstack/react-query'
import { FlatList } from 'react-native'
import { H3 } from '../Global/helpers/text'
import { fetchSearchSuggestions } from '../../api/queries/functions/suggestions'
import { Spinner, YStack } from 'tamagui'
import Suggestions from './suggestions'
import { isEmpty } from 'lodash'
import HorizontalCardList from '../Global/components/horizontal-list'
import { ItemCard } from '../Global/components/item-card'
export default function Search({
navigation
}: {
navigation: NativeStackNavigationProp<StackParamList>
export default function Search({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const [searchString, setSearchString] = useState<string | undefined>(undefined)
const [searchString, setSearchString] = useState<string | undefined>(undefined);
const {
data: items,
refetch,
isFetching: fetchingResults,
} = useQuery({
queryKey: [QueryKeys.Search, searchString],
queryFn: () => fetchSearchResults(searchString),
})
const { data: items, refetch, isFetching: fetchingResults } = useQuery({
queryKey: [QueryKeys.Search, searchString],
queryFn: () => fetchSearchResults(searchString)
});
const {
data: suggestions,
isFetching: fetchingSuggestions,
refetch: refetchSuggestions,
} = useQuery({
queryKey: [QueryKeys.SearchSuggestions],
queryFn: () => fetchSearchSuggestions(),
})
const { data: suggestions, isFetching: fetchingSuggestions, refetch: refetchSuggestions } = useQuery({
queryKey: [QueryKeys.SearchSuggestions],
queryFn: () => fetchSearchSuggestions()
});
const search = useCallback(() => {
let timeout: NodeJS.Timeout
const search = useCallback(() => {
return () => {
clearTimeout(timeout)
timeout = setTimeout(() => {
refetch()
refetchSuggestions()
}, 1000)
}
}, [])
let timeout : NodeJS.Timeout;
return () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
refetch();
refetchSuggestions();
}, 1000)
}
}, []);
const handleSearchStringUpdate = (value: string | undefined) => {
setSearchString(value)
search()
}
const handleSearchStringUpdate = (value: string | undefined) => {
setSearchString(value)
search();
}
return (
<FlatList
contentInsetAdjustmentBehavior='automatic'
progressViewOffset={10}
ListHeaderComponent={
<YStack>
<Input
placeholder='Seek and ye shall find'
onChangeText={(value) => handleSearchStringUpdate(value)}
value={searchString}
/>
return (
<FlatList
contentInsetAdjustmentBehavior="automatic"
progressViewOffset={10}
ListHeaderComponent={(
<YStack>
<Input
placeholder="Seek and ye shall find"
onChangeText={(value) => handleSearchStringUpdate(value)}
value={searchString}
/>
{!isEmpty(items) && (
<YStack>
<H3>Results</H3>
{ !isEmpty(items) && (
<YStack>
<H3>Results</H3>
<HorizontalCardList
data={items?.filter(result => result.Type === 'MusicArtist')}
renderItem={({ item: artistResult }) => {
return (
<ItemCard
item={artistResult}
onPress={() => {
navigation.push('Artist', {
artist: artistResult
})
}}
size={"$8"}
caption={artistResult.Name ?? "Untitled Artist"}
/>
)
}}
/>
</YStack>
)}
</YStack>
)}
ListEmptyComponent={() => {
return (
<YStack
alignContent="center"
justifyContent="flex-end"
marginTop={"$4"}
>
{ fetchingResults ? (
<Spinner />
) : (
<Suggestions suggestions={suggestions} navigation={navigation} />
)}
</YStack>
)
}}
// We're displaying artists separately so we're going to filter them out here
data={items?.filter((result) => result.Type !== 'MusicArtist')}
refreshing={fetchingResults}
renderItem={({ item }) =>
<Item item={item} queueName={searchString ?? "Search"} navigation={navigation} />
}
/>
)
}
<HorizontalCardList
data={items?.filter((result) => result.Type === 'MusicArtist')}
renderItem={({ item: artistResult }) => {
return (
<ItemCard
item={artistResult}
onPress={() => {
navigation.push('Artist', {
artist: artistResult,
})
}}
size={'$8'}
caption={artistResult.Name ?? 'Untitled Artist'}
/>
)
}}
/>
</YStack>
)}
</YStack>
}
ListEmptyComponent={() => {
return (
<YStack alignContent='center' justifyContent='flex-end' marginTop={'$4'}>
{fetchingResults ? (
<Spinner />
) : (
<Suggestions suggestions={suggestions} navigation={navigation} />
)}
</YStack>
)
}}
// We're displaying artists separately so we're going to filter them out here
data={items?.filter((result) => result.Type !== 'MusicArtist')}
refreshing={fetchingResults}
renderItem={({ item }) => (
<Item item={item} queueName={searchString ?? 'Search'} navigation={navigation} />
)}
/>
)
}

View File

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

View File

@@ -1,65 +1,65 @@
import { createNativeStackNavigator } from "@react-navigation/native-stack"
import SearchScreen from "./screen";
import { StackParamList } from "../types";
import { ArtistScreen } from "../Artist";
import { AlbumScreen } from "../Album";
import { PlaylistScreen } from "../Playlist/screens";
import DetailsScreen from "../ItemDetail/screen";
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import SearchScreen from './screen'
import { StackParamList } from '../types'
import { ArtistScreen } from '../Artist'
import { AlbumScreen } from '../Album'
import { PlaylistScreen } from '../Playlist/screens'
import DetailsScreen from '../ItemDetail/screen'
const Stack = createNativeStackNavigator<StackParamList>();
const Stack = createNativeStackNavigator<StackParamList>()
export default function SearchStack() : React.JSX.Element {
return (
<Stack.Navigator>
<Stack.Screen
name="Search"
component={SearchScreen}
options={{
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold'
}
}}
/>
export default function SearchStack(): React.JSX.Element {
return (
<Stack.Navigator>
<Stack.Screen
name='Search'
component={SearchScreen}
options={{
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold',
},
}}
/>
<Stack.Screen
name="Artist"
component={ArtistScreen}
options={({ route }) => ({
title: route.params.artist.Name ?? "Unknown Artist",
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold'
}
})}
/>
<Stack.Screen
name='Artist'
component={ArtistScreen}
options={({ route }) => ({
title: route.params.artist.Name ?? 'Unknown Artist',
headerLargeTitle: true,
headerLargeTitleStyle: {
fontFamily: 'Aileron-Bold',
},
})}
/>
<Stack.Screen
name="Album"
component={AlbumScreen}
options={({ route }) => ({
headerShown: true,
headerTitle: ""
})}
/>
<Stack.Screen
name='Album'
component={AlbumScreen}
options={({ route }) => ({
headerShown: true,
headerTitle: '',
})}
/>
<Stack.Screen
name="Playlist"
component={PlaylistScreen}
options={({ route }) => ({
headerShown: true,
headerTitle: ""
})}
/>
<Stack.Screen
name='Playlist'
component={PlaylistScreen}
options={({ route }) => ({
headerShown: true,
headerTitle: '',
})}
/>
<Stack.Screen
name="Details"
component={DetailsScreen}
options={{
headerShown: false,
presentation: "modal"
}}
/>
</Stack.Navigator>
)
}
<Stack.Screen
name='Details'
component={DetailsScreen}
options={{
headerShown: false,
presentation: 'modal',
}}
/>
</Stack.Navigator>
)
}

View File

@@ -1,55 +1,56 @@
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { FlatList, RefreshControl } from "react-native";
import { StackParamList } from "../types";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import Item from "../Global/components/item";
import { H3, Text } from "../Global/helpers/text";
import { YStack } from "tamagui";
import { ItemCard } from "../Global/components/item-card";
import HorizontalCardList from "../Global/components/horizontal-list";
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { FlatList, RefreshControl } from 'react-native'
import { StackParamList } from '../types'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import Item from '../Global/components/item'
import { H3, Text } from '../Global/helpers/text'
import { YStack } from 'tamagui'
import { ItemCard } from '../Global/components/item-card'
import HorizontalCardList from '../Global/components/horizontal-list'
interface SuggestionsProps {
suggestions: BaseItemDto[] | undefined;
navigation: NativeStackNavigationProp<StackParamList>;
suggestions: BaseItemDto[] | undefined
navigation: NativeStackNavigationProp<StackParamList>
}
export default function Suggestions(
props: SuggestionsProps
) : React.JSX.Element {
export default function Suggestions(props: SuggestionsProps): React.JSX.Element {
return (
<FlatList
// Artists are displayed in the header, so we'll filter them out here
data={props.suggestions?.filter((suggestion) => suggestion.Type !== 'MusicArtist')}
ListHeaderComponent={
<YStack>
<H3>Suggestions</H3>
return (
<FlatList
// Artists are displayed in the header, so we'll filter them out here
data={props.suggestions?.filter(suggestion => suggestion.Type !== 'MusicArtist')}
ListHeaderComponent={(
<YStack>
<H3>Suggestions</H3>
<HorizontalCardList
data={props.suggestions?.filter(suggestion => suggestion.Type === 'MusicArtist')}
renderItem={({ item: suggestedArtist }) => {
return (
<ItemCard
item={suggestedArtist}
onPress={() => {
props.navigation.push('Artist', {
artist: suggestedArtist
})
}}
size={"$8"}
caption={suggestedArtist.Name ?? "Untitled Artist"}
/>
)
}}
/>
</YStack>
)}
ListEmptyComponent={(
<Text textAlign="center">Wake now, discover that you are the eyes of the world...</Text>
)}
renderItem={({ item }) => {
return <Item item={item} queueName={"Suggestions"} navigation={props.navigation} />
}}
/>
)
}
<HorizontalCardList
data={props.suggestions?.filter(
(suggestion) => suggestion.Type === 'MusicArtist',
)}
renderItem={({ item: suggestedArtist }) => {
return (
<ItemCard
item={suggestedArtist}
onPress={() => {
props.navigation.push('Artist', {
artist: suggestedArtist,
})
}}
size={'$8'}
caption={suggestedArtist.Name ?? 'Untitled Artist'}
/>
)
}}
/>
</YStack>
}
ListEmptyComponent={
<Text textAlign='center'>
Wake now, discover that you are the eyes of the world...
</Text>
}
renderItem={({ item }) => {
return <Item item={item} queueName={'Suggestions'} navigation={props.navigation} />
}}
/>
)
}

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