API Client Refactor, Add Frequents to CarPlay, Allow Playback on Startup (#327)

Refactors the client.ts file and moves it's functionality into the JellifyContext for use in other componentry. Lots of touching in the api folder to refactor this. I also found an issue when adding items to a playlist, so I bumped the axios package - which fixed the issue.

Adds "Most Played" and "On Repeat" sections to CarPlay. Most Played will allow users to view their most played artists, while On Repeat will give users access to their top tracks, of which they can select and start playback

Fixes an issue where on startup, if the user was logged in and had a persisted queue, it wouldn't playback. Users should be able to directly start up the queue upon relaunching the app if they are authenticated
This commit is contained in:
Violet Caulfield
2025-05-02 18:34:57 -05:00
committed by GitHub
parent cfc58f7a42
commit c421c20d35
76 changed files with 1610 additions and 1250 deletions

View File

@@ -4,13 +4,6 @@ import App from './App'
import { name as appName } from './app.json'
import { PlaybackService } from './src/player/service'
import TrackPlayer from 'react-native-track-player'
import Client from './src/api/client'
// Initialize API client instance
/* eslint-disable @typescript-eslint/no-unused-expressions */
Client.instance
console.debug('Created Jellify client')
AppRegistry.registerComponent(appName, () => App)
AppRegistry.registerComponent('RNCarPlayScene', () => App)

View File

@@ -0,0 +1,57 @@
import { render, screen, waitFor } from '@testing-library/react-native'
import { JellifyProvider, useJellifyContext } from '../src/components/provider'
import { Text, View } from 'react-native'
import { MMKVStorageKeys } from '../src/enums/mmkv-storage-keys'
import { storage } from '../src/constants/storage'
import { useEffect } from 'react'
const JellifyConsumer = () => {
const { server, user, library } = useJellifyContext()
return (
<View>
<Text testID='api-base-path'>{server?.url}</Text>
<Text testID='user-name'>{user?.name}</Text>
<Text testID='library-name'>{library?.musicLibraryName}</Text>
</View>
)
}
test(`${JellifyProvider.name} renders correctly`, async () => {
storage.set(
MMKVStorageKeys.Server,
JSON.stringify({
url: 'http://localhost:8096',
}),
)
storage.set(
MMKVStorageKeys.User,
JSON.stringify({
name: 'Violet Caulfield',
}),
)
storage.set(
MMKVStorageKeys.Library,
JSON.stringify({
musicLibraryName: 'Music Library',
}),
)
render(
<JellifyProvider>
<JellifyConsumer />
</JellifyProvider>,
)
const apiBasePath = screen.getByTestId('api-base-path')
const userName = screen.getByTestId('user-name')
const libraryName = screen.getByTestId('library-name')
await waitFor(() => {
expect(apiBasePath.props.children).toBe('http://localhost:8096')
expect(userName.props.children).toBe('Violet Caulfield')
expect(libraryName.props.children).toBe('Music Library')
})
})

View File

@@ -1,4 +1,18 @@
jest.mock('../src/api/client')
jest.mock('../src/api/info', () => {
return {
JellyfinInfo: {
clientInfo: {
name: 'Jellify',
version: '0.0.1',
},
deviceInfo: {
name: 'iPhone 12',
id: '1234567890',
},
createApi: jest.fn(),
},
}
})
jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter')

View File

@@ -1,120 +1,120 @@
{
"name": "jellify",
"version": "0.11.23",
"private": true,
"scripts": {
"init-android": "yarn",
"init-ios": "yarn init-ios:new-arch",
"init-ios:new-arch": "yarn && yarn pod:install:new-arch",
"reinstall": "rm -rf ./node_modules && yarn install",
"android": "react-native run-android",
"ios": "react-native run-ios",
"lint": "eslint .",
"start": "react-native start",
"test": "jest",
"tsc": "tsc",
"clean:ios": "cd ios && pod deintegrate",
"clean:android": "cd android && rm -rf app/ build/",
"pod:install": "echo 'Please run `yarn pod:install:new-arch` to enable the new architecture'",
"pod:install:new-arch": "cd ios && bundle install && RCT_NEW_ARCH_ENABLED=1 bundle exec pod install",
"pod:clean": "cd ios && pod deintegrate",
"fastlane:ios:build": "cd ios && bundle exec fastlane build",
"fastlane:ios:match": "cd ios && bundle exec fastlane match development",
"fastlane:ios:beta": "cd ios && bundle exec fastlane beta",
"fastlane:android:build": "cd android && bundle install && bundle exec fastlane build",
"androidBuild": "cd android && ./gradlew clean && ./gradlew assembleRelease && cd .. && echo 'find apk in android/app/build/outputs/apk/release'",
"prepare": "husky",
"format:check": "prettier --check .",
"format": "prettier --write .",
"postinstall": "patch-package"
},
"dependencies": {
"@jellyfin/sdk": "^0.11.0",
"@react-native-community/blur": "^4.4.1",
"@react-native-community/cli": "^18.0.0",
"@react-native-community/netinfo": "^11.4.1",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-navigation/bottom-tabs": "^7.3.10",
"@react-navigation/material-top-tabs": "^7.2.10",
"@react-navigation/native": "^7.1.6",
"@react-navigation/native-stack": "^7.3.10",
"@react-navigation/stack": "^7.2.10",
"@tamagui/config": "^1.126.4",
"@tanstack/query-sync-storage-persister": "^5.74.6",
"@tanstack/react-query": "^5.74.4",
"@tanstack/react-query-persist-client": "^5.74.6",
"@testing-library/react-native": "^13.2.0",
"axios": "^1.8.4",
"bundle": "^2.1.0",
"gem": "^2.4.3",
"invert-color": "^2.0.0",
"lodash": "^4.17.21",
"react": "19.0.0",
"react-freeze": "^1.0.4",
"react-native": "0.79.1",
"react-native-background-actions": "^4.0.1",
"react-native-carplay": "^2.4.1-beta.0",
"react-native-device-info": "^14.0.4",
"react-native-draggable-flatlist": "^4.0.2",
"react-native-fast-image": "^8.6.3",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "^2.25.0",
"react-native-haptic-feedback": "^2.3.3",
"react-native-mmkv": "3.2.0",
"react-native-pager-view": "^6.7.1",
"react-native-reanimated": "^3.17.5",
"react-native-safe-area-context": "^5.4.0",
"react-native-screens": "^4.11.0-beta.2",
"react-native-swipeable-item": "^2.0.9",
"react-native-text-ticker": "^1.14.0",
"react-native-toast-message": "^2.3.0",
"react-native-track-player": "git+https://github.com/riteshshukla04/react-native-track-player#APM",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.3",
"react-native-vector-icons": "^10.2.0",
"ruby": "^0.6.1",
"tamagui": "^1.126.4"
},
"devDependencies": {
"@babel/core": "^7.27.1",
"@babel/preset-env": "^7.27.1",
"@babel/runtime": "^7.27.1",
"@react-native-community/cli-platform-android": "18.0.0",
"@react-native-community/cli-platform-ios": "18.0.0",
"@react-native/babel-preset": "0.79.1",
"@react-native/eslint-config": "0.79.1",
"@react-native/metro-config": "0.79.1",
"@react-native/typescript-config": "0.79.1",
"@types/jest": "^29.5.13",
"@types/lodash": "^4.17.10",
"@types/react": "^19.1.2",
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "19.0.0",
"babel-plugin-module-resolver": "^5.0.2",
"eslint": "^8.57.1",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-native": "^5.0.0",
"husky": "^9.1.7",
"jest": "^29.6.3",
"jscodeshift": "^0.15.2",
"lint-staged": "^15.5.1",
"patch-package": "8.0.0",
"prettier": "^3.5.3",
"react-dom": "^19.0.0",
"react-native-cli-bump-version": "^1.5.1",
"react-test-renderer": "19.0.0",
"typescript": "5.8.3"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"prettier --write",
"eslint --fix"
]
},
"engines": {
"node": ">=18"
}
}
"name": "jellify",
"version": "0.11.23",
"private": true,
"scripts": {
"init-android": "yarn",
"init-ios": "yarn init-ios:new-arch",
"init-ios:new-arch": "yarn && yarn pod:install:new-arch",
"reinstall": "rm -rf ./node_modules && yarn install",
"android": "react-native run-android",
"ios": "react-native run-ios",
"lint": "eslint .",
"start": "react-native start",
"test": "jest",
"tsc": "tsc",
"clean:ios": "cd ios && pod deintegrate",
"clean:android": "cd android && rm -rf app/ build/",
"pod:install": "echo 'Please run `yarn pod:install:new-arch` to enable the new architecture'",
"pod:install:new-arch": "cd ios && bundle install && RCT_NEW_ARCH_ENABLED=1 bundle exec pod install",
"pod:clean": "cd ios && pod deintegrate",
"fastlane:ios:build": "cd ios && bundle exec fastlane build",
"fastlane:ios:match": "cd ios && bundle exec fastlane match development",
"fastlane:ios:beta": "cd ios && bundle exec fastlane beta",
"fastlane:android:build": "cd android && bundle install && bundle exec fastlane build",
"androidBuild": "cd android && ./gradlew clean && ./gradlew assembleRelease && cd .. && echo 'find apk in android/app/build/outputs/apk/release'",
"prepare": "husky",
"format:check": "prettier --check .",
"format": "prettier --write .",
"postinstall": "patch-package"
},
"dependencies": {
"@jellyfin/sdk": "^0.11.0",
"@react-native-community/blur": "^4.4.1",
"@react-native-community/cli": "^18.0.0",
"@react-native-community/netinfo": "^11.4.1",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-navigation/bottom-tabs": "^7.3.10",
"@react-navigation/material-top-tabs": "^7.2.10",
"@react-navigation/native": "^7.1.6",
"@react-navigation/native-stack": "^7.3.10",
"@react-navigation/stack": "^7.2.10",
"@tamagui/config": "^1.126.4",
"@tanstack/query-sync-storage-persister": "^5.74.6",
"@tanstack/react-query": "^5.74.4",
"@tanstack/react-query-persist-client": "^5.74.6",
"@testing-library/react-native": "^13.2.0",
"axios": "^1.9.0",
"bundle": "^2.1.0",
"gem": "^2.4.3",
"invert-color": "^2.0.0",
"lodash": "^4.17.21",
"react": "19.0.0",
"react-freeze": "^1.0.4",
"react-native": "0.79.1",
"react-native-background-actions": "^4.0.1",
"react-native-carplay": "^2.4.1-beta.0",
"react-native-device-info": "^14.0.4",
"react-native-draggable-flatlist": "^4.0.2",
"react-native-fast-image": "^8.6.3",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "^2.25.0",
"react-native-haptic-feedback": "^2.3.3",
"react-native-mmkv": "3.2.0",
"react-native-pager-view": "^6.7.1",
"react-native-reanimated": "^3.17.5",
"react-native-safe-area-context": "^5.4.0",
"react-native-screens": "^4.11.0-beta.2",
"react-native-swipeable-item": "^2.0.9",
"react-native-text-ticker": "^1.14.0",
"react-native-toast-message": "^2.3.0",
"react-native-track-player": "git+https://github.com/riteshshukla04/react-native-track-player#APM",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.3",
"react-native-vector-icons": "^10.2.0",
"ruby": "^0.6.1",
"tamagui": "^1.126.4"
},
"devDependencies": {
"@babel/core": "^7.27.1",
"@babel/preset-env": "^7.27.1",
"@babel/runtime": "^7.27.1",
"@react-native-community/cli-platform-android": "18.0.0",
"@react-native-community/cli-platform-ios": "18.0.0",
"@react-native/babel-preset": "0.79.1",
"@react-native/eslint-config": "0.79.1",
"@react-native/metro-config": "0.79.1",
"@react-native/typescript-config": "0.79.1",
"@types/jest": "^29.5.13",
"@types/lodash": "^4.17.10",
"@types/react": "^19.1.2",
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "19.0.0",
"babel-plugin-module-resolver": "^5.0.2",
"eslint": "^8.57.1",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-native": "^5.0.0",
"husky": "^9.1.7",
"jest": "^29.6.3",
"jscodeshift": "^0.15.2",
"lint-staged": "^15.5.1",
"patch-package": "8.0.0",
"prettier": "^3.5.3",
"react-dom": "^19.0.0",
"react-native-cli-bump-version": "^1.5.1",
"react-test-renderer": "19.0.0",
"typescript": "5.8.3"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"prettier --write",
"eslint --fix"
]
},
"engines": {
"node": ">=18"
}
}

View File

@@ -1,162 +0,0 @@
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
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,
) {
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 (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 (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
}
public static get instance(): Client {
if (!Client.#instance) {
Client.#instance = new Client()
}
return Client.#instance
}
public static get api(): Api | undefined {
return Client.#instance.api
}
public static get server(): JellifyServer | undefined {
return Client.#instance.server
}
public static get user(): JellifyUser | undefined {
return Client.#instance.user
}
public static get library(): JellifyLibrary | undefined {
return Client.#instance.library
}
public static get sessionId(): string {
return Client.#instance.sessionId
}
public static signOut(): void {
Client.#instance.removeCredentials()
}
public static switchServer(): void {
Client.#instance.removeServer()
}
public static switchUser(): void {
Client.#instance.removeUser()
}
public static setUser(user: JellifyUser): void {
Client.#instance.setAndPersistUser(user)
}
private setAndPersistUser(user: JellifyUser) {
this.user = user
// persist user details
storage.set(MMKVStorageKeys.User, JSON.stringify(user))
}
private setAndPersistServer(server: JellifyServer) {
this.server = server
storage.set(MMKVStorageKeys.Server, JSON.stringify(server))
}
private setAndPersistLibrary(library: JellifyLibrary) {
this.library = library
storage.set(MMKVStorageKeys.Library, JSON.stringify(library))
}
private removeCredentials() {
this.library = undefined
this.server = undefined
this.user = undefined
storage.delete(MMKVStorageKeys.Server)
storage.delete(MMKVStorageKeys.Library)
storage.delete(MMKVStorageKeys.User)
}
private removeServer() {
this.server = undefined
storage.delete(MMKVStorageKeys.Server)
}
private removeUser() {
this.user = undefined
storage.delete(MMKVStorageKeys.User)
}
/**
* Uses the jellifyClient to create a public Jellyfin API instance.
* @param serverUrl The URL of the Jellyfin server
* @returns
*/
public static setPublicApiClient(server: JellifyServer): void {
const api = JellyfinInfo.createApi(server.url)
Client.#instance = new Client(api, undefined, server, undefined)
}
/**
*
* @param serverUrl The URL of the Jellyfin server
* @param accessToken The assigned accessToken for the Jellyfin user
*/
public static setPrivateApiClient(server: JellifyServer, user: JellifyUser): void {
const api = JellyfinInfo.createApi(server.url, user.accessToken)
Client.#instance = new Client(api, user, server, undefined)
}
public static setLibrary(library: JellifyLibrary): void {
Client.#instance = new Client(undefined, undefined, undefined, library)
}
}

View File

@@ -1,4 +1,5 @@
import Client from '../client'
import { JellifyUser } from '../../types/JellifyUser'
import { Api } from '@jellyfin/sdk'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { isUndefined } from 'lodash'
@@ -14,15 +15,19 @@ import { isUndefined } from 'lodash'
*
* @param item The item to mark as played
*/
export async function markItemPlayed(item: BaseItemDto): Promise<void> {
export async function markItemPlayed(
api: Api | undefined,
user: JellifyUser | undefined,
item: BaseItemDto,
): Promise<void> {
return new Promise((resolve, reject) => {
if (isUndefined(Client.api) || isUndefined(Client.user))
return reject('Client instance not set')
if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(user)) return reject('User instance not set')
getItemsApi(Client.api)
getItemsApi(api)
.updateItemUserData({
itemId: item.Id!,
userId: Client.user.id,
userId: user.id,
updateUserItemDataDto: {
LastPlayedDate: new Date().getUTCDate().toLocaleString(),
Played: true,

View File

@@ -1,54 +1,189 @@
import { JellifyUser } from '@/src/types/JellifyUser'
import { Api } from '@jellyfin/sdk'
import { BaseItemDto, MediaType } from '@jellyfin/sdk/lib/generated-client/models'
import Client from '../client'
import { getLibraryApi, getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api'
import { isUndefined } from 'lodash'
export async function addToPlaylist(track: BaseItemDto, playlist: BaseItemDto) {
/**
* Adds a track to a Jellyfin playlist.
*
* @param api The Jellyfin {@link Api} client
* @param user The signed in {@link JellifyUser}
* @param track The {@link BaseItemDto} to add
* @param playlist The {@link BaseItemDto} playlist to add the track to
* @returns
*/
export async function addToPlaylist(
api: Api | undefined,
user: JellifyUser | undefined,
track: BaseItemDto,
playlist: BaseItemDto,
): Promise<void> {
console.debug('Adding track to playlist')
return getPlaylistsApi(Client.api!).addItemToPlaylist({
ids: [track.Id!],
playlistId: playlist.Id!,
return new Promise<void>((resolve, reject) => {
if (isUndefined(api)) return reject(new Error('No API client available'))
if (isUndefined(user)) return reject(new Error('No user available'))
console.debug(api)
console.debug(api.axiosInstance)
getPlaylistsApi(api)
.addItemToPlaylist(
{
ids: [track.Id!],
userId: user.id,
playlistId: playlist.Id!,
},
{
headers: {},
},
)
.then(() => {
resolve()
})
.catch((error) => {
console.error(error)
reject(error)
})
})
}
export async function removeFromPlaylist(track: BaseItemDto, playlist: BaseItemDto) {
/**
* Removes a track from a Jellyfin playlist.
*
* @param api The Jellyfin {@link Api} client
* @param track The {@link BaseItemDto} to remove
* @param playlist The {@link BaseItemDto} playlist to remove the track from
* @returns
*/
export async function removeFromPlaylist(
api: Api | undefined,
track: BaseItemDto,
playlist: BaseItemDto,
) {
console.debug('Removing track from playlist')
return getPlaylistsApi(Client.api!).removeItemFromPlaylist({
playlistId: playlist.Id!,
entryIds: [track.Id!],
return new Promise<void>((resolve, reject) => {
if (isUndefined(api)) return reject(new Error('No API client available'))
getPlaylistsApi(api)
.removeItemFromPlaylist({
playlistId: playlist.Id!,
entryIds: [track.Id!],
})
.then(() => {
resolve()
})
.catch((error) => {
reject(error)
console.error(error)
})
})
}
export async function reorderPlaylist(playlistId: string, itemId: string, to: number) {
/**
* Reorders a track in a Jellyfin playlist.
*
* @param api The Jellyfin {@link Api} client
* @param playlistId The ID field of the playlist to reorder
* @param itemId The ID field of the item to reorder
* @param to The index to move the item to
* @returns
*/
export async function reorderPlaylist(
api: Api | undefined,
playlistId: string,
itemId: string,
to: number,
) {
console.debug(`Moving track to index ${to}`)
return getPlaylistsApi(Client.api!).moveItem({
playlistId,
itemId,
newIndex: to,
return new Promise<void>((resolve, reject) => {
if (isUndefined(api)) return reject(new Error('No API client available'))
getPlaylistsApi(api)
.moveItem({
playlistId,
itemId,
newIndex: to,
})
.then(() => {
resolve()
})
.catch((error) => {
reject(error)
console.error(error)
})
})
}
export async function createPlaylist(name: string) {
/**
* Creates a new Jellyfin playlist on the server.
*
* @param api The Jellyfin {@link Api} client
* @param user The signed in {@link JellifyUser}
* @param name The name of the playlist to create
* @returns
*/
export async function createPlaylist(
api: Api | undefined,
user: JellifyUser | undefined,
name: string,
) {
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 new Promise<void>((resolve, reject) => {
if (isUndefined(api)) return reject(new Error('No API client available'))
if (isUndefined(user)) return reject(new Error('No user available'))
getPlaylistsApi(api)
.createPlaylist({
userId: user.id,
mediaType: MediaType.Audio,
createPlaylistDto: {
Name: name,
IsPublic: false,
MediaType: MediaType.Audio,
},
})
.then(() => {
resolve()
})
.catch((error) => {
reject(error)
console.error(error)
})
})
}
export async function deletePlaylist(playlistId: string) {
/**
* Deletes a Jellyfin playlist from the server.
*
* @param api The Jellyfin {@link Api} client
* @param playlistId The ID field of the playlist to delete
* @returns
*/
export async function deletePlaylist(api: Api | undefined, playlistId: string) {
console.debug('Deleting playlist...')
return getLibraryApi(Client.api!).deleteItem({
itemId: playlistId,
return new Promise<void>((resolve, reject) => {
if (isUndefined(api)) return reject(new Error('No API client available'))
getLibraryApi(api)
.deleteItem({
itemId: playlistId,
})
.then(() => {
resolve()
})
.catch((error) => {
reject(error)
console.error(error)
})
})
}
@@ -61,14 +196,31 @@ export async function deletePlaylist(playlistId: string) {
* @param playlistId The Jellyfin ID of the playlist to update
* @returns
*/
export async function updatePlaylist(playlistId: string, name: string, trackIds: string[]) {
export async function updatePlaylist(
api: Api | undefined,
playlistId: string,
name: string,
trackIds: string[],
) {
console.debug('Updating playlist')
return getPlaylistsApi(Client.api!).updatePlaylist({
playlistId,
updatePlaylistDto: {
Name: name,
Ids: trackIds,
},
return new Promise<void>((resolve, reject) => {
if (isUndefined(api)) return reject(new Error('No API client available'))
getPlaylistsApi(api)
.updatePlaylist({
playlistId,
updatePlaylistDto: {
Name: name,
Ids: trackIds,
},
})
.then(() => {
resolve()
})
.catch((error) => {
reject(error)
console.error(error)
})
})
}

View File

@@ -1,4 +1,6 @@
import Client from '../client'
import { JellifyLibrary } from '../../../src/types/JellifyLibrary'
import { JellifyUser } from '../../types/JellifyUser'
import { Api } from '@jellyfin/sdk'
import {
BaseItemDto,
BaseItemKind,
@@ -7,111 +9,202 @@ import {
UserItemDataDto,
} from '@jellyfin/sdk/lib/generated-client/models'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { isUndefined } from 'lodash'
export async function fetchFavoriteArtists(): Promise<BaseItemDto[]> {
/**
* Fetches the {@link BaseItemDto}s that are marked as favorite artists
* @param api The Jellyfin {@link Api} instance
* @param user The Jellyfin {@link JellifyUser} instance
* @param library The Jellyfin {@link JellifyLibrary} instance
* @returns The {@link BaseItemDto}s that are marked as favorite artists
*/
export async function fetchFavoriteArtists(
api: Api | undefined,
user: JellifyUser | undefined,
library: JellifyLibrary | undefined,
): 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)
if (response.data.Items) return response.data.Items
else return []
})
.catch((error) => {
console.error(error)
return []
})
return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(user)) return reject('User instance not set')
if (isUndefined(library)) return reject('Library instance not set')
getItemsApi(api)
.getItems({
includeItemTypes: [BaseItemKind.MusicArtist],
isFavorite: true,
parentId: library.musicLibraryId,
recursive: true,
sortBy: [ItemSortBy.SortName],
sortOrder: [SortOrder.Ascending],
})
.then((response) => {
console.debug(`Received favorite artist response`, response)
if (response.data.Items) return resolve(response.data.Items)
else return resolve([])
})
.catch((error) => {
console.error(error)
return reject(error)
})
})
}
export async function fetchFavoriteAlbums(): Promise<BaseItemDto[]> {
/**
* Fetches the {@link BaseItemDto}s that are marked as favorite albums
* @param api The Jellyfin {@link Api} instance
* @param user The Jellyfin {@link JellifyUser} instance
* @param library The Jellyfin {@link JellifyLibrary} instance
* @returns The {@link BaseItemDto}s that are marked as favorite albums
*/
export async function fetchFavoriteAlbums(
api: Api | undefined,
user: JellifyUser | undefined,
library: JellifyLibrary | undefined,
): Promise<BaseItemDto[]> {
console.debug(`Fetching user's favorite albums`)
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)
return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(user)) return reject('User instance not set')
if (isUndefined(library)) return reject('Library instance not set')
if (response.data.Items) return response.data.Items
else return []
})
.catch((error) => {
console.error(error)
return []
})
getItemsApi(api)
.getItems({
includeItemTypes: [BaseItemKind.MusicAlbum],
isFavorite: true,
parentId: 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) return resolve(response.data.Items)
else return resolve([])
})
.catch((error) => {
console.error(error)
return reject(error)
})
})
}
export async function fetchFavoritePlaylists(): Promise<BaseItemDto[]> {
/**
* Fetches the {@link BaseItemDto}s that are marked as favorite playlists
* @param api The Jellyfin {@link Api} instance
* @param user The Jellyfin {@link JellifyUser} instance
* @param library The Jellyfin {@link JellifyLibrary} instance
* @returns The {@link BaseItemDto}s that are marked as favorite playlists
*/
export async function fetchFavoritePlaylists(
api: Api | undefined,
user: JellifyUser | undefined,
library: JellifyLibrary | undefined,
): Promise<BaseItemDto[]> {
console.debug(`Fetching user's favorite playlists`)
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 []
})
return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(user)) return reject('User instance not set')
if (isUndefined(library)) return reject('Library instance not set')
getItemsApi(api)
.getItems({
userId: user.id,
parentId: library.playlistLibraryId,
fields: ['Path'],
sortBy: [ItemSortBy.SortName],
sortOrder: [SortOrder.Ascending],
})
.then((response) => {
console.log(response)
if (response.data.Items)
return resolve(
response.data.Items.filter(
(item) =>
item.UserData?.IsFavorite || item.Path?.includes('/data/playlists'),
),
)
else return resolve([])
})
.catch((error) => {
console.error(error)
return reject(error)
})
})
}
export async function fetchFavoriteTracks(): Promise<BaseItemDto[]> {
/**
* Fetches the {@link BaseItemDto}s that are marked as favorite tracks
* @param api The Jellyfin {@link Api} instance
* @param user The Jellyfin {@link JellifyUser} instance
* @param library The Jellyfin {@link JellifyLibrary} instance
* @returns The {@link BaseItemDto}s that are marked as favorite tracks
*/
export async function fetchFavoriteTracks(
api: Api | undefined,
user: JellifyUser | undefined,
library: JellifyLibrary | undefined,
): 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)
if (response.data.Items) return response.data.Items
else return []
})
.catch((error) => {
console.error(error)
return []
})
return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(user)) return reject('User instance not set')
if (isUndefined(library)) return reject('Library instance not set')
getItemsApi(api)
.getItems({
includeItemTypes: [BaseItemKind.Audio],
isFavorite: true,
parentId: library.musicLibraryId,
recursive: true,
sortBy: [ItemSortBy.SortName],
sortOrder: [SortOrder.Ascending],
})
.then((response) => {
console.debug(`Received favorite artist response`, response)
if (response.data.Items) return resolve(response.data.Items)
else return resolve([])
})
.catch((error) => {
console.error(error)
return 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)
})
/**
* Fetches the {@link UserItemDataDto} for a given {@link BaseItemDto}
* @param api The Jellyfin {@link Api} instance
* @param itemId The ID field of the {@link BaseItemDto} to fetch user data for
* @returns The {@link UserItemDataDto} for the given item
*/
export async function fetchUserData(
api: Api | undefined,
user: JellifyUser | undefined,
itemId: string,
): Promise<UserItemDataDto | void> {
return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(user)) return reject('User instance not set')
getItemsApi(api)
.getItemUserData({
itemId,
userId: user.id,
})
.then((response) => {
return resolve(response.data)
})
.catch((error) => {
console.error(error)
return reject(error)
})
})
}

View File

@@ -1,4 +1,3 @@
import Client from '../client'
import {
BaseItemDto,
BaseItemKind,
@@ -6,13 +5,28 @@ import {
SortOrder,
} from '@jellyfin/sdk/lib/generated-client/models'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { Api } from '@jellyfin/sdk'
import { isUndefined } from 'lodash'
import { JellifyLibrary } from '../../types/JellifyLibrary'
export function fetchFrequentlyPlayed(): Promise<BaseItemDto[]> {
/**
* Fetches the 100 most frequently played items from the user's library
* @param api The Jellyfin {@link Api} instance
* @param library The Jellyfin {@link JellifyLibrary} instance
* @returns The 100 most frequently played items from the user's library
*/
export function fetchFrequentlyPlayed(
api: Api | undefined,
library: JellifyLibrary | undefined,
): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
getItemsApi(Client.api!)
if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(library)) return reject('Library instance not set')
getItemsApi(api!)
.getItems({
includeItemTypes: [BaseItemKind.Audio],
parentId: Client.library!.musicLibraryId,
parentId: library!.musicLibraryId,
recursive: true,
limit: 100,
sortBy: [ItemSortBy.PlayCount],
@@ -28,12 +42,22 @@ export function fetchFrequentlyPlayed(): Promise<BaseItemDto[]> {
})
}
export function fetchFrequentlyPlayedArtists(): Promise<BaseItemDto[]> {
/**
* Fetches most frequently played artists from the user's library based on the
* {@link fetchFrequentlyPlayed} query
* @param api The Jellyfin {@link Api} instance
* @param library The Jellyfin {@link JellifyLibrary} instance
* @returns The most frequently played artists from the user's library
*/
export function fetchFrequentlyPlayedArtists(
api: Api | undefined,
library: JellifyLibrary | undefined,
): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
console.debug('Fetching frequently played artists')
try {
fetchFrequentlyPlayed()
fetchFrequentlyPlayed(api, library)
.then((frequentlyPlayed) => {
console.debug('Received frequently played artists response')
return frequentlyPlayed.map((played) => played.ArtistItems![0] as BaseItemDto)

View File

@@ -1,43 +0,0 @@
import { ImageFormat, ImageType } from '@jellyfin/sdk/lib/generated-client/models'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import _ from 'lodash'
import Client from '../client'
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)
})
}

View File

@@ -1,22 +1,29 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getInstantMixApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../client'
import { isUndefined } from 'lodash'
import QueryConfig from './query.config'
import { Api } from '@jellyfin/sdk'
import { JellifyUser } from '../../types/JellifyUser'
/**
* Fetches an instant mix for a given item
* @param api The Jellyfin {@link Api} instance
* @param user The Jellyfin {@link JellifyUser} instance
* @param item The item to fetch an instant mix for
* @returns A promise of a {@link BaseItemDto} array, be it empty or not
*/
export function fetchInstantMixFromItem(item: BaseItemDto): Promise<BaseItemDto[]> {
export function fetchInstantMixFromItem(
api: Api | undefined,
user: JellifyUser | undefined,
item: BaseItemDto,
): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
if (isUndefined(Client.api)) return reject(new Error('Client not initialized'))
if (isUndefined(api)) return reject(new Error('Client not initialized'))
if (isUndefined(user)) return reject(new Error('User not initialized'))
getInstantMixApi(Client.api)
getInstantMixApi(api)
.getInstantMixFromArtists({
itemId: item.Id!,
userId: Client.user!.id,
userId: user.id,
limit: QueryConfig.limits.instantMix,
})
.then(({ data }) => {

View File

@@ -1,13 +1,20 @@
import Client from '../client'
import { BaseItemDto, ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { groupBy, isEmpty, isEqual, isUndefined } from 'lodash'
import { SectionList } from 'react-native'
import { Api } from '@jellyfin/sdk/lib/api'
export async function fetchItem(itemId: string): Promise<BaseItemDto> {
/**
* Fetches a single Jellyfin item by it's ID
* @param itemId The ID of the item to fetch
* @returns The item - a {@link BaseItemDto}
*/
export async function fetchItem(api: Api | undefined, itemId: string): Promise<BaseItemDto> {
return new Promise((resolve, reject) => {
if (isEmpty(itemId)) reject('No item ID proviced')
if (isEmpty(itemId)) return reject('No item ID proviced')
if (isUndefined(api)) return reject('Client not initialized')
getItemsApi(Client.api!)
getItemsApi(api)
.getItems({
ids: [itemId],
})
@@ -16,22 +23,31 @@ export async function fetchItem(itemId: string): Promise<BaseItemDto> {
resolve(response.data.Items[0])
else reject(`${response.data.TotalRecordCount} items returned for ID`)
})
.catch((error) => {
reject(error)
})
})
}
/**
* Fetches tracks for an album, sectioned into discs for display in a {@link SectionList}
* @param album The album to fetch tracks for
* @returns An array of {@link Section}s, where each section title is the disc number,
* and the data is the disc tracks - an array of {@link BaseItemDto}s
*/
export async function fetchAlbumDiscs(
api: Api | undefined,
album: BaseItemDto,
): Promise<{ title: string; data: BaseItemDto[] }[]> {
return new Promise<{ title: string; data: BaseItemDto[] }[]>((resolve, reject) => {
if (isEmpty(album.Id)) reject('No album ID provided')
if (isUndefined(Client.api)) reject('Client not initialized')
if (isEmpty(album.Id)) return reject('No album ID provided')
if (isUndefined(api)) return reject('Client not initialized')
let sortBy: ItemSortBy[] = []
sortBy = [ItemSortBy.ParentIndexNumber, ItemSortBy.IndexNumber, ItemSortBy.SortName]
getItemsApi(Client.api!)
getItemsApi(api)
.getItems({
parentId: album.Id!,
sortBy,

View File

@@ -1,67 +1,79 @@
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 { Api } from '@jellyfin/sdk'
import { JellifyUser } from '../../types/JellifyUser'
export async function fetchMusicLibraries(): Promise<BaseItemDto[] | void> {
export async function fetchMusicLibraries(api: Api | undefined): Promise<BaseItemDto[] | void> {
console.debug('Fetching music libraries from Jellyfin')
const libraries = await getItemsApi(Client.api!).getItems({
includeItemTypes: ['CollectionFolder'],
return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set')
getItemsApi(api)
.getItems({
includeItemTypes: ['CollectionFolder'],
})
.then((response) => {
if (response.data.Items) return resolve(response.data.Items)
else return resolve([])
})
.catch((error) => {
console.error(error)
return reject(error)
})
})
if (isUndefined(libraries.data.Items)) {
console.warn('No libraries found on Jellyfin')
return
}
const musicLibraries = libraries.data.Items!.filter(
(library) => library.CollectionType == 'music',
)
return musicLibraries
}
export async function fetchPlaylistLibrary(): Promise<BaseItemDto | void> {
export async function fetchPlaylistLibrary(api: Api | undefined): Promise<BaseItemDto | undefined> {
console.debug('Fetching playlist library from Jellyfin')
const libraries = await getItemsApi(Client.api!).getItems({
includeItemTypes: ['ManualPlaylistsFolder'],
excludeItemTypes: ['CollectionFolder'],
return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set')
getItemsApi(api)
.getItems({
includeItemTypes: ['ManualPlaylistsFolder'],
excludeItemTypes: ['CollectionFolder'],
})
.then((response) => {
if (response.data.Items)
return resolve(
response.data.Items.filter(
(library) => library.CollectionType == 'playlists',
)[0],
)
else return resolve(undefined)
})
.catch((error) => {
console.error(error)
return reject(error)
})
})
if (isUndefined(libraries.data.Items)) {
console.warn('No playlist libraries found on Jellyfin')
return
}
console.debug('Playlist libraries', libraries.data.Items!)
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 async function fetchUserViews(): Promise<BaseItemDto[] | void> {
export async function fetchUserViews(
api: Api | undefined,
user: JellifyUser | undefined,
): Promise<BaseItemDto[] | void> {
console.debug('Fetching user views')
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)
})
return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(user)) return reject('User instance not set')
getUserViewsApi(api)
.getUserViews({
userId: user.id,
})
.then((response) => {
if (response.data.Items) return resolve(response.data.Items)
else return resolve([])
})
.catch((error) => {
console.error(error)
return reject(error)
})
})
}

View File

@@ -1,13 +1,20 @@
import { Api } from '@jellyfin/sdk'
import { PlaybackInfoResponse } from '@jellyfin/sdk/lib/generated-client/models'
import Client from '../client'
import { getAudioApi, getMediaInfoApi } from '@jellyfin/sdk/lib/utils/api'
export async function fetchMediaInfo(itemId: string): Promise<PlaybackInfoResponse> {
import { isUndefined } from 'lodash'
import { JellifyUser } from '../../types/JellifyUser'
export async function fetchMediaInfo(
api: Api | undefined,
user: JellifyUser | undefined,
itemId: string,
): Promise<PlaybackInfoResponse> {
return new Promise((resolve, reject) => {
getMediaInfoApi(Client.api!)
if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(user)) return reject('User instance not set')
getMediaInfoApi(api)
.getPlaybackInfo({
itemId,
userId: Client.user?.id,
userId: user.id,
})
.then(({ data }) => {
console.debug('Received media info response')

View File

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

View File

@@ -1,26 +1,31 @@
import {
BaseItemDto,
BaseItemKind,
ItemFields,
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 { Api } from '@jellyfin/sdk'
import { isUndefined } from 'lodash'
import { JellifyLibrary } from '../../../src/types/JellifyLibrary'
export async function fetchRecentlyAdded(
api: Api | undefined,
library: JellifyLibrary | undefined,
limit: number = QueryConfig.limits.recents,
offset?: number | undefined,
): Promise<BaseItemDto[]> {
if (!Client.api) {
if (isUndefined(api)) {
console.error('Client not set')
return []
}
if (!Client.library) return []
return await getUserLibraryApi(Client.api)
if (isUndefined(library)) return []
return await getUserLibraryApi(api)
.getLatestMedia({
parentId: Client.library.musicLibraryId,
parentId: library.musicLibraryId,
limit,
})
.then(({ data }) => {
@@ -35,31 +40,39 @@ export async function fetchRecentlyAdded(
* @returns The recently played items.
*/
export async function fetchRecentlyPlayed(
api: Api | undefined,
library: JellifyLibrary | undefined,
limit: number = QueryConfig.limits.recents,
offset?: number | undefined,
): Promise<BaseItemDto[]> {
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((resolve, reject) => {
if (isUndefined(api)) return reject(new Error('API client not set'))
else if (isUndefined(library)) return reject(new Error('Library not set'))
if (response.data.Items) return response.data.Items
return []
})
.catch((error) => {
console.error(error)
return []
})
getItemsApi(api)
.getItems({
includeItemTypes: [BaseItemKind.Audio],
startIndex: offset,
limit,
parentId: library!.musicLibraryId,
recursive: true,
sortBy: [ItemSortBy.DatePlayed],
sortOrder: [SortOrder.Descending],
fields: [ItemFields.ParentId],
})
.then((response) => {
console.debug('Received recently played items response')
if (response.data.Items) return resolve(response.data.Items)
return resolve([])
})
.catch((error) => {
console.error(error)
return reject(error)
})
})
}
/**
@@ -70,16 +83,20 @@ export async function fetchRecentlyPlayed(
* @returns The recently played artists.
*/
export function fetchRecentlyPlayedArtists(
api: Api | undefined,
library: JellifyLibrary | undefined,
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!
})
})
return fetchRecentlyPlayed(api, library, limit, offset ? offset + 10 : undefined).then(
(tracks) => {
return getItemsApi(api!)
.getItems({
ids: tracks.map((track) => track.ArtistItems![0].Id!),
})
.then((recentArtists) => {
return recentArtists.data.Items!
})
},
)
}

View File

@@ -1,22 +1,25 @@
import Client from '../client'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { isEmpty, trim } from 'lodash'
import { isEmpty, isUndefined, trim } from 'lodash'
import QueryConfig from './query.config'
import { Api } from '@jellyfin/sdk'
/**
* Performs a search for items against the Jellyfin server, trimming whitespace
* around the search term for the best possible results.
* @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[]> {
export async function fetchSearchResults(
api: Api | undefined,
searchString: string | undefined,
): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
console.debug('Searching Jellyfin for items')
if (isEmpty(searchString)) resolve([])
getItemsApi(Client.api!)
if (isUndefined(api)) return reject('Client instance not set')
getItemsApi(api)
.getItems({
searchTerm: trim(searchString),
recursive: true,

View File

@@ -1,26 +1,32 @@
import Client from '../client'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api'
import QueryConfig from './query.config'
import { Api } from '@jellyfin/sdk'
import { isUndefined } from 'lodash'
import { JellifyUser } from '../../types/JellifyUser'
export default function fetchSimilar(
api: Api | undefined,
user: JellifyUser | undefined,
itemId: string,
limit: number = QueryConfig.limits.similar,
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)
})
if (isUndefined(api)) return reject('Client has not been set')
if (isUndefined(user)) return reject('User has not been set')
getLibraryApi(api)
.getSimilarArtists({
userId: user.id,
itemId: itemId,
limit,
})
.then(({ data }) => {
resolve(data.Items ?? [])
})
.catch((error) => {
reject(error)
})
})
}

View File

@@ -1,10 +1,18 @@
import { getItemsApi, getSuggestionsApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../client'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'
import { Api } from '@jellyfin/sdk'
import { isUndefined } from 'lodash'
export async function fetchSearchSuggestions(): Promise<BaseItemDto[]> {
/**
* Fetches search suggestions from the Jellyfin server
* @param api The Jellyfin {@link Api} client
* @returns A promise of a {@link BaseItemDto} array, be it empty or not
*/
export async function fetchSearchSuggestions(api: Api | undefined): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
getItemsApi(Client.api!)
if (isUndefined(api)) return reject('Client instance not set')
getItemsApi(api)
.getItems({
recursive: true,
limit: 10,
@@ -25,66 +33,3 @@ export async function fetchSearchSuggestions(): Promise<BaseItemDto[]> {
})
})
}
/**
* @deprecated Use Items API based functions instead of Suggestions API
* @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)
})
})
}
/**
* @deprecated Use Items API based functions instead of Suggestions API
* @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)
})
})
}
/**
* @deprecated Use Items API based functions instead of Suggestions API
* @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)
})
})
}

View File

@@ -1,7 +1,7 @@
import { HomeAlbumProps, StackParamList } from '../types'
import { YStack, XStack, Separator, getToken, Spacer } from 'tamagui'
import { H5, Text } from '../Global/helpers/text'
import { FlatList, SectionList, useWindowDimensions } from 'react-native'
import { ActivityIndicator, FlatList, SectionList, useWindowDimensions } from 'react-native'
import { RunTimeTicks } from '../Global/helpers/time-codes'
import Track from '../Global/components/track'
import FavoriteButton from '../Global/components/favorite-button'
@@ -14,7 +14,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import InstantMixButton from '../Global/components/instant-mix-button'
import ItemImage from '../Global/components/image'
import React from 'react'
import IconButton from '../Global/helpers/icon-button'
import { useJellifyContext } from '../provider'
/**
* The screen for an Album's track list
@@ -29,9 +29,11 @@ import IconButton from '../Global/helpers/icon-button'
export function AlbumScreen({ route, navigation }: HomeAlbumProps): React.JSX.Element {
const { album } = route.params
const { data: discs } = useQuery({
const { api } = useJellifyContext()
const { data: discs, isPending } = useQuery({
queryKey: [QueryKeys.ItemTracks, album.Id!],
queryFn: () => fetchAlbumDiscs(album),
queryFn: () => fetchAlbumDiscs(api, album),
})
return (
@@ -61,6 +63,15 @@ export function AlbumScreen({ route, navigation }: HomeAlbumProps): React.JSX.El
/>
)}
ListFooterComponent={() => AlbumTrackListFooter(album, navigation)}
ListEmptyComponent={() => (
<YStack>
{isPending ? (
<ActivityIndicator size='large' color={'$background'} />
) : (
<Text>No tracks found</Text>
)}
</YStack>
)}
/>
)
}

View File

@@ -4,15 +4,17 @@ import { FlatList, RefreshControl } from 'react-native'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../enums/query-keys'
import { fetchFavoriteAlbums } from '../../api/queries/favorites'
import { useJellifyContext } from '../provider'
export default function Albums({ navigation, route }: AlbumsProps): React.JSX.Element {
const { api, user, library } = useJellifyContext()
const {
data: albums,
refetch,
isPending,
} = useQuery({
queryKey: [QueryKeys.FavoriteAlbums],
queryFn: fetchFavoriteAlbums,
queryFn: () => fetchFavoriteAlbums(api, user, library),
})
return (

View File

@@ -1,4 +1,3 @@
import Client from '../../api/client'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import React, { useEffect } from 'react'
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'
@@ -14,10 +13,11 @@ import {
import { StackParamList } from '../types'
import { useArtistContext } from './provider'
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models'
import { useJellifyContext } from '../provider'
const ArtistTabs = createMaterialTopTabNavigator<StackParamList>()
export default function ArtistNavigation(): React.JSX.Element {
const { api } = useJellifyContext()
const { artist, scroll } = useArtistContext()
const { width } = useSafeAreaFrame()
@@ -39,7 +39,7 @@ export default function ArtistNavigation(): React.JSX.Element {
<Animated.View style={[animatedBannerStyle]}>
<FastImage
source={{
uri: getImageApi(Client.api!).getItemImageUrlById(
uri: getImageApi(api!).getItemImageUrlById(
artist.Id!,
ImageType.Backdrop,
),

View File

@@ -1,5 +1,4 @@
import fetchSimilar from '../../api/queries/similar'
import Client from '../../api/client'
import { QueryKeys } from '../../enums/query-keys'
import {
BaseItemDto,
@@ -11,6 +10,7 @@ import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { useQuery } from '@tanstack/react-query'
import { createContext, ReactNode, SetStateAction, useContext, useState } from 'react'
import { SharedValue, useSharedValue } from 'react-native-reanimated'
import { useJellifyContext } from '../provider'
interface ArtistContext {
fetchingAlbums: boolean
@@ -24,6 +24,8 @@ interface ArtistContext {
}
const ArtistContextInitializer = (artist: BaseItemDto) => {
const { api, user } = useJellifyContext()
const {
data: albums,
refetch: refetchAlbums,
@@ -31,7 +33,7 @@ const ArtistContextInitializer = (artist: BaseItemDto) => {
} = useQuery({
queryKey: [QueryKeys.ArtistAlbums, artist.Id!],
queryFn: ({ queryKey }) => {
return getItemsApi(Client.api!)
return getItemsApi(api!)
.getItems({
includeItemTypes: [BaseItemKind.MusicAlbum],
recursive: true,
@@ -56,7 +58,7 @@ const ArtistContextInitializer = (artist: BaseItemDto) => {
isPending: fetchingSimilarArtists,
} = useQuery({
queryKey: [QueryKeys.SimilarItems, artist.Id],
queryFn: () => fetchSimilar(artist.Id!),
queryFn: () => fetchSimilar(api, user, artist.Id!),
})
const refresh = () => {

View File

@@ -7,15 +7,17 @@ import { fetchFavoriteArtists } from '../../api/queries/favorites'
import { YStack } from 'tamagui'
import { Text } from '../Global/helpers/text'
import { FlatList } from 'react-native'
import { useJellifyContext } from '../provider'
export default function Artists({ navigation, route }: ArtistsProps): React.JSX.Element {
const { api, user, library } = useJellifyContext()
const {
data: favoriteArtists,
refetch,
isPending,
} = useQuery({
queryKey: [QueryKeys.FavoriteArtists],
queryFn: fetchFavoriteArtists,
queryFn: () => fetchFavoriteArtists(api, user, library),
})
return (

View File

@@ -2,7 +2,7 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { ListTemplate } from 'react-native-carplay'
import uuid from 'react-native-uuid'
const RecentArtistsTemplate = (items: BaseItemDto[]) =>
const ArtistsTemplate = (items: BaseItemDto[]) =>
new ListTemplate({
id: uuid.v4(),
sections: [
@@ -19,4 +19,4 @@ const RecentArtistsTemplate = (items: BaseItemDto[]) =>
onItemSelect: async (item) => {},
})
export default RecentArtistsTemplate
export default ArtistsTemplate

View File

@@ -1,48 +1,78 @@
import { QueryKeys } from '../../enums/query-keys'
import Client from '../../api/client'
import { CarPlay, ListTemplate } from 'react-native-carplay'
import { queryClient } from '../../constants/query-client'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import RecentTracksTemplate from './RecentTracks'
import RecentArtistsTemplate from './RecentArtists'
import TracksTemplate from './Tracks'
import ArtistsTemplate from './Artists'
import uuid from 'react-native-uuid'
import { Api } from '@jellyfin/sdk'
import { JellifyUser } from '../../types/JellifyUser'
const CarPlayHome = new ListTemplate({
id: uuid.v4(),
title: 'Home',
tabTitle: 'Home',
tabSystemImageName: 'music.house.fill',
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' },
],
const CarPlayHome = (api: Api, user: JellifyUser, sessionId: string) =>
new ListTemplate({
id: uuid.v4(),
title: 'Home',
tabTitle: 'Home',
tabSystemImageName: 'music.house.fill',
sections: [
{
header: `Hi ${user.name}`,
items: [],
},
{
header: 'Recents',
items: [
{ id: QueryKeys.RecentlyPlayedArtists, text: 'Recent Artists' },
{ id: QueryKeys.RecentlyPlayed, text: 'Recently Played' },
],
},
{
header: 'Frequents',
items: [
{ id: QueryKeys.FrequentArtists, text: 'Most Played' },
{ id: QueryKeys.FrequentlyPlayed, text: 'On Repeat' },
],
},
],
onItemSelect: async ({ index }) => {
console.debug(`Home item selected`)
switch (index) {
case 0: {
// Recent Artists
const artists =
queryClient.getQueryData<BaseItemDto[]>([
QueryKeys.RecentlyPlayedArtists,
]) ?? []
CarPlay.pushTemplate(ArtistsTemplate(artists))
break
}
case 1: {
// Recent Tracks
const items =
queryClient.getQueryData<BaseItemDto[]>([QueryKeys.RecentlyPlayed]) ?? []
CarPlay.pushTemplate(TracksTemplate(api, sessionId, items))
break
}
case 2: {
// Most Played Artists
const artists =
queryClient.getQueryData<BaseItemDto[]>([QueryKeys.FrequentArtists]) ?? []
CarPlay.pushTemplate(ArtistsTemplate(artists))
break
}
case 3: {
// On Repeat
const items =
queryClient.getQueryData<BaseItemDto[]>([QueryKeys.FrequentlyPlayed]) ?? []
CarPlay.pushTemplate(TracksTemplate(api, sessionId, items))
break
}
}
},
],
onItemSelect: async ({ index }) => {
console.debug(`Home item selected`)
switch (index) {
case 0: {
const artists =
queryClient.getQueryData<BaseItemDto[]>([QueryKeys.RecentlyPlayedArtists]) ?? []
CarPlay.pushTemplate(RecentArtistsTemplate(artists))
break
}
case 1: {
const items =
queryClient.getQueryData<BaseItemDto[]>([QueryKeys.RecentlyPlayed]) ?? []
CarPlay.pushTemplate(RecentTracksTemplate(items))
break
}
case 2: {
break
}
}
},
})
})
export default CarPlayHome

View File

@@ -2,12 +2,15 @@ import { TabBarTemplate } from 'react-native-carplay'
import CarPlayHome from './Home'
import CarPlayDiscover from './Discover'
import uuid from 'react-native-uuid'
import { Api } from '@jellyfin/sdk'
import { JellifyUser } from '../../types/JellifyUser'
const CarPlayNavigation = new TabBarTemplate({
id: uuid.v4(),
title: 'Tabs',
templates: [CarPlayHome, CarPlayDiscover],
onTemplateSelect(template, e) {},
})
const CarPlayNavigation = (api: Api, user: JellifyUser, sessionId: string) =>
new TabBarTemplate({
id: uuid.v4(),
title: 'Tabs',
templates: [CarPlayHome(api, user, sessionId), CarPlayDiscover],
onTemplateSelect(template, e) {},
})
export default CarPlayNavigation

View File

@@ -1,4 +1,4 @@
import { mapDtoToTrack } from '../..//helpers/mappings'
import { mapDtoToTrack } from '../../helpers/mappings'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { CarPlay, ListTemplate } from 'react-native-carplay'
import TrackPlayer from 'react-native-track-player'
@@ -6,8 +6,9 @@ import uuid from 'react-native-uuid'
import CarPlayNowPlaying from './NowPlaying'
import { queryClient } from '../../constants/query-client'
import { QueryKeys } from '../../enums/query-keys'
import { Api } from '@jellyfin/sdk'
const RecentTracksTemplate = (items: BaseItemDto[]) =>
const TracksTemplate = (api: Api, sessionId: string, items: BaseItemDto[]) =>
new ListTemplate({
id: uuid.v4(),
sections: [
@@ -24,7 +25,12 @@ const RecentTracksTemplate = (items: BaseItemDto[]) =>
onItemSelect: async (item) => {
await TrackPlayer.setQueue(
items.map((item) =>
mapDtoToTrack(item, queryClient.getQueryData([QueryKeys.AudioCache]) ?? []),
mapDtoToTrack(
api,
sessionId,
item,
queryClient.getQueryData([QueryKeys.AudioCache]) ?? [],
),
),
)
@@ -36,4 +42,4 @@ const RecentTracksTemplate = (items: BaseItemDto[]) =>
},
})
export default RecentTracksTemplate
export default TracksTemplate

View File

@@ -4,7 +4,6 @@ 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'

View File

@@ -3,7 +3,7 @@ import { fetchRecentlyAdded, fetchRecentlyPlayed } from '../../api/queries/recen
import { QueryKeys } from '../../enums/query-keys'
import { createContext, ReactNode, useContext, useState } from 'react'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { useJellifyContext } from '../provider'
interface DiscoverContext {
refreshing: boolean
refresh: () => void
@@ -12,16 +12,17 @@ interface DiscoverContext {
}
const DiscoverContextInitializer = () => {
const { api, library } = useJellifyContext()
const [refreshing, setRefreshing] = useState<boolean>(false)
const { data: recentlyAdded, refetch } = useQuery({
queryKey: [QueryKeys.RecentlyAdded],
queryFn: () => fetchRecentlyAdded(),
queryFn: () => fetchRecentlyAdded(api, library),
})
const { data: recentlyPlayed, refetch: refetchRecentlyPlayed } = useQuery({
queryKey: [QueryKeys.RecentlyPlayed],
queryFn: () => fetchRecentlyPlayed(),
queryFn: () => fetchRecentlyPlayed(api, library),
})
const refresh = async () => {

View File

@@ -1,45 +0,0 @@
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/images'
interface AvatarProps extends TamaguiAvatarProps {
item: BaseItemDto
imageType?: ImageType
subheading?: string | null | undefined
}
export default function Avatar({
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,
})
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

@@ -7,6 +7,7 @@ import { getTokens, Spinner } from 'tamagui'
import { QueryKeys } from '../../../enums/query-keys'
import { fetchUserData } from '../../../api/queries/favorites'
import { useJellifyUserDataContext } from '../../../components/user-data-provider'
import { useJellifyContext } from '../../provider'
interface SetFavoriteMutation {
item: BaseItemDto
@@ -21,11 +22,12 @@ export default function FavoriteButton({
}): React.JSX.Element {
const [isFavorite, setFavorite] = useState<boolean>(isFavoriteItem(item))
const { api, user } = useJellifyContext()
const { toggleFavorite } = useJellifyUserDataContext()
const { data, isFetching, refetch } = useQuery({
queryKey: [QueryKeys.UserData, item.Id!],
queryFn: () => fetchUserData(item.Id!),
queryFn: () => fetchUserData(api, user, item.Id!),
})
useEffect(() => {

View File

@@ -5,13 +5,14 @@ import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../../enums/query-keys'
import { fetchUserData } from '../../../api/queries/favorites'
import { useEffect, useState } from 'react'
import { useJellifyContext } from '../../provider'
export default function FavoriteIcon({ item }: { item: BaseItemDto }): React.JSX.Element {
const [isFavorite, setIsFavorite] = useState<boolean>(item.UserData?.IsFavorite ?? false)
const { api, user, library } = useJellifyContext()
const { data: userData } = useQuery({
queryKey: [QueryKeys.UserData, item.Id!],
queryFn: () => fetchUserData(item.Id!),
queryFn: () => fetchUserData(api, user, item.Id!),
staleTime: 1000 * 60 * 5, // 5 minutes,
})

View File

@@ -1,11 +1,10 @@
import Client from '../../../api/client'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import { isUndefined } from 'lodash'
import { StyleProp } from 'react-native'
import FastImage, { ImageStyle } from 'react-native-fast-image'
import { FontSizeTokens, getFontSizeToken, getToken, getTokenValue, Token } from 'tamagui'
import { useJellifyContext } from '../../provider'
interface ImageProps {
item: BaseItemDto
circular?: boolean | undefined
@@ -21,9 +20,11 @@ export default function ItemImage({
height,
style,
}: ImageProps): React.JSX.Element {
const { api } = useJellifyContext()
return (
<FastImage
source={{ uri: getImageApi(Client.api!).getItemImageUrlById(item.Id!) }}
source={{ uri: getImageApi(api!).getItemImageUrlById(item.Id!) }}
style={{
borderRadius: getBorderRadius(circular, width),
width: !isUndefined(width)

View File

@@ -8,7 +8,7 @@ import { fetchInstantMixFromItem } from '../../../api/queries/instant-mixes'
import Icon from '../helpers/icon'
import { getToken, Spacer, Spinner } from 'tamagui'
import { useColorScheme } from 'react-native'
import { useJellifyContext } from '../../provider'
export default function InstantMixButton({
item,
navigation,
@@ -16,9 +16,10 @@ export default function InstantMixButton({
item: BaseItemDto
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { api, user } = useJellifyContext()
const { data, isFetching, refetch } = useQuery({
queryKey: [QueryKeys.InstantMix, item.Id!],
queryFn: () => fetchInstantMixFromItem(item),
queryFn: () => fetchInstantMixFromItem(api, user, item),
})
const isDarkMode = useColorScheme() === 'dark'

View File

@@ -5,11 +5,10 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { Text } from '../helpers/text'
import FastImage from 'react-native-fast-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../../../api/client'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../../enums/query-keys'
import { fetchMediaInfo } from '../../../api/queries/media'
import { useJellifyContext } from '../../provider'
interface CardProps extends TamaguiCardProps {
caption?: string | null | undefined
subCaption?: string | null | undefined
@@ -18,9 +17,11 @@ interface CardProps extends TamaguiCardProps {
}
export function ItemCard(props: CardProps) {
const { api, user } = useJellifyContext()
const mediaInfo = useQuery({
queryKey: [QueryKeys.MediaSources, props.item.Id!],
queryFn: () => fetchMediaInfo(props.item.Id!),
queryFn: () => fetchMediaInfo(api, user, props.item.Id!),
})
return (
@@ -52,7 +53,7 @@ export function ItemCard(props: CardProps) {
<TamaguiCard.Background>
<FastImage
source={{
uri: getImageApi(Client.api!).getItemImageUrlById(
uri: getImageApi(api!).getItemImageUrlById(
props.item.Type === 'Audio' ? props.item.AlbumId! : props.item.Id!,
),
}}

View File

@@ -12,15 +12,16 @@ import { Queue } from '../../../player/types/queue-item'
import FavoriteIcon from './favorite-icon'
import FastImage from 'react-native-fast-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../../../api/client'
import { networkStatusTypes } from '../../../components/Network/internetConnectionWatcher'
import { useNetworkContext } from '../../../components/Network/provider'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../../enums/query-keys'
import { fetchMediaInfo } from '../../../api/queries/media'
import { useQueueContext } from '../../../player/queue-provider'
import { fetchItem } from '../../../api/queries/item'
import { useJellifyContext } from '../../provider'
interface TrackProps {
export interface TrackProps {
track: BaseItemDto
navigation: NativeStackNavigationProp<StackParamList>
tracklist?: BaseItemDto[] | undefined
@@ -34,13 +35,6 @@ interface TrackProps {
prependElement?: React.JSX.Element | undefined
showRemove?: boolean | undefined
onRemove?: () => void | undefined
/**
* Optional prepend element function.
* If provided, function will be called when the user
* presses the prepend element.
*/
prependOnPress?: (() => void) | undefined
}
export default function Track({
@@ -54,11 +48,11 @@ export default function Track({
onLongPress,
isNested,
invertedColors,
prependElement,
showRemove,
onRemove,
}: TrackProps): React.JSX.Element {
const theme = useTheme()
const { api, user } = useJellifyContext()
const { nowPlaying, useStartPlayback } = usePlayerContext()
const { playQueue, useLoadNewQueue } = useQueueContext()
const { downloadedTracks, networkStatus } = useNetworkContext()
@@ -70,9 +64,16 @@ export default function Track({
const isOffline = networkStatus === networkStatusTypes.DISCONNECTED
// Fetch media info so it's available in the player
const mediaInfo = useQuery({
queryKey: [QueryKeys.MediaSources, track.Id!],
queryFn: () => fetchMediaInfo(track.Id!),
queryFn: () => fetchMediaInfo(api, user, track.Id!),
})
// Fetch album so it's available in the Details screen
const { data: album } = useQuery({
queryKey: [QueryKeys.MediaSources, track.Id!],
queryFn: () => fetchItem(api, track.Id!),
})
return (
@@ -111,12 +112,6 @@ export default function Track({
}
paddingVertical={'$2'}
>
{prependElement && (
<YStack alignContent='center' justifyContent='center' flex={1}>
{prependElement}
</YStack>
)}
<XStack
alignContent='center'
justifyContent='center'
@@ -127,7 +122,7 @@ export default function Track({
{showArtwork ? (
<FastImage
source={{
uri: getImageApi(Client.api!).getItemImageUrlById(track.AlbumId!),
uri: getImageApi(api!).getItemImageUrlById(track.AlbumId!),
}}
style={{
width: getToken('$12'),

View File

@@ -5,19 +5,18 @@ 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 FrequentArtists from './helpers/frequent-artists'
import FrequentlyPlayedTracks from './helpers/frequent-tracks'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useJellifyContext } from '../provider'
export function ProvidedHome({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { user } = useJellifyContext()
const { refreshing: refetching, onRefresh } = useHomeContext()
const insets = useSafeAreaInsets()
return (
@@ -28,7 +27,7 @@ export function ProvidedHome({
>
<YStack alignContent='flex-start'>
<XStack margin={'$2'}>
<H3>{`Hi, ${Client.user!.name}`}</H3>
<H3>{`Hi, ${user?.name ?? 'there'}`}</H3>
</XStack>
<Separator marginVertical={'$2'} />

View File

@@ -1,6 +1,5 @@
import HorizontalCardList from '../../../components/Global/components/horizontal-list'
import { StackParamList } from '../../../components/types'
import { QueryKeys } from '../../../enums/query-keys'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import React from 'react'
import { ItemCard } from '../../../components/Global/components/item-card'

View File

@@ -6,7 +6,7 @@ import { fetchRecentlyPlayed, fetchRecentlyPlayedArtists } from '../../api/queri
import { queryClient } from '../../constants/query-client'
import QueryConfig from '../../api/queries/query.config'
import { fetchFrequentlyPlayed, fetchFrequentlyPlayedArtists } from '../../api/queries/frequents'
import { useJellifyContext } from '../provider'
interface HomeContext {
refreshing: boolean
onRefresh: () => void
@@ -17,25 +17,26 @@ interface HomeContext {
}
const HomeContextInitializer = () => {
const { api, library, user } = useJellifyContext()
const [refreshing, setRefreshing] = useState<boolean>(false)
const { data: recentTracks, refetch: refetchRecentTracks } = useQuery({
queryKey: [QueryKeys.RecentlyPlayed],
queryFn: () => fetchRecentlyPlayed(),
queryFn: () => fetchRecentlyPlayed(api, library),
})
const { data: recentArtists, refetch: refetchRecentArtists } = useQuery({
queryKey: [QueryKeys.RecentlyPlayedArtists],
queryFn: () => fetchRecentlyPlayedArtists(),
queryFn: () => fetchRecentlyPlayedArtists(api, library),
})
const { data: frequentlyPlayed, refetch: refetchFrequentlyPlayed } = useQuery({
queryKey: [QueryKeys.FrequentlyPlayed],
queryFn: () => fetchFrequentlyPlayed(),
queryFn: () => fetchFrequentlyPlayed(api, library),
})
const { data: frequentArtists, refetch: refetchFrequentArtists } = useQuery({
queryKey: [QueryKeys.FrequentArtists],
queryFn: () => fetchFrequentlyPlayedArtists(),
queryFn: () => fetchFrequentlyPlayedArtists(api, library),
})
const onRefresh = async () => {

View File

@@ -12,12 +12,11 @@ import TextTicker from 'react-native-text-ticker'
import { TextTickerConfig } from '../Player/component.config'
import FastImage from 'react-native-fast-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../../api/client'
import Icon from '../Global/helpers/icon'
import { Platform, useColorScheme } from 'react-native'
import JellifyToastConfig from '../../constants/toast.config'
import Toast from 'react-native-toast-message'
import { useJellifyContext } from '../provider'
export default function ItemDetail({
item,
navigation,
@@ -29,6 +28,8 @@ export default function ItemDetail({
}): React.JSX.Element {
let options: React.JSX.Element | undefined = undefined
const { api } = useJellifyContext()
useEffect(() => {
trigger('impactMedium')
}, [item])
@@ -85,7 +86,7 @@ export default function ItemDetail({
<FastImage
source={{
uri: getImageApi(Client.api!).getItemImageUrlById(
uri: getImageApi(api!).getItemImageUrlById(
item.Type === 'Audio' ? item.AlbumId! : item.Id!,
),
}}

View File

@@ -26,9 +26,8 @@ import { queryClient } from '../../../constants/query-client'
import { QueryKeys } from '../../../enums/query-keys'
import { fetchItem } from '../../../api/queries/item'
import { fetchUserPlaylists } from '../../../api/queries/playlists'
import { useJellifyContext } from '../../provider'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../../../api/client'
import { useNetworkContext } from '../../../components/Network/provider'
import { useQueueContext } from '../../../player/queue-provider'
import Toast from 'react-native-toast-message'
@@ -49,9 +48,11 @@ export default function TrackOptions({
navigation,
isNested,
}: TrackOptionsProps): React.JSX.Element {
const { api, user, library } = useJellifyContext()
const { data: album, isSuccess: albumFetchSuccess } = useQuery({
queryKey: [QueryKeys.Item, track.AlbumId!],
queryFn: () => fetchItem(track.AlbumId!),
queryFn: () => fetchItem(api, track.AlbumId!),
})
const { useDownload, useRemoveDownload, downloadedTracks } = useNetworkContext()
@@ -64,7 +65,7 @@ export default function TrackOptions({
isSuccess: playlistsFetchSuccess,
} = useQuery({
queryKey: [QueryKeys.UserPlaylists],
queryFn: () => fetchUserPlaylists(),
queryFn: () => fetchUserPlaylists(api, user, library),
})
const { useAddToQueue } = useQueueContext()
@@ -74,14 +75,9 @@ export default function TrackOptions({
const useAddToPlaylist = useMutation({
mutationFn: ({ track, playlist }: AddToPlaylistMutation) => {
trigger('impactLight')
return addToPlaylist(track, playlist)
return addToPlaylist(api, user, track, playlist)
},
onSuccess: (data, { playlist }) => {
// Burnt.alert({
// title: `Added to playlist`,
// duration: 1,
// preset: 'done',
// })
Toast.show({
text1: 'Added to playlist',
type: 'success',
@@ -98,11 +94,6 @@ export default function TrackOptions({
})
},
onError: () => {
// Burnt.alert({
// title: `Unable to add`,
// duration: 1,
// preset: 'error',
// })
Toast.show({
text1: 'Unable to add',
type: 'error',
@@ -216,9 +207,9 @@ export default function TrackOptions({
<YStack flex={1}>
<FastImage
source={{
uri: getImageApi(
Client.api!,
).getItemImageUrlById(playlist.Id!),
uri: getImageApi(api!).getItemImageUrlById(
playlist.Id!,
),
}}
style={{
borderRadius: getToken('$1.5'),

View File

@@ -11,6 +11,7 @@ import { trigger } from 'react-native-haptic-feedback'
import { queryClient } from '../../../constants/query-client'
import { QueryKeys } from '../../../enums/query-keys'
import Toast from 'react-native-toast-message'
import { useJellifyContext } from '../../provider'
// import * as Burnt from 'burnt'
export default function AddPlaylist({
@@ -18,10 +19,11 @@ export default function AddPlaylist({
}: {
navigation: NativeStackNavigationProp<StackParamList, 'AddPlaylist'>
}): React.JSX.Element {
const { api, user, library } = useJellifyContext()
const [name, setName] = useState<string>('')
const useAddPlaylist = useMutation({
mutationFn: ({ name }: { name: string }) => createPlaylist(name),
mutationFn: ({ name }: { name: string }) => createPlaylist(api, user, name),
onSuccess: (data, { name }) => {
trigger('notificationSuccess')

View File

@@ -8,15 +8,16 @@ import { deletePlaylist } from '../../../api/mutations/playlists'
import { trigger } from 'react-native-haptic-feedback'
import { queryClient } from '../../../constants/query-client'
import { QueryKeys } from '../../../enums/query-keys'
import { useJellifyContext } from '../../../components/provider'
// import * as Burnt from 'burnt'
export default function DeletePlaylist({
navigation,
route,
}: DeletePlaylistProps): React.JSX.Element {
const { api, user, library } = useJellifyContext()
const useDeletePlaylist = useMutation({
mutationFn: (playlist: BaseItemDto) => deletePlaylist(playlist.Id!),
mutationFn: (playlist: BaseItemDto) => deletePlaylist(api, playlist.Id!),
onSuccess: (data, playlist) => {
trigger('notificationSuccess')

View File

@@ -3,20 +3,19 @@ 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 { useJellifyContext } from '../provider'
const LoginStack = createStackNavigator()
/**
* The login screen.
* @returns The login screen.
*/
export default function Login(): React.JSX.Element {
const { user, server, setTriggerAuth } = useAuthenticationContext()
const Stack = createStackNavigator()
useEffect(() => {
setTriggerAuth(false)
})
const { user, server } = useJellifyContext()
return (
<Stack.Navigator
<LoginStack.Navigator
initialRouteName={
isUndefined(server)
? 'ServerAddress'
@@ -26,7 +25,7 @@ export default function Login(): React.JSX.Element {
}
screenOptions={{ headerShown: false }}
>
<Stack.Screen
<LoginStack.Screen
name='ServerAddress'
options={{
headerShown: false,
@@ -34,22 +33,20 @@ export default function Login(): React.JSX.Element {
component={ServerAddress}
/>
<Stack.Screen
<LoginStack.Screen
name='ServerAuthentication'
options={{
headerShown: false,
}}
initialParams={{ server }}
//@ts-expect-error TOOD: Explain why this exists
component={ServerAuthentication}
/>
<Stack.Screen
<LoginStack.Screen
name='LibrarySelection'
options={{
headerShown: false,
}}
component={ServerLibrary}
/>
</Stack.Navigator>
</LoginStack.Navigator>
)
}

View File

@@ -1,75 +0,0 @@
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>>
}
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 [triggerAuth, setTriggerAuth] = useState<boolean>(true)
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: () => {},
})
export const JellyfinAuthenticationProvider: ({
children,
}: {
children: ReactNode
}) => React.JSX.Element = ({ children }: { children: ReactNode }) => {
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>
)
}
export const useAuthenticationContext = () => useContext(JellyfinAuthenticationContext)

View File

@@ -1,8 +1,8 @@
import React, { useState } from 'react'
import React, { useEffect, 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 { Input, Spinner, YStack } from 'tamagui'
import { SwitchWithLabel } from '../../Global/helpers/switch-with-label'
import { H2 } from '../../Global/helpers/text'
import Button from '../../Global/helpers/button'
@@ -11,27 +11,24 @@ 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 Toast from 'react-native-toast-message'
import { useJellifyContext } from '../../provider'
export default function ServerAddress({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
navigation.setOptions({
animationTypeForReplace: 'push',
})
const [useHttps, setUseHttps] = useState<boolean>(true)
const [serverAddress, setServerAddress] = useState<string | undefined>(undefined)
const { server, setServer } = useAuthenticationContext()
const { server, setServer, signOut } = useJellifyContext()
useEffect(() => {
signOut()
}, [])
const useServerMutation = useMutation({
mutationFn: () => {
@@ -59,14 +56,12 @@ export default function ServerAddress({
startUpComplete: publicSystemInfoResponse.data.StartupWizardCompleted!,
}
Client.setPublicApiClient(server)
setServer(server)
navigation.navigate('ServerAuthentication', { server })
navigation.navigate('ServerAuthentication')
},
onError: async (error: Error) => {
console.error('An error occurred connecting to the Jellyfin instance', error)
Client.signOut()
setServer(undefined)
// Burnt.toast({

View File

@@ -3,34 +3,31 @@ 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 { StackParamList } from '../../../components/types'
import Input from '../../../components/Global/helpers/input'
import Icon from '../../../components/Global/helpers/icon'
// import Toast from '../../../components/Global/components/toast'
import { useJellifyContext } from '../../provider'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import Toast from 'react-native-toast-message'
export default function ServerAuthentication({
route,
navigation,
}: ServerAuthenticationProps): React.JSX.Element {
// const toast = useToastController()
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { api } = useJellifyContext()
const [username, setUsername] = useState<string | undefined>(undefined)
const [password, setPassword] = React.useState<string | undefined>(undefined)
const { setUser, setServer } = useAuthenticationContext()
const { server, setUser, setServer } = useJellifyContext()
const useApiMutation = useMutation({
mutationFn: async (credentials: JellyfinCredentials) => {
return await Client.api!.authenticateUserByName(
credentials.username,
credentials.password,
)
return await api!.authenticateUserByName(credentials.username, credentials.password)
},
onSuccess: async (authResult) => {
console.log(`Received auth response from server`)
@@ -51,16 +48,18 @@ export default function ServerAuthentication({
accessToken: authResult.data.AccessToken as string,
}
Client.setUser(user)
setUser(user)
navigation.navigate('LibrarySelection', { user })
navigation.navigate('LibrarySelection')
},
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({
text1: `Unable to sign in to ${server!.name}`,
type: 'error',
})
return Promise.reject(`An error occured signing into ${server!.name}`)
},
})
@@ -68,7 +67,7 @@ export default function ServerAuthentication({
<SafeAreaView style={{ flex: 1 }}>
<YStack maxHeight={'$19'} flex={1} justifyContent='center'>
<H2 marginHorizontal={'$2'} textAlign='center'>
{`Sign in to ${route.params.server.name}`}
{`Sign in to ${server?.name ?? 'Jellyfin'}`}
</H2>
</YStack>
<YStack marginHorizontal={'$2'}>
@@ -109,8 +108,11 @@ export default function ServerAuthentication({
icon={() => <Icon name='chevron-left' small />}
bordered={0}
onPress={() => {
Client.switchServer()
navigation.push('ServerAddress')
if (navigation.canGoBack()) navigation.goBack()
else
navigation.navigate('ServerAddress', undefined, {
pop: false,
})
}}
>
Switch Server

View File

@@ -1,21 +1,23 @@
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 { getToken, Spinner, ToggleGroup, YStack } from 'tamagui'
import { H2, 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/libraries'
import { useQuery } from '@tanstack/react-query'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { StackParamList } from '../../../components/types'
export default function ServerLibrary(): React.JSX.Element {
const { setUser } = useAuthenticationContext()
const { setLoggedIn } = useJellifyContext()
export default function ServerLibrary({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { api, user, setUser, setLibrary } = useJellifyContext()
const [libraryId, setLibraryId] = useState<string | undefined>(undefined)
const [playlistLibrary, setPlaylistLibrary] = useState<BaseItemDto | undefined>(undefined)
@@ -28,7 +30,7 @@ export default function ServerLibrary(): React.JSX.Element {
refetch,
} = useQuery({
queryKey: [QueryKeys.UserViews],
queryFn: () => fetchUserViews(),
queryFn: () => fetchUserViews(api, user),
})
useEffect(() => {
@@ -40,69 +42,76 @@ export default function ServerLibrary(): React.JSX.Element {
return (
<SafeAreaView>
<H2>Select Music Library</H2>
<YStack marginHorizontal={'$2'}>
<H2>Select Music Library</H2>
{isPending ? (
<Spinner size='large' />
) : (
<ToggleGroup
orientation='vertical'
type='single'
disableDeactivation={true}
value={libraryId}
onValueChange={setLibraryId}
{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>}
<Button
disabled={!libraryId}
onPress={() => {
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,
})
navigation.navigate('Tabs', {
screen: 'Home',
params: {},
})
}}
>
{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>
)}
{`Let's Go!`}
</Button>
{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
onPress={() => {
Client.switchUser()
setUser(undefined)
}}
>
Switch User
</Button>
<Button
onPress={() => {
setUser(undefined)
navigation.navigate('ServerAuthentication', undefined, {
pop: false,
})
}}
>
Switch User
</Button>
</YStack>
</SafeAreaView>
)
}

View File

@@ -7,7 +7,8 @@ import { deleteAudio, getAudioCache, saveAudio } from './offlineModeUtils'
import { QueryKeys } from '../../enums/query-keys'
import { networkStatusTypes } from './internetConnectionWatcher'
import DownloadProgress from '../../types/DownloadProgress'
import { useJellifyContext } from '../provider'
import { isUndefined } from 'lodash'
interface NetworkContext {
useDownload: UseMutationResult<void, Error, BaseItemDto, unknown>
useRemoveDownload: UseMutationResult<void, Error, BaseItemDto, unknown>
@@ -17,11 +18,14 @@ interface NetworkContext {
}
const NetworkContextInitializer = () => {
const { api, sessionId } = useJellifyContext()
const queryClient = useQueryClient()
const useDownload = useMutation({
mutationFn: (trackItem: BaseItemDto) => {
const track = mapDtoToTrack(trackItem, [])
if (isUndefined(api)) throw new Error('API client not initialized')
const track = mapDtoToTrack(api, sessionId, trackItem, [])
return saveAudio(track, queryClient, false)
},

View File

@@ -42,6 +42,10 @@ export default function Scrubber(): React.JSX.Element {
setPosition(Math.floor(progress.position * ProgressMultiplier))
}, [progress.position])
useEffect(() => {
if (useSeekTo.isIdle) setSeeking(false)
}, [useSeekTo.isIdle])
return (
<YStack>
<GestureDetector gesture={scrubGesture}>

View File

@@ -10,16 +10,15 @@ import PlayPauseButton from './helpers/buttons'
import { TextTickerConfig } from './component.config'
import FastImage from 'react-native-fast-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../../api/client'
import { useQueueContext } from '../../player/queue-provider'
import { useJellifyContext } from '../provider'
export function Miniplayer({
navigation,
}: {
navigation: NavigationHelpers<ParamListBase, BottomTabNavigationEventMap>
}): React.JSX.Element {
const theme = useTheme()
const { api } = useJellifyContext()
const { nowPlaying } = usePlayerContext()
const { useSkip } = useQueueContext()
@@ -46,7 +45,7 @@ export function Miniplayer({
>
<FastImage
source={{
uri: getImageApi(Client.api!).getItemImageUrlById(
uri: getImageApi(api!).getItemImageUrlById(
nowPlaying!.item.AlbumId!,
),
}}

View File

@@ -13,17 +13,19 @@ import Scrubber from '../helpers/scrubber'
import Controls from '../helpers/controls'
import FastImage from 'react-native-fast-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../../../api/client'
import { useQueueContext } from '../../../player/queue-provider'
import Toast from 'react-native-toast-message'
import JellifyToastConfig from '../../../constants/toast.config'
import { useColorScheme } from 'react-native'
import { useFocusEffect } from '@react-navigation/native'
import { useJellifyContext } from '../../provider'
export default function PlayerScreen({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { api } = useJellifyContext()
const [showToast, setShowToast] = useState(true)
const isDarkMode = useColorScheme() === 'dark'
@@ -91,7 +93,7 @@ export default function PlayerScreen({
>
<FastImage
source={{
uri: getImageApi(Client.api!).getItemImageUrlById(
uri: getImageApi(api!).getItemImageUrlById(
nowPlaying!.item.AlbumId!,
),
}}

View File

@@ -19,7 +19,6 @@ export default function Queue({
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { width } = useSafeAreaFrame()
const { nowPlaying } = usePlayerContext()
const {
@@ -57,13 +56,12 @@ export default function Queue({
data={playQueue}
dragHitSlop={{
left: -50, // https://github.com/computerjazz/react-native-draggable-flatlist/issues/336
right: isReordering ? -(width * 0.95 - 20) : width,
}}
extraData={nowPlaying}
// enableLayoutAnimationExperimental
getItemLayout={(data, index) => ({
length: width / 9,
offset: (width / 9) * index,
length: 20,
offset: (20 / 9) * index,
index,
})}
initialScrollIndex={scrollIndex !== -1 ? scrollIndex : 0}
@@ -85,7 +83,7 @@ export default function Queue({
renderItem={({ item: queueItem, getIndex, drag, isActive }) => (
<XStack
alignItems='center'
onPress={(event) => {
onLongPress={(event) => {
trigger('impactLight')
drag()
}}
@@ -95,7 +93,6 @@ export default function Queue({
</YStack>
<Track
invertedColors={isActive}
queue={queueRef}
navigation={navigation}
track={queueItem.item}
@@ -107,10 +104,8 @@ export default function Queue({
useSkip.mutate(index)
}}
onLongPress={() => {
navigation.navigate('Details', {
item: queueItem.item,
isNested: true,
})
trigger('impactLight')
drag()
}}
isNested
showRemove

View File

@@ -14,9 +14,9 @@ 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 FastImage from 'react-native-fast-image'
import { useJellifyContext } from '../provider'
interface PlaylistProps {
playlist: BaseItemDto
@@ -36,6 +36,8 @@ interface RemoveFromPlaylistMutation {
}
export default function Playlist({ playlist, navigation }: PlaylistProps): React.JSX.Element {
const { api, user } = useJellifyContext()
const [editing, setEditing] = useState<boolean>(false)
const [playlistTracks, setPlaylistTracks] = useState<BaseItemDto[]>([])
const {
@@ -46,7 +48,7 @@ export default function Playlist({ playlist, navigation }: PlaylistProps): React
} = useQuery({
queryKey: [QueryKeys.ItemTracks, playlist.Id!],
queryFn: () => {
return getItemsApi(Client.api!)
return getItemsApi(api!)
.getItems({
parentId: playlist.Id!,
})
@@ -94,6 +96,7 @@ export default function Playlist({ playlist, navigation }: PlaylistProps): React
const useUpdatePlaylist = useMutation({
mutationFn: ({ playlist, tracks }: { playlist: BaseItemDto; tracks: BaseItemDto[] }) => {
return updatePlaylist(
api,
playlist.Id!,
playlist.Name!,
tracks.map((track) => track.Id!),
@@ -116,7 +119,7 @@ export default function Playlist({ playlist, navigation }: PlaylistProps): React
const useRemoveFromPlaylist = useMutation({
mutationFn: ({ playlist, track, index }: RemoveFromPlaylistMutation) => {
return removeFromPlaylist(track, playlist)
return removeFromPlaylist(api, track, playlist)
},
onSuccess: (data, { index }) => {
trigger('notificationSuccess')
@@ -145,7 +148,7 @@ export default function Playlist({ playlist, navigation }: PlaylistProps): React
ListHeaderComponent={
<YStack alignItems='center' marginTop={'$4'}>
<FastImage
source={{ uri: getImageApi(Client.api!).getItemImageUrlById(playlist.Id!) }}
source={{ uri: getImageApi(api!).getItemImageUrlById(playlist.Id!) }}
style={{
borderRadius: getToken('$5'),
width: getToken('$20') + getToken('$15'),
@@ -183,10 +186,12 @@ export default function Playlist({ playlist, navigation }: PlaylistProps): React
queue={playlist}
showArtwork
onLongPress={() => {
navigation.navigate('Details', {
item: track,
isNested: false,
})
editing
? drag()
: navigation.navigate('Details', {
item: track,
isNested: false,
})
}}
showRemove={editing}
onRemove={() =>

View File

@@ -7,10 +7,11 @@ import { getToken } from 'tamagui'
import { fetchFavoritePlaylists } from '../../api/queries/favorites'
import { QueryKeys } from '../../enums/query-keys'
import { useQuery } from '@tanstack/react-query'
import { useJellifyContext } from '../provider'
export default function FavoritePlaylists({
navigation,
}: FavoritePlaylistsProps): React.JSX.Element {
const { api, user, library } = useJellifyContext()
navigation.setOptions({
headerRight: () => {
return (
@@ -29,7 +30,7 @@ export default function FavoritePlaylists({
refetch,
} = useQuery({
queryKey: [QueryKeys.UserPlaylists],
queryFn: () => fetchFavoritePlaylists(),
queryFn: () => fetchFavoritePlaylists(api, user, library),
})
return (

View File

@@ -14,12 +14,14 @@ import Suggestions from './suggestions'
import { isEmpty } from 'lodash'
import HorizontalCardList from '../Global/components/horizontal-list'
import { ItemCard } from '../Global/components/item-card'
import { useJellifyContext } from '../provider'
export default function Search({
navigation,
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { api } = useJellifyContext()
const [searchString, setSearchString] = useState<string | undefined>(undefined)
const {
@@ -28,7 +30,7 @@ export default function Search({
isFetching: fetchingResults,
} = useQuery({
queryKey: [QueryKeys.Search, searchString],
queryFn: () => fetchSearchResults(searchString),
queryFn: () => fetchSearchResults(api, searchString),
})
const {
@@ -37,7 +39,7 @@ export default function Search({
refetch: refetchSuggestions,
} = useQuery({
queryKey: [QueryKeys.SearchSuggestions],
queryFn: () => fetchSearchSuggestions(),
queryFn: () => fetchSearchSuggestions(api),
})
const search = useCallback(() => {

View File

@@ -2,13 +2,14 @@ import { XStack } from '@tamagui/stacks'
import React from 'react'
import { Text } from '../../../components/Global/helpers/text'
import Icon from '../../../components/Global/helpers/icon'
import Client from '../../../api/client'
import { useJellifyContext } from '../../provider'
export default function AccountDetails(): React.JSX.Element {
const { user } = useJellifyContext()
return (
<XStack alignItems='center'>
<Icon name='account-music-outline' />
<Text>{Client.user!.name}</Text>
<Text>{user!.name}</Text>
</XStack>
)
}

View File

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

View File

@@ -1,22 +1,36 @@
import React from 'react'
import Button from '../../Global/helpers/button'
import Client from '../../../api/client'
import { useJellifyContext } from '../../../components/provider'
import TrackPlayer from 'react-native-track-player'
import { StackParamList } from '../../../components/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useNavigation } from '@react-navigation/native'
export default function SignOut(): React.JSX.Element {
const { setLoggedIn } = useJellifyContext()
const navigation = useNavigation<NativeStackNavigationProp<StackParamList>>()
return (
<Button
onPress={() => {
setLoggedIn(false)
Client.signOut()
TrackPlayer.reset()
.then(() => {
console.debug('TrackPlayer cleared')
})
.catch((error) => {
console.error('Error clearing TrackPlayer', error)
})
.finally(() => {
navigation.reset({
index: 0,
routes: [
{
name: 'Login',
params: {
screen: 'ServerAddress',
},
},
],
})
})
}}
>
Sign Out

View File

@@ -1,24 +1,32 @@
import React from 'react'
import Client from '../../../api/client'
import { Text } from 'react-native'
import { YStack, XStack } from 'tamagui'
import { H5 } from '../../../components/Global/helpers/text'
import { H5, Text } from '../../../components/Global/helpers/text'
import Icon from '../../../components/Global/helpers/icon'
import { useJellifyContext } from '../../provider'
export default function ServerDetails(): React.JSX.Element {
const { api, library } = useJellifyContext()
return (
<YStack>
{Client.api && (
{api && (
<YStack>
<H5>Access Token</H5>
<XStack>
<Icon name='hand-coin-outline' />
<Text>{Client.api!.accessToken}</Text>
<Text>{api.accessToken}</Text>
</XStack>
<H5>Jellyfin Server</H5>
<XStack>
<Icon name='server-network' />
<Text>{Client.api!.basePath}</Text>
<Text>{api.basePath}</Text>
</XStack>
</YStack>
)}
{library && (
<YStack>
<H5>Library</H5>
<XStack>
<Icon name='book-outline' />
<Text>{library.musicLibraryName!}</Text>
</XStack>
</YStack>
)}

View File

@@ -6,11 +6,13 @@ import { Separator } from 'tamagui'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../enums/query-keys'
import { fetchFavoriteTracks } from '../../api/queries/favorites'
import { useJellifyContext } from '../provider'
export default function TracksScreen({ route, navigation }: TracksProps): React.JSX.Element {
const { api, user, library } = useJellifyContext()
const { data: favoriteTracks } = useQuery({
queryKey: [QueryKeys.FavoriteTracks],
queryFn: fetchFavoriteTracks,
queryFn: () => fetchFavoriteTracks(api, user, library),
})
return (

View File

@@ -2,15 +2,12 @@ import _ from 'lodash'
import React from 'react'
import Navigation from './navigation'
import Login from './Login/component'
import { JellyfinAuthenticationProvider } from './Login/provider'
import { PlayerProvider } from '../player/player-provider'
import { useColorScheme } from 'react-native'
import { JellifyProvider, useJellifyContext } from './provider'
import { JellifyUserDataProvider } from './user-data-provider'
import { NetworkContextProvider } from './Network/provider'
import { QueueProvider } from '../player/queue-provider'
import Toast from 'react-native-toast-message'
import JellifyToastConfig from '../constants/toast.config'
/**
* The main component for the Jellify app. Children are wrapped in the {@link JellifyProvider}
@@ -30,9 +27,7 @@ export default function Jellify(): React.JSX.Element {
* @returns The {@link App} component
*/
function App(): React.JSX.Element {
const { loggedIn } = useJellifyContext()
return loggedIn ? (
return (
<JellifyUserDataProvider>
<NetworkContextProvider>
<QueueProvider>
@@ -42,9 +37,5 @@ function App(): React.JSX.Element {
</QueueProvider>
</NetworkContextProvider>
</JellifyUserDataProvider>
) : (
<JellyfinAuthenticationProvider>
<Login />
</JellyfinAuthenticationProvider>
)
}

View File

@@ -3,14 +3,17 @@ import Player from './Player/stack'
import { Tabs } from './tabs'
import { StackParamList } from './types'
import { useTheme } from 'tamagui'
import { useJellifyContext } from './provider'
import Login from './Login/component'
const RootStack = createNativeStackNavigator<StackParamList>()
export default function Navigation(): React.JSX.Element {
const RootStack = createNativeStackNavigator<StackParamList>()
const theme = useTheme()
const { api, library } = useJellifyContext()
return (
<RootStack.Navigator>
<RootStack.Navigator initialRouteName={api && library ? 'Tabs' : 'Login'}>
<RootStack.Screen
name='Tabs'
component={Tabs}
@@ -27,6 +30,13 @@ export default function Navigation(): React.JSX.Element {
presentation: 'modal',
}}
/>
<RootStack.Screen
name='Login'
component={Login}
options={{
headerShown: false,
}}
/>
</RootStack.Navigator>
)
}

View File

@@ -1,33 +1,142 @@
import { isUndefined } from 'lodash'
import { createContext, ReactNode, SetStateAction, useContext, useEffect, useState } from 'react'
import { CarPlay } from 'react-native-carplay'
import Client from '../api/client'
import CarPlayNavigation from './CarPlay/Navigation'
import { Platform } from 'react-native'
import { JellifyLibrary } from '../types/JellifyLibrary'
import { JellifyServer } from '../types/JellifyServer'
import { JellifyUser } from '../types/JellifyUser'
import { storage } from '../constants/storage'
import { MMKVStorageKeys } from '../enums/mmkv-storage-keys'
import { Api } from '@jellyfin/sdk/lib/api'
import { JellyfinInfo } from '../api/info'
import uuid from 'react-native-uuid'
/**
* The context for the Jellify provider.
*/
interface JellifyContext {
/**
* Whether the user is logged in.
*/
loggedIn: boolean
setLoggedIn: React.Dispatch<SetStateAction<boolean>>
/**
* The {@link Api} client.
*/
api: Api | undefined
/**
* The connected {@link JellifyServer} object.
*/
server: JellifyServer | undefined
/**
* The signed in {@link JellifyUser} object.
*/
user: JellifyUser | undefined
/**
* The selected{@link JellifyLibrary} object.
*/
library: JellifyLibrary | undefined
/**
* Whether CarPlay / Android Auto is connected.
*/
carPlayConnected: boolean
/**
* The ID for the current session.
*/
sessionId: string
/**
* The function to set the context {@link JellifyServer}.
*/
setServer: React.Dispatch<SetStateAction<JellifyServer | undefined>>
/**
* The function to set the context {@link JellifyUser}.
*/
setUser: React.Dispatch<SetStateAction<JellifyUser | undefined>>
/**
* The function to set the context {@link JellifyLibrary}.
*/
setLibrary: React.Dispatch<SetStateAction<JellifyLibrary | undefined>>
/**
* The function to sign out of Jellify. This will clear the context
* and remove all data from the device.
*/
signOut: () => void
}
const JellifyContextInitializer = () => {
const [loggedIn, setLoggedIn] = useState<boolean>(
!isUndefined(Client) &&
!isUndefined(Client.api) &&
!isUndefined(Client.user) &&
!isUndefined(Client.server) &&
!isUndefined(Client.library),
const userJson = storage.getString(MMKVStorageKeys.User)
const serverJson = storage.getString(MMKVStorageKeys.Server)
const libraryJson = storage.getString(MMKVStorageKeys.Library)
const apiJson = storage.getString(MMKVStorageKeys.Api)
const sessionId = uuid.v4()
const [api, setApi] = useState<Api | undefined>(apiJson ? JSON.parse(apiJson) : undefined)
const [server, setServer] = useState<JellifyServer | undefined>(
serverJson ? JSON.parse(serverJson) : undefined,
)
const [user, setUser] = useState<JellifyUser | undefined>(
userJson ? JSON.parse(userJson) : undefined,
)
const [library, setLibrary] = useState<JellifyLibrary | undefined>(
libraryJson ? JSON.parse(libraryJson) : undefined,
)
const [loggedIn, setLoggedIn] = useState<boolean>(false)
const [carPlayConnected, setCarPlayConnected] = useState(CarPlay ? CarPlay.connected : false)
const signOut = () => {
setServer(undefined)
setUser(undefined)
setLibrary(undefined)
}
useEffect(() => {
if (!isUndefined(server) && !isUndefined(user))
setApi(JellyfinInfo.createApi(server.url, user.accessToken))
else if (!isUndefined(server)) setApi(JellyfinInfo.createApi(server.url))
else setApi(undefined)
setLoggedIn(!isUndefined(server) && !isUndefined(user) && !isUndefined(library))
}, [server, user, library])
useEffect(() => {
if (api) storage.set(MMKVStorageKeys.Api, JSON.stringify(api))
else storage.delete(MMKVStorageKeys.Api)
}, [api])
useEffect(() => {
if (server) storage.set(MMKVStorageKeys.Server, JSON.stringify(server))
else storage.delete(MMKVStorageKeys.Server)
}, [server])
useEffect(() => {
if (user) storage.set(MMKVStorageKeys.User, JSON.stringify(user))
else storage.delete(MMKVStorageKeys.User)
}, [user])
useEffect(() => {
if (library) storage.set(MMKVStorageKeys.Library, JSON.stringify(library))
else storage.delete(MMKVStorageKeys.Library)
}, [library])
useEffect(() => {
function onConnect() {
setCarPlayConnected(true)
if (loggedIn) {
CarPlay.setRootTemplate(CarPlayNavigation)
if (user && api) {
CarPlay.setRootTemplate(CarPlayNavigation(api, user, sessionId))
if (Platform.OS === 'ios') {
CarPlay.enableNowPlaying(true) // https://github.com/birkir/react-native-carplay/issues/185
@@ -51,15 +160,31 @@ const JellifyContextInitializer = () => {
return {
loggedIn,
setLoggedIn,
api,
server,
user,
library,
sessionId,
setServer,
setUser,
setLibrary,
carPlayConnected,
signOut,
}
}
const JellifyContext = createContext<JellifyContext>({
loggedIn: false,
setLoggedIn: () => {},
api: undefined,
server: undefined,
user: undefined,
library: undefined,
sessionId: '',
setServer: () => {},
setUser: () => {},
setLibrary: () => {},
carPlayConnected: false,
signOut: () => {},
})
/**
@@ -73,19 +198,9 @@ export const JellifyProvider: ({ children }: { children: ReactNode }) => React.J
}: {
children: ReactNode
}) => {
const { loggedIn, setLoggedIn, carPlayConnected } = JellifyContextInitializer()
const context = JellifyContextInitializer()
return (
<JellifyContext.Provider
value={{
loggedIn,
setLoggedIn,
carPlayConnected,
}}
>
{children}
</JellifyContext.Provider>
)
return <JellifyContext.Provider value={context}>{children}</JellifyContext.Provider>
}
export const useJellifyContext = () => useContext(JellifyContext)

View File

@@ -1,19 +1,16 @@
import { QueryKeys } from '../enums/query-keys'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { NativeStackNavigationProp, NativeStackScreenProps } from '@react-navigation/native-stack'
import { JellifyServer } from '../types/JellifyServer'
import { JellifyUser } from '../types/JellifyUser'
import { Queue } from '../player/types/queue-item'
export type StackParamList = {
Login: {
screen: keyof StackParamList
}
ServerAddress: undefined
ServerAuthentication: {
server: JellifyServer
}
ServerAuthentication: undefined
LibrarySelection: {
user: JellifyUser
}
LibrarySelection: undefined
Home: undefined
AddPlaylist: undefined
@@ -55,7 +52,7 @@ export type StackParamList = {
Labs: undefined
Tabs: {
screen: string
screen: keyof StackParamList
params: object
}
@@ -90,6 +87,7 @@ export type StackParamList = {
}
}
export type LoginProps = NativeStackScreenProps<StackParamList, 'Login'>
export type ServerAddressProps = NativeStackScreenProps<StackParamList, 'ServerAddress'>
export type ServerAuthenticationProps = NativeStackScreenProps<
StackParamList,

View File

@@ -1,4 +1,3 @@
import Client from '../api/client'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api'
import { useMutation } from '@tanstack/react-query'
@@ -8,6 +7,7 @@ import { trigger } from 'react-native-haptic-feedback'
import { queryClient } from '../constants/query-client'
import { QueryKeys } from '../enums/query-keys'
import Toast from 'react-native-toast-message'
import { useJellifyContext } from './provider'
interface SetFavoriteMutation {
item: BaseItemDto
@@ -20,9 +20,10 @@ interface JellifyUserDataContext {
}
const JellifyUserDataContextInitializer = () => {
const { api } = useJellifyContext()
const useSetFavorite = useMutation({
mutationFn: async (mutation: SetFavoriteMutation) => {
return getUserLibraryApi(Client.api!).markFavoriteItem({
return getUserLibraryApi(api!).markFavoriteItem({
itemId: mutation.item.Id!,
})
},
@@ -49,7 +50,7 @@ const JellifyUserDataContextInitializer = () => {
const useRemoveFavorite = useMutation({
mutationFn: async (mutation: SetFavoriteMutation) => {
return getUserLibraryApi(Client.api!).unmarkFavoriteItem({
return getUserLibraryApi(api!).unmarkFavoriteItem({
itemId: mutation.item.Id!,
})
},

View File

@@ -9,4 +9,5 @@ export enum MMKVStorageKeys {
NowPlaying = 'NowPlaying',
Queue = 'Queue',
CurrentIndex = 'CurrentIndex',
Api = 'Api',
}

View File

@@ -7,11 +7,11 @@ import { JellifyTrack } from '../types/JellifyTrack'
import { RatingType, TrackType } from 'react-native-track-player'
import { QueuingType } from '../enums/queuing-type'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import Client from '../api/client'
import { isUndefined } from 'lodash'
import { JellifyDownload } from '../types/JellifyDownload'
import { queryClient } from '../constants/query-client'
import { QueryKeys } from '../enums/query-keys'
import { Api } from '@jellyfin/sdk/lib/api'
/**
* The container that the Jellyfin server will attempt to transcode to
@@ -34,6 +34,8 @@ const transcodingContainer = 'ts'
* @returns A `JellifyTrack`, which represents a Jellyfin library track queued in the player
*/
export function mapDtoToTrack(
api: Api,
sessionId: string,
item: BaseItemDto,
downloadedTracks: JellifyDownload[],
queuingType?: QueuingType,
@@ -43,9 +45,9 @@ export function mapDtoToTrack(
TranscodingContainer: transcodingContainer,
EnableRemoteMedia: 'true',
EnableRedirection: 'true',
api_key: Client.api!.accessToken,
api_key: api.accessToken,
StartTimeTicks: '0',
PlaySessionId: Client.sessionId,
PlaySessionId: sessionId,
}
console.debug(`Mapping BaseItemDTO to Track object`)
@@ -68,10 +70,7 @@ export function mapDtoToTrack(
PlaybackInfoResponse.MediaSources[0].TranscodingUrl
)
url = PlaybackInfoResponse.MediaSources![0].TranscodingUrl
else
url = `${Client.api!.basePath}/Audio/${item.Id!}/universal?${new URLSearchParams(
urlParams,
)}`
else url = `${api.basePath}/Audio/${item.Id!}/universal?${new URLSearchParams(urlParams)}`
}
console.debug(url.length)
@@ -79,14 +78,14 @@ export function mapDtoToTrack(
url,
type: TrackType.Default,
headers: {
'X-Emby-Token': Client.api!.accessToken,
'X-Emby-Token': api.accessToken,
},
title: item.Name,
album: item.Album,
artist: item.Artists?.join(', '),
duration: item.RunTimeTicks,
artwork: item.AlbumId
? getImageApi(Client.api!).getItemImageUrlById(item.AlbumId, ImageType.Primary, {
? getImageApi(api).getItemImageUrlById(item.AlbumId, ImageType.Primary, {
width: 300,
height: 300,
})

View File

@@ -1,4 +1,4 @@
export const UPDATE_INTERVAL: number = 50 // 8 milliseconds
export const UPDATE_INTERVAL: number = 200 // 20 milliseconds
/**
* Indicates the seconds the progress position must be

View File

@@ -3,21 +3,68 @@ import { QueuingType } from '../enums/queuing-type'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { Queue } from './types/queue-item'
/**
* A mutation to handle loading a new queue.
*/
export interface QueueMutation {
/**
* The track that will be played first in the queue.
*/
track: BaseItemDto
/**
* The index in the queue of the initially played track.
*/
index?: number | undefined
/**
* The list of tracks to load into the queue.
*/
tracklist: BaseItemDto[]
/**
* The {@link Queue} that this tracklist represents, be it
* an album or playlist (represented as a {@link BaseItemDto}),
* or a specific queue type (represented by a string)
*/
queue: Queue
/**
* The type of queuing to use, dictates the placement of tracks in the queue.
*/
queuingType?: QueuingType | undefined
}
/**
* A mutation to handle adding a track to the queue.
*/
export interface AddToQueueMutation {
/**
* The track to add to the queue.
*/
track: BaseItemDto
/**
* The type of queuing to use, dictates the placement of the track in the queue,
* be it playing next, or playing in the queue later
*/
queuingType?: QueuingType | undefined
}
/**
* A mutation to handle reordering the queue.
*/
export interface QueueOrderMutation {
/**
* The new order of the queue.
*
* btw, New Order is fantastic if you like Britpop
* {@link https://www.youtube.com/watch?v=c1GxjzHm5us}
*
* I took every opportunity to use that reference in this project
*/
newOrder: JellifyTrack[]
/**
* The index the track is moving from
*/
from: number
/**
* The index the track is moving to
*/
to: number
}

View File

@@ -2,7 +2,7 @@ import { createContext, ReactNode, useContext, useEffect, useState } from 'react
import { JellifyTrack } from '../types/JellifyTrack'
import { storage } from '../constants/storage'
import { MMKVStorageKeys } from '../enums/mmkv-storage-keys'
import {
import TrackPlayer, {
Event,
Progress,
State,
@@ -13,13 +13,14 @@ import { handlePlaybackProgress, handlePlaybackState } from './handlers'
import { useMutation, UseMutationResult } from '@tanstack/react-query'
import { trigger } from 'react-native-haptic-feedback'
import { pause, play, seekBy, seekTo } from 'react-native-track-player/lib/src/trackPlayer'
import Client from '../api/client'
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api'
import { useNetworkContext } from '../components/Network/provider'
import { useQueueContext } from './queue-provider'
import { PlaystateApi } from '@jellyfin/sdk/lib/generated-client/api/playstate-api'
import { networkStatusTypes } from '../components/Network/internetConnectionWatcher'
import { useJellifyContext } from '../components/provider'
import { isUndefined } from 'lodash'
interface PlayerContext {
nowPlaying: JellifyTrack | undefined
@@ -31,39 +32,59 @@ interface PlayerContext {
}
const PlayerContextInitializer = () => {
const { api, sessionId } = useJellifyContext()
const { playQueue, currentIndex, queueRef } = useQueueContext()
const nowPlayingJson = storage.getString(MMKVStorageKeys.NowPlaying)
/**
* A Jellyfin {@link PlaystateApi} instance. Used to report playback state and progress
* to Jellyfin.
*/
let playStateApi: PlaystateApi | undefined
if (Client.api) playStateApi = getPlaystateApi(Client.api)
/**
* Initialize the {@link playStateApi} instance.
*/
if (!isUndefined(api)) playStateApi = getPlaystateApi(api)
//#region State
const [nowPlaying, setNowPlaying] = useState<JellifyTrack | undefined>(
nowPlayingJson ? JSON.parse(nowPlayingJson) : undefined,
)
const { playQueue, currentIndex } = useQueueContext()
const [initialized, setInitialized] = useState<boolean>(false)
//#endregion State
//#region Functions
const handlePlaybackStateChanged = async (state: State) => {
if (playStateApi && nowPlaying)
await handlePlaybackState(Client.sessionId, playStateApi, nowPlaying, state)
await handlePlaybackState(sessionId, playStateApi, nowPlaying, state)
}
/**
* A function to handle reporting playback progress to Jellyfin. Does nothing if
* the {@link playStateApi} or {@link nowPlaying} are not defined.
*/
const handlePlaybackProgressUpdated = async (progress: Progress) => {
if (playStateApi && nowPlaying)
await handlePlaybackProgress(Client.sessionId, playStateApi, nowPlaying, progress)
await handlePlaybackProgress(sessionId, playStateApi, nowPlaying, progress)
}
//#endregion Functions
//#region Hooks
/**
* A mutation to handle starting playback
*/
const useStartPlayback = useMutation({
mutationFn: play,
})
/**
* A mutation to handle toggling the playback state
*/
const useTogglePlayback = useMutation({
mutationFn: () => {
trigger('impactMedium')
@@ -72,6 +93,9 @@ const PlayerContextInitializer = () => {
},
})
/**
* A mutation to handle seeking to a specific position in the track
*/
const useSeekTo = useMutation({
mutationFn: async (position: number) => {
trigger('impactLight')
@@ -79,6 +103,9 @@ const PlayerContextInitializer = () => {
},
})
/**
* A mutation to handle seeking to a specific position in the track
*/
const useSeekBy = useMutation({
mutationFn: async (seekSeconds: number) => {
trigger('clockTick')
@@ -87,10 +114,16 @@ const PlayerContextInitializer = () => {
},
})
/**
* A mutation to handle reporting playback state to Jellyfin
*/
const usePlaybackStateChanged = useMutation({
mutationFn: async (state: State) => handlePlaybackStateChanged(state),
})
/**
* A mutation to handle reporting playback progress to Jellyfin
*/
const usePlaybackProgressUpdated = useMutation({
mutationFn: async (progress: Progress) => handlePlaybackProgressUpdated(progress),
})
@@ -101,18 +134,16 @@ const PlayerContextInitializer = () => {
const { state: playbackState } = usePlaybackState()
const { useDownload, downloadedTracks, networkStatus } = useNetworkContext()
/**
* Use the {@link useTrackPlayerEvents} hook to listen for events from the player.
*
* This is use to report playback status to Jellyfin, and as such the player context
* is only concerned about the playback state and progress.
*/
useTrackPlayerEvents(
[Event.RemoteLike, Event.RemoteDislike, Event.PlaybackProgressUpdated, Event.PlaybackState],
(event) => {
switch (event.type) {
case Event.RemoteLike: {
break
}
case Event.RemoteDislike: {
break
}
case Event.PlaybackState: {
usePlaybackStateChanged.mutate(event.state)
break
@@ -139,17 +170,42 @@ const PlayerContextInitializer = () => {
//#endregion RNTP Setup
//#region useEffects
/**
* Store the now playing track in storage when it changes
*/
useEffect(() => {
if (nowPlaying) storage.set(MMKVStorageKeys.NowPlaying, JSON.stringify(nowPlaying))
}, [nowPlaying])
/**
* Set the now playing track to the track at the current index in the play queue
*/
useEffect(() => {
if (currentIndex > -1 && playQueue.length > currentIndex) {
console.debug(`Setting now playing to queue index ${currentIndex}`)
setNowPlaying(playQueue[currentIndex])
}
}, [currentIndex])
}, [currentIndex, playQueue])
/**
* Initialize the player. This is used to load the queue from the {@link QueueProvider}
* and set it to the player if we have already completed the onboarding process
* and the user has a valid queue in storage
*/
useEffect(() => {
console.debug('Initialized', initialized)
console.debug('Play queue length', playQueue.length)
console.debug('Current index', currentIndex)
if (playQueue.length > 0 && currentIndex > -1 && !initialized) {
TrackPlayer.setQueue(playQueue)
TrackPlayer.skip(currentIndex)
console.debug('Loaded queue from storage')
setInitialized(true)
} else if (queueRef === 'Recently Played' && currentIndex === -1) {
console.debug('Not loading queue as it is empty')
setInitialized(true)
}
}, [])
//#endregion useEffects
//#region return
@@ -165,6 +221,12 @@ const PlayerContextInitializer = () => {
}
//#region Create PlayerContext
/**
* Context for the player. This is used to provide the player context to the
* player components.
* @param param0 The default {@link PlayerContext}
* @returns The default {@link PlayerContext}
*/
export const PlayerContext = createContext<PlayerContext>({
nowPlaying: undefined,
playbackState: undefined,
@@ -243,6 +305,12 @@ export const PlayerContext = createContext<PlayerContext>({
})
//#endregion Create PlayerContext
/**
* Provider for the player context. This is used to provide player controls and the currently
* playing track to child components.
* @param param0 The {@link ReactNode} to render
* @returns A {@link ReactNode}
*/
export const PlayerProvider: ({ children }: { children: ReactNode }) => React.JSX.Element = ({
children,
}: {
@@ -253,4 +321,9 @@ export const PlayerProvider: ({ children }: { children: ReactNode }) => React.JS
return <PlayerContext.Provider value={context}>{children}</PlayerContext.Provider>
}
/**
* Hook to use the player context. This is used to get the player context from the
* {@link PlayerProvider}.
* @returns The {@link PlayerContext}
*/
export const usePlayerContext = () => useContext(PlayerContext)

View File

@@ -21,6 +21,7 @@ import { filterTracksOnNetworkStatus } from './helpers/queue'
import { SKIP_TO_PREVIOUS_THRESHOLD } from './config'
import { isUndefined } from 'lodash'
import Toast from 'react-native-toast-message'
import { useJellifyContext } from '../components/provider'
interface QueueContext {
queueRef: Queue
playQueue: JellifyTrack[]
@@ -36,17 +37,21 @@ interface QueueContext {
}
const QueueContextInitailizer = () => {
const currentIndexValue = storage.getNumber(MMKVStorageKeys.CurrentIndex)
const queueRefJson = storage.getString(MMKVStorageKeys.Queue)
const playQueueJson = storage.getString(MMKVStorageKeys.PlayQueue)
const queueRefInit = queueRefJson ? JSON.parse(queueRefJson) : 'Recently Played'
const playQueueInit = playQueueJson ? JSON.parse(playQueueJson) : []
const [queueRef, setQueueRef] = useState<Queue>(queueRefInit)
const [playQueue, setPlayQueue] = useState<JellifyTrack[]>(playQueueInit)
const [queueRef, setQueueRef] = useState<Queue>(queueRefInit)
const [currentIndex, setCurrentIndex] = useState<number>(-1)
const [currentIndex, setCurrentIndex] = useState<number>(
!isUndefined(currentIndexValue) ? currentIndexValue : -1,
)
const { api, sessionId, user } = useJellifyContext()
const { downloadedTracks, networkStatus } = useNetworkContext()
useTrackPlayerEvents([Event.PlaybackActiveTrackChanged], ({ index }) => {
@@ -81,7 +86,13 @@ const QueueContextInitailizer = () => {
TrackPlayer.remove([queueItemIndex]).then(() => {
TrackPlayer.add(
mapDtoToTrack(track, downloadedTracks ?? [], queueItem.QueuingType),
mapDtoToTrack(
api!,
sessionId,
track,
downloadedTracks ?? [],
queueItem.QueuingType,
),
queueItemIndex,
)
})
@@ -93,15 +104,9 @@ const QueueContextInitailizer = () => {
queuingRef: Queue,
startIndex: number = 0,
) => {
trigger('impactLight')
console.debug(`Queuing ${audioItems.length} items`)
/**
* If the start index matches the current index,
* then our useEffect won't fire - this ensures
* it does
*/
setCurrentIndex(-1)
const availableAudioItems = filterTracksOnNetworkStatus(
networkStatus,
audioItems,
@@ -115,7 +120,7 @@ const QueueContextInitailizer = () => {
)
const queue = availableAudioItems.map((item) =>
mapDtoToTrack(item, downloadedTracks ?? [], QueuingType.FromSelection),
mapDtoToTrack(api!, sessionId, item, downloadedTracks ?? [], QueuingType.FromSelection),
)
setQueueRef(queuingRef)
@@ -132,7 +137,13 @@ const QueueContextInitailizer = () => {
const playNextInQueue = async (item: BaseItemDto) => {
console.debug(`Playing item next in queue`)
const playNextTrack = mapDtoToTrack(item, downloadedTracks ?? [], QueuingType.PlayingNext)
const playNextTrack = mapDtoToTrack(
api!,
sessionId,
item,
downloadedTracks ?? [],
QueuingType.PlayingNext,
)
TrackPlayer.add([playNextTrack], currentIndex + 1)
setPlayQueue((await getQueue()) as JellifyTrack[])
@@ -149,7 +160,13 @@ const QueueContextInitailizer = () => {
await TrackPlayer.add(
items.map((item) =>
mapDtoToTrack(item, downloadedTracks ?? [], QueuingType.DirectlyQueued),
mapDtoToTrack(
api!,
sessionId,
item,
downloadedTracks ?? [],
QueuingType.DirectlyQueued,
),
),
insertIndex,
)
@@ -176,8 +193,6 @@ const QueueContextInitailizer = () => {
const skip = async (index?: number | undefined) => {
trigger('impactMedium')
setCurrentIndex(-1)
console.debug(
`Skip to next triggered. Index is ${`using ${
!isUndefined(index) ? index : currentIndex
@@ -220,7 +235,7 @@ const QueueContextInitailizer = () => {
onSuccess: async (data, { queue }: QueueMutation) => {
trigger('notificationSuccess')
if (typeof queue === 'object') await markItemPlayed(queue)
if (typeof queue === 'object' && api && user) await markItemPlayed(api, user, queue)
},
})
@@ -286,8 +301,12 @@ const QueueContextInitailizer = () => {
* Store current index in storage when it changes
*/
useEffect(() => {
storage.set(MMKVStorageKeys.CurrentIndex, currentIndex)
if (currentIndex !== -1) {
console.debug(`Storing current index ${currentIndex}`)
storage.set(MMKVStorageKeys.CurrentIndex, currentIndex)
}
}, [currentIndex])
//#endregion useEffect(s)
//#region Return

View File

@@ -1,8 +1,4 @@
import Client from '../api/client'
import { JellifyTrack } from '../types/JellifyTrack'
import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api'
import TrackPlayer, { Event, RatingType } from 'react-native-track-player'
import { getActiveTrack, getActiveTrackIndex } from 'react-native-track-player/lib/src/trackPlayer'
import TrackPlayer, { Event } from 'react-native-track-player'
import { SKIP_TO_PREVIOUS_THRESHOLD } from './config'
/**
@@ -33,38 +29,4 @@ export async function PlaybackService() {
TrackPlayer.addEventListener(Event.RemoteSeek, async (event) => {
await TrackPlayer.seekTo(event.position)
})
// TrackPlayer.addEventListener(Event.RemoteJumpForward, async (event) => {
// await TrackPlayer.seekBy(event.interval)
// });
// TrackPlayer.addEventListener(Event.RemoteJumpBackward, async (event) => {
// await TrackPlayer.seekBy(-event.interval)
// });
TrackPlayer.addEventListener(Event.RemoteLike, async () => {
const nowPlaying = (await getActiveTrack()) as JellifyTrack
const nowPlayingIndex = await getActiveTrackIndex()
await getUserLibraryApi(Client.api!).markFavoriteItem({
itemId: nowPlaying.item.Id!,
})
await TrackPlayer.updateMetadataForTrack(nowPlayingIndex!, {
rating: RatingType.Heart,
})
})
TrackPlayer.addEventListener(Event.RemoteDislike, async () => {
const nowPlaying = (await getActiveTrack()) as JellifyTrack
const nowPlayingIndex = await getActiveTrackIndex()
await getUserLibraryApi(Client.api!).markFavoriteItem({
itemId: nowPlaying.item.Id!,
})
await TrackPlayer.updateMetadataForTrack(nowPlayingIndex!, {
rating: undefined,
})
})
}

View File

@@ -4021,7 +4021,7 @@ available-typed-arrays@^1.0.7:
dependencies:
possible-typed-array-names "^1.0.0"
axios@^1.8.4:
axios@^1.9.0:
version "1.9.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.9.0.tgz#25534e3b72b54540077d33046f77e3b8d7081901"
integrity sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==