diff --git a/.github/workflows/publish-beta.yml b/.github/workflows/publish-beta.yml index 2825064f..be252517 100644 --- a/.github/workflows/publish-beta.yml +++ b/.github/workflows/publish-beta.yml @@ -21,7 +21,7 @@ jobs: run: yarn init-ios:new-arch - name: ➕ Version Up - run: yarn react-native bump-version --type patch + run: yarn react-native bump-version --type minor - name: 💬 Echo package.json version to Github ENV run: echo VERSION_NUMBER=$(node -p -e "require('./package.json').version") >> $GITHUB_ENV diff --git a/App.tsx b/App.tsx index 1657c60e..3b471214 100644 --- a/App.tsx +++ b/App.tsx @@ -33,30 +33,16 @@ export default function App(): React.JSX.Element { }) .then(() => TrackPlayer.updateOptions({ - progressUpdateEventInterval: 1, capabilities: CAPABILITIES, notificationCapabilities: CAPABILITIES, compactCapabilities: CAPABILITIES, - // ratingType: RatingType.Heart, - // likeOptions: { - // isActive: false, - // title: "Favorite" - // }, - // dislikeOptions: { - // isActive: true, - // title: "Unfavorite" - // } }), ) .finally(() => { setPlayerIsReady(true) requestStoragePermission() }) - const getActiveTrack = async () => { - const track = await TrackPlayer.getActiveTrack() - console.log('playerIsReady', track) - } - getActiveTrack() + const [reloader, setReloader] = useState(0) const handleRetry = () => setReloader((r) => r + 1) diff --git a/jest/JellifyProvider.test.tsx b/jest/JellifyProvider.test.tsx index 18b1b492..25077259 100644 --- a/jest/JellifyProvider.test.tsx +++ b/jest/JellifyProvider.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react-native' -import { JellifyProvider, useJellifyContext } from '../src/components/provider' +import { JellifyProvider, useJellifyContext } from '../src/providers' import { Text, View } from 'react-native' import { MMKVStorageKeys } from '../src/enums/mmkv-storage-keys' import { storage } from '../src/constants/storage' diff --git a/jest/PlayerProvider.test.tsx b/jest/PlayerProvider.test.tsx index ccb4cb0b..47d58161 100644 --- a/jest/PlayerProvider.test.tsx +++ b/jest/PlayerProvider.test.tsx @@ -3,8 +3,8 @@ import React from 'react' import { render } from '@testing-library/react-native' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { QueueProvider } from '../src/player/queue-provider' -import { PlayerProvider } from '../src/player/player-provider' +import { QueueProvider } from '../src/providers/Player/queue' +import { PlayerProvider } from '../src/providers/Player' import { View } from 'react-native' const queryClient = new QueryClient() diff --git a/jest/QueueProvider.test.tsx b/jest/QueueProvider.test.tsx index 286fc33a..7689784f 100644 --- a/jest/QueueProvider.test.tsx +++ b/jest/QueueProvider.test.tsx @@ -5,7 +5,7 @@ import { Event } from 'react-native-track-player' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { Button, Text } from 'react-native' -import { QueueProvider, useQueueContext } from '../src/player/queue-provider' +import { QueueProvider, useQueueContext } from '../src/providers/Player/queue' import { eventHandler } from './setup-rntp' const queryClient = new QueryClient() diff --git a/package.json b/package.json index bbcddfbc..06338b44 100644 --- a/package.json +++ b/package.json @@ -1,120 +1,120 @@ { - "name": "jellify", - "version": "0.11.27", - "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.git#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" - } -} \ No newline at end of file + "name": "jellify", + "version": "0.11.27", + "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.git#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" + } +} diff --git a/src/api/queries.ts b/src/api/queries.ts deleted file mode 100644 index 46fab66b..00000000 --- a/src/api/queries.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { QueryKeys } from '../enums/query-keys' -import { createApi } from './queries/api' - -export const useApi = ( - serverUrl?: string, - username?: string, - password?: string, - accessToken?: string, -): ReturnType => - useQuery({ - queryKey: [QueryKeys.Api, serverUrl, username, password, accessToken], - queryFn: ({ queryKey }) => { - const serverUrl: string | undefined = queryKey[1] - const username: string | undefined = queryKey[2] - const password: string | undefined = queryKey[3] - const accessToken: string | undefined = queryKey[4] - - return createApi(serverUrl, username, password, accessToken) - }, - }) diff --git a/src/api/queries/album.ts b/src/api/queries/album.ts new file mode 100644 index 00000000..181dc161 --- /dev/null +++ b/src/api/queries/album.ts @@ -0,0 +1,24 @@ +import { getItemsApi } from '@jellyfin/sdk/lib/utils/api' +import QueryConfig from './query.config' +import { + BaseItemDto, + BaseItemKind, + ItemSortBy, + SortOrder, +} from '@jellyfin/sdk/lib/generated-client/models' +import { JellifyLibrary } from '../../types/JellifyLibrary' +import { Api } from '@jellyfin/sdk' +import { fetchItems } from './item' + +export function fetchAlbums( + api: Api | undefined, + library: JellifyLibrary | undefined, + page: number, + isFavorite: boolean = false, + sortBy: ItemSortBy[] = [ItemSortBy.SortName], + sortOrder: SortOrder[] = [SortOrder.Ascending], +): Promise { + console.debug('Fetching albums', page) + + return fetchItems(api, library, [BaseItemKind.MusicAlbum], page, sortBy, sortOrder, isFavorite) +} diff --git a/src/api/queries/artist.ts b/src/api/queries/artist.ts index e3b73d1a..9434e9a8 100644 --- a/src/api/queries/artist.ts +++ b/src/api/queries/artist.ts @@ -1,3 +1,4 @@ +import { JellifyLibrary } from '@/src/types/JellifyLibrary' import { Api } from '@jellyfin/sdk/lib/api' import { BaseItemDto, @@ -6,6 +7,19 @@ import { SortOrder, } from '@jellyfin/sdk/lib/generated-client/models' import { getItemsApi } from '@jellyfin/sdk/lib/utils/api' +import { fetchItems } from './item' + +export function fetchArtists( + api: Api | undefined, + library: JellifyLibrary | undefined, + page: number, + isFavorite: boolean, + sortBy: ItemSortBy[] = [ItemSortBy.SortName], + sortOrder: SortOrder[] = [SortOrder.Ascending], +): Promise { + console.debug('Fetching artists', page) + return fetchItems(api, library, [BaseItemKind.MusicArtist], page, sortBy, sortOrder, isFavorite) +} /** * Fetches all albums for an artist @@ -17,6 +31,8 @@ export function fetchArtistAlbums( api: Api | undefined, artist: BaseItemDto, ): Promise { + console.debug('Fetching artist albums') + return new Promise((resolve, reject) => { if (!api) return reject('No API instance provided') @@ -48,6 +64,8 @@ export function fetchArtistFeaturedOn( api: Api | undefined, artist: BaseItemDto, ): Promise { + console.debug('Fetching artist featured on') + return new Promise((resolve, reject) => { if (!api) return reject('No API instance provided') diff --git a/src/api/queries/frequents.ts b/src/api/queries/frequents.ts index 8f53dbb7..53f5a151 100644 --- a/src/api/queries/frequents.ts +++ b/src/api/queries/frequents.ts @@ -13,11 +13,13 @@ import { JellifyLibrary } from '../../types/JellifyLibrary' * 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 + * @param page The page number to fetch + * @returns The most frequently played items from the user's library */ export function fetchFrequentlyPlayed( api: Api | undefined, library: JellifyLibrary | undefined, + page: number, ): Promise { return new Promise((resolve, reject) => { if (isUndefined(api)) return reject('Client instance not set') @@ -29,6 +31,7 @@ export function fetchFrequentlyPlayed( parentId: library!.musicLibraryId, recursive: true, limit: 100, + startIndex: page * 100, sortBy: [ItemSortBy.PlayCount], sortOrder: [SortOrder.Descending], }) @@ -47,30 +50,45 @@ export function fetchFrequentlyPlayed( * {@link fetchFrequentlyPlayed} query * @param api The Jellyfin {@link Api} instance * @param library The Jellyfin {@link JellifyLibrary} instance + * @param page The page number to fetch * @returns The most frequently played artists from the user's library */ export function fetchFrequentlyPlayedArtists( api: Api | undefined, library: JellifyLibrary | undefined, + page: number, ): Promise { + console.debug('Fetching frequently played artists', page) + return new Promise((resolve, reject) => { console.debug('Fetching frequently played artists') - try { - fetchFrequentlyPlayed(api, library) - .then((frequentlyPlayed) => { - console.debug('Received frequently played artists response') - return frequentlyPlayed.map((played) => played.ArtistItems![0] as BaseItemDto) - }) - .then((artists) => { - resolve( - artists.filter((item, index, artists) => { - return index === artists.findIndex((artist) => artist.Id === item.Id) - }), - ) - }) - } catch (error) { - reject(error) - } + if (isUndefined(api)) return reject('Client instance not set') + if (isUndefined(library)) return reject('Library instance not set') + + getItemsApi(api!) + .getItems({ + includeItemTypes: [BaseItemKind.MusicArtist], + parentId: library!.musicLibraryId, + recursive: true, + limit: 100, + startIndex: page * 100, + sortBy: [ItemSortBy.PlayCount], + sortOrder: [SortOrder.Descending], + }) + .then(({ data }) => { + if (data.Items) return data.Items + else return [] + }) + .then((artists) => { + resolve( + artists.filter((item, index, artists) => { + return index === artists.findIndex((artist) => artist.Id === item.Id) + }), + ) + }) + .catch((error) => { + reject(error) + }) }) } diff --git a/src/api/queries/instant-mixes.ts b/src/api/queries/instant-mixes.ts index dbc08d99..bd15cc00 100644 --- a/src/api/queries/instant-mixes.ts +++ b/src/api/queries/instant-mixes.ts @@ -16,6 +16,8 @@ export function fetchInstantMixFromItem( user: JellifyUser | undefined, item: BaseItemDto, ): Promise { + console.debug('Fetching instant mix from item') + return new Promise((resolve, reject) => { if (isUndefined(api)) return reject(new Error('Client not initialized')) if (isUndefined(user)) return reject(new Error('User not initialized')) diff --git a/src/api/queries/item.ts b/src/api/queries/item.ts index b8458238..c0fa99ad 100644 --- a/src/api/queries/item.ts +++ b/src/api/queries/item.ts @@ -1,8 +1,15 @@ -import { BaseItemDto, ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models' +import { + BaseItemDto, + BaseItemKind, + ItemSortBy, + SortOrder, +} 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' +import { JellifyLibrary } from '../../types/JellifyLibrary' +import QueryConfig from './query.config' /** * Fetches a single Jellyfin item by it's ID @@ -10,6 +17,7 @@ import { Api } from '@jellyfin/sdk/lib/api' * @returns The item - a {@link BaseItemDto} */ export async function fetchItem(api: Api | undefined, itemId: string): Promise { + console.debug('Fetching item by id') return new Promise((resolve, reject) => { if (isEmpty(itemId)) return reject('No item ID proviced') if (isUndefined(api)) return reject('Client not initialized') @@ -29,6 +37,51 @@ export async function fetchItem(api: Api | undefined, itemId: string): Promise { + console.debug('Fetching items', page) + return new Promise((resolve, reject) => { + if (isUndefined(api)) return reject('Client not initialized') + if (isUndefined(library)) return reject('Library not initialized') + + getItemsApi(api) + .getItems({ + parentId: parentId ?? library.musicLibraryId, + includeItemTypes: types, + sortBy, + recursive: true, + sortOrder, + startIndex: page * QueryConfig.limits.library, + limit: QueryConfig.limits.library, + isFavorite, + }) + .then(({ data }) => { + resolve(data.Items ?? []) + }) + .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 @@ -39,6 +92,7 @@ export async function fetchAlbumDiscs( api: Api | undefined, album: BaseItemDto, ): Promise<{ title: string; data: BaseItemDto[] }[]> { + console.debug('Fetching album discs') return new Promise<{ title: string; data: BaseItemDto[] }[]>((resolve, reject) => { if (isEmpty(album.Id)) return reject('No album ID provided') if (isUndefined(api)) return reject('Client not initialized') diff --git a/src/api/queries/media.ts b/src/api/queries/media.ts index e853e351..146e801a 100644 --- a/src/api/queries/media.ts +++ b/src/api/queries/media.ts @@ -1,19 +1,22 @@ import { Api } from '@jellyfin/sdk' -import { PlaybackInfoResponse } from '@jellyfin/sdk/lib/generated-client/models' +import { BaseItemDto, PlaybackInfoResponse } from '@jellyfin/sdk/lib/generated-client/models' import { getAudioApi, getMediaInfoApi } from '@jellyfin/sdk/lib/utils/api' import { isUndefined } from 'lodash' import { JellifyUser } from '../../types/JellifyUser' export async function fetchMediaInfo( api: Api | undefined, user: JellifyUser | undefined, - itemId: string, + item: BaseItemDto, ): Promise { + console.debug('Fetching media info') + return new Promise((resolve, reject) => { if (isUndefined(api)) return reject('Client instance not set') if (isUndefined(user)) return reject('User instance not set') + getMediaInfoApi(api) .getPlaybackInfo({ - itemId, + itemId: item.Id!, userId: user.id, }) .then(({ data }) => { diff --git a/src/api/queries/query.config.ts b/src/api/queries/query.config.ts index 19f2af8c..51c01422 100644 --- a/src/api/queries/query.config.ts +++ b/src/api/queries/query.config.ts @@ -1,10 +1,33 @@ import { ImageFormat } from '@jellyfin/sdk/lib/generated-client/models' const QueryConfig = { + /** + * Defines the limits for the number of items returned by a query + */ limits: { recents: 50, + + /** + * The number of items to fetch for the library, set to 30 + * This is used for the artists, albums, and tracks tabs in the library + */ + library: 30, + + /** + * The number of items to fetch for the instant mix, set to 50 + * This is used for the instant mix results + */ instantMix: 50, + + /** + * The number of items to fetch for the search, set to 50 + * This is used for the search tab in the player + */ search: 50, // TODO: make this a paginated search so limits don't even matter + + /** + * The number of items to fetch for the similar items, set to 20 + */ similar: 20, }, images: { diff --git a/src/api/queries/recents.ts b/src/api/queries/recents.ts index cb309ae1..e67d0356 100644 --- a/src/api/queries/recents.ts +++ b/src/api/queries/recents.ts @@ -10,27 +10,35 @@ import QueryConfig from './query.config' import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api' import { Api } from '@jellyfin/sdk' import { isUndefined } from 'lodash' -import { JellifyLibrary } from '../../../src/types/JellifyLibrary' +import { JellifyLibrary } from '../../types/JellifyLibrary' +import { JellifyUser } from '../../types/JellifyUser' +import { queryClient } from '../../constants/query-client' +import { QueryKeys } from '../../enums/query-keys' +import { InfiniteData } from '@tanstack/react-query' export async function fetchRecentlyAdded( api: Api | undefined, library: JellifyLibrary | undefined, - limit: number = QueryConfig.limits.recents, - offset?: number | undefined, + page: number, ): Promise { - if (isUndefined(api)) { - console.error('Client not set') - return [] - } - if (isUndefined(library)) return [] - return await getUserLibraryApi(api) - .getLatestMedia({ - parentId: library.musicLibraryId, - limit, - }) - .then(({ data }) => { - return offset ? data.slice(offset, data.length - 1) : data - }) + return new Promise((resolve, reject) => { + if (isUndefined(api)) return reject('Client instance not set') + if (isUndefined(library)) return reject('Library instance not set') + + getUserLibraryApi(api) + .getLatestMedia({ + parentId: library.musicLibraryId, + limit: QueryConfig.limits.recents, + }) + .then(({ data }) => { + if (data) return resolve(data) + return resolve([]) + }) + .catch((error) => { + console.error(error) + return reject(error) + }) + }) } /** @@ -41,22 +49,25 @@ export async function fetchRecentlyAdded( */ export async function fetchRecentlyPlayed( api: Api | undefined, + user: JellifyUser | undefined, library: JellifyLibrary | undefined, + page: number, limit: number = QueryConfig.limits.recents, - offset?: number | undefined, ): Promise { console.debug('Fetching recently played items') 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 (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], - startIndex: offset, + startIndex: page * limit, + userId: user.id, limit, - parentId: library!.musicLibraryId, + parentId: library.musicLibraryId, recursive: true, sortBy: [ItemSortBy.DatePlayed], sortOrder: [SortOrder.Descending], @@ -76,27 +87,38 @@ export async function fetchRecentlyPlayed( } /** - * Fetches recently played artists for a user from the Jellyfin server, - * referencing the recently played tracks. - * @param limit The number of items to fetch. Defaults to 50 - * @param offset The offset of the items to fetch. + * Fetches recently played artists for a user, using the recently played tracks + * from the query client since Jellyfin doesn't track when artists are played accurately. + * @param page The page number of the recently played tracks to fetch artists from. * @returns The recently played artists. */ -export function fetchRecentlyPlayedArtists( - api: Api | undefined, - library: JellifyLibrary | undefined, - limit: number = QueryConfig.limits.recents, - offset?: number | undefined, -): Promise { - 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! - }) - }, - ) +export function fetchRecentlyPlayedArtists(page: number): Promise { + console.debug('Fetching recently played artists') + return new Promise((resolve, reject) => { + // Get the recently played tracks from the query client + const recentlyPlayedTracks = queryClient.getQueryData>([ + QueryKeys.RecentlyPlayed, + ]) + if (!recentlyPlayedTracks) { + return resolve([]) + } + + // Get the artists from the recently played tracks + const artists = recentlyPlayedTracks.pages[page] + + // Map artist from the recently played tracks + .map((track) => (track.ArtistItems ? track.ArtistItems[0] : undefined)) + + // Filter out undefined artists + .filter((artist) => artist !== undefined) + + // Filter out duplicate artists + .filter( + (artist, index, artists) => + artists.findIndex((duplicateArtist) => duplicateArtist.Id === artist.Id) === + index, + ) + + resolve(artists) + }) } diff --git a/src/api/queries/tracks.ts b/src/api/queries/tracks.ts new file mode 100644 index 00000000..8fff6bc3 --- /dev/null +++ b/src/api/queries/tracks.ts @@ -0,0 +1,47 @@ +import { JellifyLibrary } from '@/src/types/JellifyLibrary' +import { Api } from '@jellyfin/sdk' +import { + BaseItemDto, + BaseItemKind, + ItemSortBy, + SortOrder, +} from '@jellyfin/sdk/lib/generated-client/models' +import { getItemsApi } from '@jellyfin/sdk/lib/utils/api' +import { isUndefined } from 'lodash' +import QueryConfig from './query.config' + +export function fetchTracks( + api: Api | undefined, + library: JellifyLibrary | undefined, + pageParam: number, + isFavorite: boolean = false, + sortBy: ItemSortBy = ItemSortBy.SortName, + sortOrder: SortOrder = SortOrder.Ascending, +) { + return new Promise((resolve, reject) => { + 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: library.musicLibraryId, + recursive: true, + isFavorite: isFavorite, + limit: QueryConfig.limits.library * 2, + startIndex: pageParam * QueryConfig.limits.library * 2, + sortBy: [sortBy], + sortOrder: [sortOrder], + }) + .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) + }) + }) +} diff --git a/src/components/Album/index.tsx b/src/components/Album/index.tsx index a2d436e9..312d4356 100644 --- a/src/components/Album/index.tsx +++ b/src/components/Album/index.tsx @@ -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 { useJellifyContext } from '../provider' +import { useJellifyContext } from '../../providers' import { useSafeAreaFrame } from 'react-native-safe-area-context' /** diff --git a/src/components/Albums/component.tsx b/src/components/Albums/component.tsx index 7ada0108..c79802ed 100644 --- a/src/components/Albums/component.tsx +++ b/src/components/Albums/component.tsx @@ -1,32 +1,26 @@ -import { AlbumsProps } from '../types' import { ItemCard } from '../Global/components/item-card' -import { FlatList, RefreshControl } from 'react-native' -import { useQuery } from '@tanstack/react-query' -import { QueryKeys } from '../../enums/query-keys' -import { fetchFavoriteAlbums } from '../../api/queries/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(api, user, library), - }) +import { FlatList } from 'react-native' +import { AlbumsProps } from '../types' +import { useDisplayContext } from '../../providers/Display/display-provider' +import { getTokens } from 'tamagui' +export default function Albums({ + albums, + navigation, + fetchNextPage, + hasNextPage, +}: AlbumsProps): React.JSX.Element { + const { numberOfColumns } = useDisplayContext() return ( } + numColumns={numberOfColumns} + data={albums?.pages.flatMap((page) => page) ?? []} renderItem={({ index, item: album }) => ( { navigation.navigate('Album', { album }) }} - size={'$14'} + size={'$11'} /> )} + onEndReached={() => { + if (hasNextPage) fetchNextPage() + }} + onEndReachedThreshold={0.25} + removeClippedSubviews /> ) } diff --git a/src/components/Albums/screen.tsx b/src/components/Albums/screen.tsx deleted file mode 100644 index d98856aa..00000000 --- a/src/components/Albums/screen.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react' -import { StackParamList } from '../types' -import { NativeStackScreenProps } from '@react-navigation/native-stack' -import Albums from './component' - -export default function AlbumsScreen( - props: NativeStackScreenProps, -): React.JSX.Element { - return -} diff --git a/src/components/Artist/albums.tsx b/src/components/Artist/albums.tsx index 16427436..bf0bce57 100644 --- a/src/components/Artist/albums.tsx +++ b/src/components/Artist/albums.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react' import { ItemCard } from '../Global/components/item-card' import { ArtistAlbumsProps, ArtistEpsProps, ArtistFeaturedOnProps } from '../types' import { Text } from '../Global/helpers/text' -import { useArtistContext } from './provider' +import { useArtistContext } from '../../providers/Artist' import { convertRunTimeTicksToSeconds } from '../../helpers/runtimeticks' import Animated, { useAnimatedScrollHandler } from 'react-native-reanimated' import { ActivityIndicator } from 'react-native' diff --git a/src/components/Artist/index.tsx b/src/components/Artist/index.tsx index 6ae0260c..1bce3bd2 100644 --- a/src/components/Artist/index.tsx +++ b/src/components/Artist/index.tsx @@ -1,21 +1,65 @@ -import { RouteProp } from '@react-navigation/native' -import { NativeStackNavigationProp } from '@react-navigation/native-stack' +import React from 'react' +import Albums from './albums' +import SimilarArtists from './similar' +import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs' import { StackParamList } from '../types' -import { ArtistProvider } from './provider' -import ArtistNavigation from './navigation' +import { NativeStackNavigationProp } from '@react-navigation/native-stack' +import ArtistTabBar from './tab-bar' +import { useArtistContext } from '../../providers/Artist' -export function ArtistScreen({ - route, +const ArtistTabs = createMaterialTopTabNavigator() + +export default function ArtistNavigation({ navigation, }: { - route: RouteProp navigation: NativeStackNavigationProp }): React.JSX.Element { - const { artist } = route.params + const { featuredOn, artist } = useArtistContext() return ( - - - + ArtistTabBar(props, navigation)} + screenOptions={{ + tabBarLabelStyle: { + fontFamily: 'Aileron-Bold', + }, + }} + > + + + + + {featuredOn && featuredOn.length > 0 && ( + + )} + + 20 ? '...' : '' + }`, + }} + component={SimilarArtists} + /> + ) } diff --git a/src/components/Artist/navigation.tsx b/src/components/Artist/navigation.tsx deleted file mode 100644 index 22c14e65..00000000 --- a/src/components/Artist/navigation.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react' -import Albums from './albums' -import SimilarArtists from './similar' -import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs' -import { StackParamList } from '../types' -import { NativeStackNavigationProp } from '@react-navigation/native-stack' -import ArtistTabBar from './tab-bar' -import { useArtistContext } from './provider' -const ArtistTabs = createMaterialTopTabNavigator() - -export default function ArtistNavigation({ - navigation, -}: { - navigation: NativeStackNavigationProp -}): React.JSX.Element { - const { featuredOn, artist } = useArtistContext() - - return ( - ArtistTabBar(props, navigation)} - screenOptions={{ - tabBarLabelStyle: { - fontFamily: 'Aileron-Bold', - }, - }} - > - - - - - {featuredOn && featuredOn.length > 0 && ( - - )} - - 20 ? '...' : '' - }`, - }} - component={SimilarArtists} - /> - - ) -} diff --git a/src/components/Artist/similar.tsx b/src/components/Artist/similar.tsx index 6e44ca26..3810070c 100644 --- a/src/components/Artist/similar.tsx +++ b/src/components/Artist/similar.tsx @@ -3,7 +3,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { StackParamList } from '../types' import { RouteProp } from '@react-navigation/native' import { Text } from '../Global/helpers/text' -import { useArtistContext } from './provider' +import { useArtistContext } from '../../providers/Artist' import Animated, { useAnimatedScrollHandler } from 'react-native-reanimated' import { ActivityIndicator } from 'react-native' diff --git a/src/components/Artist/tab-bar.tsx b/src/components/Artist/tab-bar.tsx index 30c2a335..2ae64b41 100644 --- a/src/components/Artist/tab-bar.tsx +++ b/src/components/Artist/tab-bar.tsx @@ -7,9 +7,9 @@ import Animated, { useAnimatedStyle, withSpring } from 'react-native-reanimated' import FastImage from 'react-native-fast-image' import { getImageApi } from '@jellyfin/sdk/lib/utils/api' import { ImageType } from '@jellyfin/sdk/lib/generated-client/models' -import { useArtistContext } from './provider' +import { useArtistContext } from '../../providers/Artist' import { useSafeAreaFrame } from 'react-native-safe-area-context' -import { useJellifyContext } from '../provider' +import { useJellifyContext } from '../../providers' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { StackParamList } from '../types' import React from 'react' diff --git a/src/components/Artists/component.tsx b/src/components/Artists/component.tsx index d572cbd6..837191a8 100644 --- a/src/components/Artists/component.tsx +++ b/src/components/Artists/component.tsx @@ -1,36 +1,35 @@ -import React from 'react' +import React, { useEffect } from 'react' import { ItemCard } from '../Global/components/item-card' -import { ArtistsProps } from '../types' -import { QueryKeys } from '../../enums/query-keys' -import { useQuery } from '@tanstack/react-query' -import { fetchFavoriteArtists } from '../../api/queries/favorites' -import { YStack } from 'tamagui' +import { getTokens, YStack } from 'tamagui' import { Text } from '../Global/helpers/text' -import { FlatList } from 'react-native' -import { useJellifyContext } from '../provider' +import { ActivityIndicator, FlatList } from 'react-native' +import { useDisplayContext } from '../../providers/Display/display-provider' +import { StackParamList } from '../types' +import { ArtistsProps } from '../types' -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(api, user, library), - }) +export default function Artists({ + artists, + navigation, + fetchNextPage, + hasNextPage, + isPending, +}: ArtistsProps): React.JSX.Element { + const { numberOfColumns } = useDisplayContext() + + useEffect(() => { + console.debug(hasNextPage) + }, [hasNextPage]) return ( page) ?? []} renderItem={({ index, item: artist }) => ( { navigation.navigate('Artist', { artist }) }} - size={'$14'} + size={'$11'} /> )} ListEmptyComponent={ - - No artists - + isPending ? ( + + ) : ( + + No artists + + ) } + onEndReached={() => { + if (hasNextPage) fetchNextPage() + }} + onEndReachedThreshold={0.25} + removeClippedSubviews /> ) } diff --git a/src/components/Artists/screen.tsx b/src/components/Artists/screen.tsx index 476c7882..e3502071 100644 --- a/src/components/Artists/screen.tsx +++ b/src/components/Artists/screen.tsx @@ -1,15 +1,21 @@ import React from 'react' -import { StackParamList } from '../types' -import { RouteProp } from '@react-navigation/native' -import { NativeStackNavigationProp } from '@react-navigation/native-stack' import Artists from './component' +import { ArtistsProps, StackParamList } from '../types' export default function ArtistsScreen({ - route, navigation, -}: { - route: RouteProp - navigation: NativeStackNavigationProp -}): React.JSX.Element { - return + artists, + fetchNextPage, + hasNextPage, + isPending, +}: ArtistsProps): React.JSX.Element { + return ( + + ) } diff --git a/src/components/CarPlay/Tracks.tsx b/src/components/CarPlay/Tracks.tsx index 5e469d6b..6ee8218f 100644 --- a/src/components/CarPlay/Tracks.tsx +++ b/src/components/CarPlay/Tracks.tsx @@ -8,7 +8,7 @@ import { queryClient } from '../../constants/query-client' import { QueryKeys } from '../../enums/query-keys' import { Api } from '@jellyfin/sdk' import React from 'react' -import { QueueContext } from '../../player/queue-provider' +import { QueueContext } from '../../providers/Player/queue' import { Queue } from '../../player/types/queue-item' const TracksTemplate = (api: Api, sessionId: string, items: BaseItemDto[], queuingRef: Queue) => diff --git a/src/components/ItemDetail/component.tsx b/src/components/Detail/component.tsx similarity index 92% rename from src/components/ItemDetail/component.tsx rename to src/components/Detail/component.tsx index 2534f3e4..0d00e14a 100644 --- a/src/components/ItemDetail/component.tsx +++ b/src/components/Detail/component.tsx @@ -16,7 +16,7 @@ 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' +import { useJellifyContext } from '../../providers' export default function ItemDetail({ item, navigation, @@ -65,7 +65,7 @@ export default function ItemDetail({ {/** * Android needs a dismiss chevron here @@ -91,11 +91,11 @@ export default function ItemDetail({ ), }} style={{ - width: getToken('$20') + getToken('$20') + getToken('$5'), - height: getToken('$20') + getToken('$20') + getToken('$5'), + width: getToken('$20') + getToken('$20'), + height: getToken('$20') + getToken('$20'), borderRadius: item.Type === 'MusicArtist' - ? getToken('$20') + getToken('$20') + getToken('$5') + ? getToken('$20') + getToken('$20') : getToken('$5'), }} /> diff --git a/src/components/Detail/helpers/TrackOptions.tsx b/src/components/Detail/helpers/TrackOptions.tsx new file mode 100644 index 00000000..eb7ae75c --- /dev/null +++ b/src/components/Detail/helpers/TrackOptions.tsx @@ -0,0 +1,241 @@ +import { StackParamList } from '../../types' +import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' +import { NativeStackNavigationProp } from '@react-navigation/native-stack' +import { + Circle, + getToken, + getTokens, + ListItem, + Separator, + Spacer, + Spinner, + XStack, + YGroup, + YStack, +} from 'tamagui' +import { QueuingType } from '../../../enums/queuing-type' +import { useSafeAreaFrame } from 'react-native-safe-area-context' +import IconButton from '../../Global/helpers/icon-button' +import { Text } from '../../Global/helpers/text' +import React from 'react' +import { useMutation, useQuery } from '@tanstack/react-query' +import { AddToPlaylistMutation } from '../types' +import { addToPlaylist } from '../../../api/mutations/playlists' +import { trigger } from 'react-native-haptic-feedback' +import { queryClient } from '../../../constants/query-client' +import { QueryKeys } from '../../../enums/query-keys' +import { fetchItem } from '../../../api/queries/item' +import { fetchUserPlaylists } from '../../../api/queries/playlists' +import { useJellifyContext } from '../../../providers' +import { getImageApi } from '@jellyfin/sdk/lib/utils/api' +import { useNetworkContext } from '../../../providers/Network' +import { useQueueContext } from '../../../providers/Player/queue' +import Toast from 'react-native-toast-message' +import FastImage from 'react-native-fast-image' + +interface TrackOptionsProps { + track: BaseItemDto + navigation: NativeStackNavigationProp + + /** + * Whether this is nested in the player modal + */ + isNested: boolean | undefined +} + +export default function TrackOptions({ + track, + navigation, + isNested, +}: TrackOptionsProps): React.JSX.Element { + const { api, user, library } = useJellifyContext() + + const { data: album, isSuccess: albumFetchSuccess } = useQuery({ + queryKey: [QueryKeys.Item, track.AlbumId!], + queryFn: () => fetchItem(api, track.AlbumId!), + }) + + const { useDownload, useRemoveDownload, downloadedTracks } = useNetworkContext() + + const isDownloaded = downloadedTracks?.find((t) => t.item.Id === track.Id)?.item?.Id + + const { + data: playlists, + isPending: playlistsFetchPending, + isSuccess: playlistsFetchSuccess, + } = useQuery({ + queryKey: [QueryKeys.UserPlaylists], + queryFn: () => fetchUserPlaylists(api, user, library), + }) + + const { useAddToQueue } = useQueueContext() + + const { width } = useSafeAreaFrame() + + const useAddToPlaylist = useMutation({ + mutationFn: ({ track, playlist }: AddToPlaylistMutation) => { + trigger('impactLight') + return addToPlaylist(api, user, track, playlist) + }, + onSuccess: (data, { playlist }) => { + Toast.show({ + text1: 'Added to playlist', + type: 'success', + }) + + trigger('notificationSuccess') + + queryClient.invalidateQueries({ + queryKey: [QueryKeys.UserPlaylists], + }) + + queryClient.invalidateQueries({ + queryKey: [QueryKeys.ItemTracks, playlist.Id!], + }) + }, + onError: () => { + Toast.show({ + text1: 'Unable to add', + type: 'error', + }) + + trigger('notificationError') + }, + }) + + return ( + + + {albumFetchSuccess && album ? ( + { + if (isNested) navigation.goBack() + + navigation.goBack() + + if (isNested) + navigation.navigate('Tabs', { + screen: 'Home', + params: { + screen: 'Album', + params: { + album, + }, + }, + }) + else + navigation.navigate('Album', { + album, + }) + }} + size={getToken('$12') + getToken('$10')} + /> + ) : ( + + )} + + { + useAddToQueue.mutate({ + track: track, + queuingType: QueuingType.PlayingNext, + }) + }} + size={getToken('$12') + getToken('$10')} + /> + + { + useAddToQueue.mutate({ + track: track, + }) + }} + size={getToken('$12') + getToken('$10')} + /> + + {useDownload.isPending ? ( + + + + ) : ( + { + if (isDownloaded) useRemoveDownload.mutate(track) + else useDownload.mutate(track) + }} + size={getToken('$12') + getToken('$10')} + /> + )} + + + + + {playlistsFetchPending && } + + {!playlistsFetchPending && playlistsFetchSuccess && ( + <> + + Add to Playlist + + + }> + {playlists?.map((playlist) => { + return ( + + { + useAddToPlaylist.mutate({ + track, + playlist, + }) + }} + > + + + + + + + + {playlist.Name ?? 'Untitled Playlist'} + + + {`${ + playlist.ChildCount ?? 0 + } tracks`} + + + + + ) + })} + + + )} + + ) +} diff --git a/src/components/ItemDetail/types.ts b/src/components/Detail/types.ts similarity index 100% rename from src/components/ItemDetail/types.ts rename to src/components/Detail/types.ts diff --git a/src/components/Discover/component.tsx b/src/components/Discover/component.tsx index fc5c7e29..3caf466c 100644 --- a/src/components/Discover/component.tsx +++ b/src/components/Discover/component.tsx @@ -4,7 +4,7 @@ import { ScrollView } from 'tamagui' import RecentlyAdded from './helpers/just-added' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { StackParamList } from '../types' -import { useDiscoverContext } from './provider' +import { useDiscoverContext } from '../../providers/Discover' import { RefreshControl } from 'react-native' export default function Index({ diff --git a/src/components/Discover/helpers/just-added.tsx b/src/components/Discover/helpers/just-added.tsx index 0741fda2..e10df5be 100644 --- a/src/components/Discover/helpers/just-added.tsx +++ b/src/components/Discover/helpers/just-added.tsx @@ -2,7 +2,7 @@ import { StackParamList } from '../../../components/types' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import HorizontalCardList from '../../../components/Global/components/horizontal-list' import { ItemCard } from '../../../components/Global/components/item-card' -import { useDiscoverContext } from '../provider' +import { useDiscoverContext } from '../../../providers/Discover' import { View, XStack } from 'tamagui' import { H2 } from '../../../components/Global/helpers/text' import Icon from '../../../components/Global/helpers/icon' @@ -12,15 +12,20 @@ export default function RecentlyAdded({ }: { navigation: NativeStackNavigationProp }): React.JSX.Element { - const { recentlyAdded } = useDiscoverContext() + const { recentlyAdded, fetchNextRecentlyAdded, hasNextRecentlyAdded, isPendingRecentlyAdded } = + useDiscoverContext() return ( { - navigation.navigate('Albums', { + navigation.navigate('RecentlyAdded', { albums: recentlyAdded, + navigation: navigation, + fetchNextPage: fetchNextRecentlyAdded, + hasNextPage: hasNextRecentlyAdded, + isPending: isPendingRecentlyAdded, }) }} > @@ -31,7 +36,9 @@ export default function RecentlyAdded({ 10) ? recentlyAdded!.slice(0, 10) : recentlyAdded + (recentlyAdded?.pages[0].length ?? 0 > 10) + ? recentlyAdded!.pages[0].slice(0, 10) + : recentlyAdded?.pages[0] } renderItem={({ item }) => ( void - recentlyAdded: BaseItemDto[] | undefined - recentlyPlayed: BaseItemDto[] | undefined -} - -const DiscoverContextInitializer = () => { - const { api, library } = useJellifyContext() - const [refreshing, setRefreshing] = useState(false) - - const { data: recentlyAdded, refetch } = useQuery({ - queryKey: [QueryKeys.RecentlyAdded], - queryFn: () => fetchRecentlyAdded(api, library), - }) - - const { data: recentlyPlayed, refetch: refetchRecentlyPlayed } = useQuery({ - queryKey: [QueryKeys.RecentlyPlayed], - queryFn: () => fetchRecentlyPlayed(api, library), - }) - - const refresh = async () => { - setRefreshing(true) - - await Promise.all([refetch(), refetchRecentlyPlayed()]) - setRefreshing(false) - } - - return { - refreshing, - refresh, - recentlyAdded, - recentlyPlayed, - } -} - -const DiscoverContext = createContext({ - refreshing: false, - refresh: () => {}, - recentlyAdded: undefined, - recentlyPlayed: undefined, -}) - -export const DiscoverProvider: ({ children }: { children: ReactNode }) => React.JSX.Element = ({ - children, -}: { - children: ReactNode -}) => { - const context = DiscoverContextInitializer() - - return {children} -} - -export const useDiscoverContext = () => useContext(DiscoverContext) diff --git a/src/components/Global/components/favorite-button.tsx b/src/components/Global/components/favorite-button.tsx index cc4fe5e8..d0ac143a 100644 --- a/src/components/Global/components/favorite-button.tsx +++ b/src/components/Global/components/favorite-button.tsx @@ -6,8 +6,8 @@ import { isUndefined } from 'lodash' 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' +import { useJellifyUserDataContext } from '../../../providers/UserData' +import { useJellifyContext } from '../../../providers' interface SetFavoriteMutation { item: BaseItemDto diff --git a/src/components/Global/components/favorite-icon.tsx b/src/components/Global/components/favorite-icon.tsx index 33c26d6f..24164cd1 100644 --- a/src/components/Global/components/favorite-icon.tsx +++ b/src/components/Global/components/favorite-icon.tsx @@ -5,7 +5,7 @@ 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' +import { useJellifyContext } from '../../../providers' export default function FavoriteIcon({ item }: { item: BaseItemDto }): React.JSX.Element { const [isFavorite, setIsFavorite] = useState(item.UserData?.IsFavorite ?? false) diff --git a/src/components/Global/components/image.tsx b/src/components/Global/components/image.tsx index 30ccd811..8967d796 100644 --- a/src/components/Global/components/image.tsx +++ b/src/components/Global/components/image.tsx @@ -4,7 +4,7 @@ import { isUndefined } from 'lodash' import { StyleProp } from 'react-native' import FastImage, { ImageStyle } from 'react-native-fast-image' import { FontSizeTokens, getFontSizeToken, getToken, getTokenValue, Token, useTheme } from 'tamagui' -import { useJellifyContext } from '../../provider' +import { useJellifyContext } from '../../../providers' interface ImageProps { item: BaseItemDto circular?: boolean | undefined diff --git a/src/components/Global/components/instant-mix-button.tsx b/src/components/Global/components/instant-mix-button.tsx index 70b8db99..e1503de4 100644 --- a/src/components/Global/components/instant-mix-button.tsx +++ b/src/components/Global/components/instant-mix-button.tsx @@ -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' +import { useJellifyContext } from '../../../providers' export default function InstantMixButton({ item, navigation, @@ -20,6 +20,7 @@ export default function InstantMixButton({ const { data, isFetching, refetch } = useQuery({ queryKey: [QueryKeys.InstantMix, item.Id!], queryFn: () => fetchInstantMixFromItem(api, user, item), + staleTime: 1000 * 60 * 60 * 24, // 24 hours }) const isDarkMode = useColorScheme() === 'dark' diff --git a/src/components/Global/components/item-card.tsx b/src/components/Global/components/item-card.tsx index 2b71dc4c..6d1abe01 100644 --- a/src/components/Global/components/item-card.tsx +++ b/src/components/Global/components/item-card.tsx @@ -8,7 +8,7 @@ import { getImageApi } from '@jellyfin/sdk/lib/utils/api' import { useQuery } from '@tanstack/react-query' import { QueryKeys } from '../../../enums/query-keys' import { fetchMediaInfo } from '../../../api/queries/media' -import { useJellifyContext } from '../../provider' +import { useJellifyContext } from '../../../providers' interface CardProps extends TamaguiCardProps { caption?: string | null | undefined subCaption?: string | null | undefined @@ -21,7 +21,9 @@ export function ItemCard(props: CardProps) { const mediaInfo = useQuery({ queryKey: [QueryKeys.MediaSources, props.item.Id!], - queryFn: () => fetchMediaInfo(api, user, props.item.Id!), + queryFn: () => fetchMediaInfo(api, user, props.item), + staleTime: Infinity, + enabled: props.item.Type === 'Audio', }) return ( @@ -72,7 +74,13 @@ export function ItemCard(props: CardProps) { {props.subCaption && ( - + {props.subCaption} )} diff --git a/src/components/Global/components/item.tsx b/src/components/Global/components/item.tsx index 77d19c0b..7c857d83 100644 --- a/src/components/Global/components/item.tsx +++ b/src/components/Global/components/item.tsx @@ -7,8 +7,8 @@ import { useSafeAreaFrame } from 'react-native-safe-area-context' import Icon from '../helpers/icon' import { QueuingType } from '../../../enums/queuing-type' import { RunTimeTicks } from '../helpers/time-codes' -import { useQueueContext } from '../../../player/queue-provider' -import { usePlayerContext } from '../../../player/player-provider' +import { useQueueContext } from '../../../providers/Player/queue' +import { usePlayerContext } from '../../../providers/Player' import ItemImage from './image' export default function Item({ @@ -89,7 +89,12 @@ export default function Item({ {item.Name ?? ''} {(item.Type === 'Audio' || item.Type === 'MusicAlbum') && ( - + {item.AlbumArtist ?? 'Untitled Artist'} )} diff --git a/src/components/Global/components/track.tsx b/src/components/Global/components/track.tsx index 59bb7fcd..055a487d 100644 --- a/src/components/Global/components/track.tsx +++ b/src/components/Global/components/track.tsx @@ -1,4 +1,4 @@ -import { usePlayerContext } from '../../../player/player-provider' +import { usePlayerContext } from '../../../providers/Player' import React from 'react' import { getToken, getTokens, Theme, useTheme, XStack, YStack } from 'tamagui' import { Text } from '../helpers/text' @@ -13,13 +13,13 @@ import FavoriteIcon from './favorite-icon' import FastImage from 'react-native-fast-image' import { getImageApi } from '@jellyfin/sdk/lib/utils/api' import { networkStatusTypes } from '../../../components/Network/internetConnectionWatcher' -import { useNetworkContext } from '../../../components/Network/provider' +import { useNetworkContext } from '../../../providers/Network' 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 { useQueueContext } from '../../../providers/Player/queue' import { fetchItem } from '../../../api/queries/item' -import { useJellifyContext } from '../../provider' +import { useJellifyContext } from '../../../providers' export interface TrackProps { track: BaseItemDto @@ -67,7 +67,9 @@ export default function Track({ // Fetch media info so it's available in the player const mediaInfo = useQuery({ queryKey: [QueryKeys.MediaSources, track.Id!], - queryFn: () => fetchMediaInfo(api, user, track.Id!), + queryFn: () => fetchMediaInfo(api, user, track), + staleTime: Infinity, + enabled: track.Type === 'Audio', }) // Fetch album so it's available in the Details screen diff --git a/src/components/Home/helpers/frequent-artists.tsx b/src/components/Home/helpers/frequent-artists.tsx index 255f2f25..6f54c559 100644 --- a/src/components/Home/helpers/frequent-artists.tsx +++ b/src/components/Home/helpers/frequent-artists.tsx @@ -4,36 +4,45 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack' import React from 'react' import { ItemCard } from '../../../components/Global/components/item-card' import { View, XStack } from 'tamagui' -import { H2 } from '../../../components/Global/helpers/text' +import { H2, H4, Text } from '../../../components/Global/helpers/text' import Icon from '../../../components/Global/helpers/icon' -import { useHomeContext } from '../provider' +import { useHomeContext } from '../../../providers/Home' +import { ActivityIndicator } from 'react-native' export default function FrequentArtists({ navigation, }: { navigation: NativeStackNavigationProp }): React.JSX.Element { - const { frequentArtists } = useHomeContext() + const { + frequentArtists, + fetchNextFrequentArtists, + hasNextFrequentArtists, + isFetchingFrequentArtists, + } = useHomeContext() return ( { - navigation.navigate('Artists', { + navigation.navigate('MostPlayedArtists', { artists: frequentArtists, + fetchNextPage: fetchNextFrequentArtists, + hasNextPage: hasNextFrequentArtists, + isPending: isFetchingFrequentArtists, }) }} > -

Most Played

+

Most Played

10) - ? frequentArtists!.slice(0, 10) - : frequentArtists + (frequentArtists?.pages.flatMap((page) => page).length ?? 0 > 10) + ? frequentArtists?.pages.flatMap((page) => page).slice(0, 10) + : frequentArtists?.pages.flatMap((page) => page) } renderItem={({ item: artist }) => ( )} + ListEmptyComponent={ + isFetchingFrequentArtists ? ( + + ) : ( + No frequent artists + ) + } />
) diff --git a/src/components/Home/helpers/frequent-tracks.tsx b/src/components/Home/helpers/frequent-tracks.tsx index 27bf1a1a..2a484cdc 100644 --- a/src/components/Home/helpers/frequent-tracks.tsx +++ b/src/components/Home/helpers/frequent-tracks.tsx @@ -1,22 +1,26 @@ import { StackParamList } from '../../../components/types' import { NativeStackNavigationProp } from '@react-navigation/native-stack' -import { useHomeContext } from '../provider' +import { useHomeContext } from '../../../providers/Home' import { View, XStack } from 'tamagui' import HorizontalCardList from '../../../components/Global/components/horizontal-list' import { ItemCard } from '../../../components/Global/components/item-card' import { QueuingType } from '../../../enums/queuing-type' import { trigger } from 'react-native-haptic-feedback' -import { H2 } from '../../../components/Global/helpers/text' import Icon from '../../../components/Global/helpers/icon' -import { useQueueContext } from '../../../player/queue-provider' -import { usePlayerContext } from '../../../player/player-provider' - +import { useQueueContext } from '../../../providers/Player/queue' +import { usePlayerContext } from '../../../providers/Player' +import { H4 } from '../../../components/Global/helpers/text' export default function FrequentlyPlayedTracks({ navigation, }: { navigation: NativeStackNavigationProp }): React.JSX.Element { - const { frequentlyPlayed } = useHomeContext() + const { + frequentlyPlayed, + fetchNextFrequentlyPlayed, + hasNextFrequentlyPlayed, + isFetchingFrequentlyPlayed, + } = useHomeContext() const { useStartPlayback } = usePlayerContext() const { useLoadNewQueue } = useQueueContext() @@ -26,26 +30,28 @@ export default function FrequentlyPlayedTracks({ { - navigation.navigate('Tracks', { + navigation.navigate('MostPlayedTracks', { tracks: frequentlyPlayed, - queue: 'On Repeat', + fetchNextPage: fetchNextFrequentlyPlayed, + hasNextPage: hasNextFrequentlyPlayed, + isPending: isFetchingFrequentlyPlayed, }) }} > -

On Repeat

+

On Repeat

10) - ? frequentlyPlayed!.slice(0, 10) - : frequentlyPlayed + (frequentlyPlayed?.pages.flatMap((page) => page).length ?? 0 > 10) + ? frequentlyPlayed?.pages.flatMap((page) => page).slice(0, 10) + : frequentlyPlayed?.pages.flatMap((page) => page) } renderItem={({ item: track, index }) => ( page) ?? [ + track, + ], queue: 'On Repeat', queuingType: QueuingType.FromSelection, }, diff --git a/src/components/Home/helpers/recent-artists.tsx b/src/components/Home/helpers/recent-artists.tsx index a2b047fb..d7e525a2 100644 --- a/src/components/Home/helpers/recent-artists.tsx +++ b/src/components/Home/helpers/recent-artists.tsx @@ -1,7 +1,7 @@ import React from 'react' import { View, XStack } from 'tamagui' -import { useHomeContext } from '../provider' -import { H2 } from '../../Global/helpers/text' +import { useHomeContext } from '../../../providers/Home' +import { H2, H4 } from '../../Global/helpers/text' import { StackParamList } from '../../types' import { ItemCard } from '../../Global/components/item-card' import { NativeStackNavigationProp } from '@react-navigation/native-stack' @@ -13,25 +13,31 @@ export default function RecentArtists({ }: { navigation: NativeStackNavigationProp }): React.JSX.Element { - const { recentArtists } = useHomeContext() + const { recentArtists, fetchNextRecentArtists, hasNextRecentArtists, isFetchingRecentArtists } = + useHomeContext() return ( { - navigation.navigate('Artists', { + navigation.navigate('RecentArtists', { artists: recentArtists, + fetchNextPage: fetchNextRecentArtists, + hasNextPage: hasNextRecentArtists, + isPending: isFetchingRecentArtists, }) }} > -

Recent Artists

+

Recent Artists

10) ? recentArtists!.slice(0, 10) : recentArtists + (recentArtists?.pages.flatMap((page) => page).length ?? 0 > 10) + ? recentArtists?.pages.flatMap((page) => page).slice(0, 10) + : recentArtists?.pages.flatMap((page) => page) } renderItem={({ item: recentArtist }) => ( )} /> diff --git a/src/components/Home/helpers/recently-played.tsx b/src/components/Home/helpers/recently-played.tsx index 50bc4798..654d39a1 100644 --- a/src/components/Home/helpers/recently-played.tsx +++ b/src/components/Home/helpers/recently-played.tsx @@ -1,16 +1,16 @@ import React, { useMemo } from 'react' import { View, XStack } from 'tamagui' -import { useHomeContext } from '../provider' -import { H2 } from '../../Global/helpers/text' +import { useHomeContext } from '../../../providers/Home' +import { H2, H4 } from '../../Global/helpers/text' import { ItemCard } from '../../Global/components/item-card' -import { usePlayerContext } from '../../../player/player-provider' +import { usePlayerContext } from '../../../providers/Player' import { StackParamList } from '../../../components/types' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { trigger } from 'react-native-haptic-feedback' import { QueuingType } from '../../../enums/queuing-type' import HorizontalCardList from '../../../components/Global/components/horizontal-list' import Icon from '../../../components/Global/helpers/icon' -import { useQueueContext } from '../../../player/queue-provider' +import { useQueueContext } from '../../../providers/Player/queue' export default function RecentlyPlayed({ navigation, @@ -19,7 +19,8 @@ export default function RecentlyPlayed({ }): React.JSX.Element { const { nowPlaying, useStartPlayback } = usePlayerContext() const { useLoadNewQueue } = useQueueContext() - const { recentTracks } = useHomeContext() + const { recentTracks, fetchNextRecentTracks, hasNextRecentTracks, isFetchingRecentTracks } = + useHomeContext() return useMemo(() => { return ( @@ -27,24 +28,28 @@ export default function RecentlyPlayed({ { - navigation.navigate('Tracks', { + navigation.navigate('RecentTracks', { tracks: recentTracks, - queue: 'Recently Played', + fetchNextPage: fetchNextRecentTracks, + hasNextPage: hasNextRecentTracks, + isPending: isFetchingRecentTracks, }) }} > -

Play it again

+

Play it again

10) ? recentTracks!.slice(0, 10) : recentTracks + (recentTracks?.pages.flatMap((page) => page).length ?? 0 > 10) + ? recentTracks?.pages.flatMap((page) => page).slice(0, 10) + : recentTracks?.pages.flatMap((page) => page) } renderItem={({ index, item: recentlyPlayedTrack }) => ( page) ?? [ + recentlyPlayedTrack, + ], queue: 'Recently Played', queuingType: QueuingType.FromSelection, }, diff --git a/src/components/Home/component.tsx b/src/components/Home/index.tsx similarity index 77% rename from src/components/Home/component.tsx rename to src/components/Home/index.tsx index e415e2f2..80886d32 100644 --- a/src/components/Home/component.tsx +++ b/src/components/Home/index.tsx @@ -1,15 +1,15 @@ import { StackParamList } from '../types' import { ScrollView, RefreshControl } from 'react-native' -import { YStack, XStack, Separator } from 'tamagui' +import { YStack, XStack, Separator, getToken } from 'tamagui' import RecentArtists from './helpers/recent-artists' import RecentlyPlayed from './helpers/recently-played' -import { useHomeContext } from './provider' -import { H3 } from '../Global/helpers/text' +import { useHomeContext } from '../../providers/Home' +import { H3, H4, H5 } from '../Global/helpers/text' 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' +import { useJellifyContext } from '../../providers' export function ProvidedHome({ navigation, }: { @@ -22,15 +22,16 @@ export function ProvidedHome({ return ( } removeClippedSubviews // Save memory usage > - -

{`Hi, ${user?.name ?? 'there'}`}

-
+

{`Hi, ${user?.name ?? ''}`}

- + diff --git a/src/components/Home/provider.tsx b/src/components/Home/provider.tsx deleted file mode 100644 index 24b232bd..00000000 --- a/src/components/Home/provider.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import React, { createContext, ReactNode, useContext, useState } from 'react' -import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' -import { useQuery } from '@tanstack/react-query' -import { QueryKeys } from '../../enums/query-keys' -import { fetchRecentlyPlayed, fetchRecentlyPlayedArtists } from '../../api/queries/recents' -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 - recentArtists: BaseItemDto[] | undefined - recentTracks: BaseItemDto[] | undefined - frequentArtists: BaseItemDto[] | undefined - frequentlyPlayed: BaseItemDto[] | undefined -} - -const HomeContextInitializer = () => { - const { api, library, user } = useJellifyContext() - const [refreshing, setRefreshing] = useState(false) - - const { data: recentTracks, refetch: refetchRecentTracks } = useQuery({ - queryKey: [QueryKeys.RecentlyPlayed], - queryFn: () => fetchRecentlyPlayed(api, library), - }) - const { data: recentArtists, refetch: refetchRecentArtists } = useQuery({ - queryKey: [QueryKeys.RecentlyPlayedArtists], - queryFn: () => fetchRecentlyPlayedArtists(api, library), - }) - - const { data: frequentlyPlayed, refetch: refetchFrequentlyPlayed } = useQuery({ - queryKey: [QueryKeys.FrequentlyPlayed], - queryFn: () => fetchFrequentlyPlayed(api, library), - }) - - const { data: frequentArtists, refetch: refetchFrequentArtists } = useQuery({ - queryKey: [QueryKeys.FrequentArtists], - queryFn: () => fetchFrequentlyPlayedArtists(api, library), - }) - - const onRefresh = async () => { - setRefreshing(true) - - queryClient.invalidateQueries({ - queryKey: [ - QueryKeys.RecentlyPlayedArtists, - QueryConfig.limits.recents * 4, - QueryConfig.limits.recents, - ], - }) - - queryClient.invalidateQueries({ - queryKey: [ - QueryKeys.RecentlyPlayed, - QueryConfig.limits.recents * 4, - QueryConfig.limits.recents, - ], - }) - - await Promise.all([ - refetchRecentTracks(), - refetchRecentArtists(), - refetchFrequentArtists(), - refetchFrequentlyPlayed(), - ]) - - setRefreshing(false) - } - - return { - refreshing, - onRefresh, - recentArtists, - recentTracks, - frequentArtists, - frequentlyPlayed, - } -} - -const HomeContext = createContext({ - refreshing: false, - onRefresh: () => {}, - recentArtists: undefined, - recentTracks: undefined, - frequentArtists: undefined, - frequentlyPlayed: undefined, -}) - -export const HomeProvider: ({ children }: { children: ReactNode }) => React.JSX.Element = ({ - children, -}: { - children: ReactNode -}) => { - const { - refreshing, - onRefresh, - recentTracks, - recentArtists, - frequentArtists, - frequentlyPlayed, - } = HomeContextInitializer() - - return ( - - {children} - - ) -} - -export const useHomeContext = () => useContext(HomeContext) diff --git a/src/components/Home/stack.tsx b/src/components/Home/stack.tsx deleted file mode 100644 index 4fac187b..00000000 --- a/src/components/Home/stack.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import _ from 'lodash' -import { HomeProvider } from './provider' -import { createNativeStackNavigator } from '@react-navigation/native-stack' -import { StackParamList } from '../types' -import { AlbumScreen } from '../Album' -import { PlaylistScreen } from '../Playlist/screen' -import { ProvidedHome } from './component' -import DetailsScreen from '../ItemDetail/screen' -import ArtistsScreen from '../Artists/screen' -import TracksScreen from '../Tracks/screen' -import { ArtistScreen } from '../Artist' -import InstantMix from '../InstantMix/component' -import { getToken, getTokens, useTheme } from 'tamagui' - -const Stack = createNativeStackNavigator() - -export default function Home(): React.JSX.Element { - const theme = useTheme() - - return ( - - - - - - ({ - title: route.params.artist.Name ?? 'Unknown Artist', - headerTitleStyle: { - color: theme.background.val, - }, - })} - /> - - - - { - return { - title: route.params.queue.valueOf() as string, - } - }} - /> - - ({ - title: route.params.album.Name ?? 'Untitled Album', - headerTitleStyle: { - color: theme.background.val, - }, - })} - /> - - ({ - headerShown: true, - headerTitleStyle: { - color: theme.background.val, - }, - })} - /> - - ({ - title: route.params.item.Name - ? `${route.params.item.Name} Mix` - : 'Instant Mix', - })} - /> - - - - - - - - ) -} diff --git a/src/components/ItemDetail/helpers/TrackOptions.tsx b/src/components/ItemDetail/helpers/TrackOptions.tsx index f45d79db..2c5a61ab 100644 --- a/src/components/ItemDetail/helpers/TrackOptions.tsx +++ b/src/components/ItemDetail/helpers/TrackOptions.tsx @@ -19,17 +19,17 @@ import IconButton from '../../../components/Global/helpers/icon-button' import { Text } from '../../../components/Global/helpers/text' import React from 'react' import { useMutation, useQuery } from '@tanstack/react-query' -import { AddToPlaylistMutation } from '../types' +import { AddToPlaylistMutation } from '../../../components/Detail/types' import { addToPlaylist } from '../../../api/mutations/playlists' import { trigger } from 'react-native-haptic-feedback' import { queryClient } from '../../../constants/query-client' import { QueryKeys } from '../../../enums/query-keys' import { fetchItem } from '../../../api/queries/item' import { fetchUserPlaylists } from '../../../api/queries/playlists' -import { useJellifyContext } from '../../provider' +import { useJellifyContext } from '../../../providers' import { getImageApi } from '@jellyfin/sdk/lib/utils/api' -import { useNetworkContext } from '../../../components/Network/provider' -import { useQueueContext } from '../../../player/queue-provider' +import { useNetworkContext } from '../../../providers/Network' +import { useQueueContext } from '../../../providers/Player/queue' import Toast from 'react-native-toast-message' import FastImage from 'react-native-fast-image' diff --git a/src/components/Library/component.tsx b/src/components/Library/component.tsx index 3f745895..41d1e543 100644 --- a/src/components/Library/component.tsx +++ b/src/components/Library/component.tsx @@ -1,10 +1,21 @@ -import { FlatList } from 'react-native' -import { useSafeAreaFrame } from 'react-native-safe-area-context' -import Categories from './categories' -import IconCard from '../../components/Global/helpers/icon-card' import { StackParamList } from '../../components/types' import { RouteProp } from '@react-navigation/native' import { NativeStackNavigationProp } from '@react-navigation/native-stack' +import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs' +import PlaylistsScreen from './components/playlists-tab' +import { getToken } from 'tamagui' +import { useColorScheme } from 'react-native' +import Icon from '../Global/helpers/icon' +import TracksTab from './components/tracks-tab' +import ArtistsTab from './components/artists-tab' +import AlbumsTab from './components/albums-tab' +import LibraryTabBar from './tab-bar' +import { Gesture } from 'react-native-gesture-handler' +import { GestureDetector } from 'react-native-gesture-handler' + +const panGesture = Gesture.Pan() + +const LibraryTabsNavigator = createMaterialTopTabNavigator() export default function Library({ route, @@ -13,24 +24,66 @@ export default function Library({ route: RouteProp navigation: NativeStackNavigationProp }): React.JSX.Element { - const { width } = useSafeAreaFrame() + const isDarkMode = useColorScheme() === 'dark' return ( - ( - { - navigation.navigate(item.name, item.params) + + } + screenOptions={{ + lazy: true, + tabBarShowIcon: true, + tabBarActiveTintColor: getToken('$color.telemagenta'), + tabBarInactiveTintColor: isDarkMode + ? getToken('$color.amethyst') + : getToken('$color.purpleGray'), + tabBarLabelStyle: { + fontFamily: 'Aileron-Bold', + }, + }} + > + ( + + ), }} - largeIcon /> - )} - /> + + ( + + ), + }} + initialParams={{ navigation }} + /> + + ( + + ), + }} + /> + + ( + + ), + }} + initialParams={{ navigation }} + /> + + ) } diff --git a/src/components/Library/components/add-playlist.tsx b/src/components/Library/components/add-playlist.tsx index 8ed8c8f6..ee717756 100644 --- a/src/components/Library/components/add-playlist.tsx +++ b/src/components/Library/components/add-playlist.tsx @@ -11,7 +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 { useJellifyContext } from '../../../providers' // import * as Burnt from 'burnt' export default function AddPlaylist({ diff --git a/src/components/Library/components/albums-tab.tsx b/src/components/Library/components/albums-tab.tsx new file mode 100644 index 00000000..952c6fc7 --- /dev/null +++ b/src/components/Library/components/albums-tab.tsx @@ -0,0 +1,21 @@ +import { NativeStackNavigationProp } from '@react-navigation/native-stack' +import Albums from '../../Albums/component' +import { StackParamList } from '../../types' +import { useLibraryContext } from '../../../providers/Library' +import { useNavigation } from '@react-navigation/native' + +export default function AlbumsTab(): React.JSX.Element { + const { albums, fetchNextAlbumsPage, hasNextAlbumsPage, isPendingAlbums } = useLibraryContext() + + const navigation = useNavigation>() + + return ( + + ) +} diff --git a/src/components/Library/components/artists-tab.tsx b/src/components/Library/components/artists-tab.tsx new file mode 100644 index 00000000..05e879b9 --- /dev/null +++ b/src/components/Library/components/artists-tab.tsx @@ -0,0 +1,22 @@ +import { useNavigation } from '@react-navigation/native' +import Artists from '../../Artists/component' +import { useLibraryContext } from '../../../providers/Library' +import { NativeStackNavigationProp } from '@react-navigation/native-stack' +import { StackParamList } from '../../types' + +export default function ArtistsTab(): React.JSX.Element { + const { artists, fetchNextArtistsPage, hasNextArtistsPage, isPendingArtists } = + useLibraryContext() + + const navigation = useNavigation>() + + return ( + + ) +} diff --git a/src/components/Library/components/delete-playlist.tsx b/src/components/Library/components/delete-playlist.tsx index a20427d0..02d38f92 100644 --- a/src/components/Library/components/delete-playlist.tsx +++ b/src/components/Library/components/delete-playlist.tsx @@ -8,7 +8,7 @@ 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 { useJellifyContext } from '../../../providers' // import * as Burnt from 'burnt' export default function DeletePlaylist({ diff --git a/src/components/Library/components/playlists-tab.tsx b/src/components/Library/components/playlists-tab.tsx new file mode 100644 index 00000000..d3022f51 --- /dev/null +++ b/src/components/Library/components/playlists-tab.tsx @@ -0,0 +1,10 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack' +import { StackParamList } from '../../types' +import Playlists from '../../Playlists/component' +import React from 'react' + +export default function PlaylistsScreen({ + navigation, +}: NativeStackScreenProps): React.JSX.Element { + return +} diff --git a/src/components/Library/components/tracks-tab.tsx b/src/components/Library/components/tracks-tab.tsx new file mode 100644 index 00000000..e80b3258 --- /dev/null +++ b/src/components/Library/components/tracks-tab.tsx @@ -0,0 +1,25 @@ +import { NativeStackNavigationProp } from '@react-navigation/native-stack' + +import { useNavigation } from '@react-navigation/native' +import { StackParamList } from '../../types' +import Tracks from '../../Tracks/component' +import { useLibraryContext } from '../../../providers/Library' +import { useLibrarySortAndFilterContext } from '../../../providers/Library/sorting-filtering' + +export default function TracksTab(): React.JSX.Element { + const navigation = useNavigation>() + + const { tracks, fetchNextTracksPage, hasNextTracksPage } = useLibraryContext() + + const { isFavorites } = useLibrarySortAndFilterContext() + + return ( + + ) +} diff --git a/src/components/Library/provider.tsx b/src/components/Library/provider.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/src/components/Library/stack.tsx b/src/components/Library/stack.tsx deleted file mode 100644 index 0017c666..00000000 --- a/src/components/Library/stack.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { createNativeStackNavigator } from '@react-navigation/native-stack' -import React from 'react' -import { StackParamList } from '../types' -import Library from './component' -import { AlbumScreen } from '../Album' -import { PlaylistScreen } from '../Playlist/screen' -import ArtistsScreen from '../Artists/screen' -import AlbumsScreen from '../Albums/screen' -import TracksScreen from '../Tracks/screen' -import DetailsScreen from '../ItemDetail/screen' -import PlaylistsScreen from '../Playlists/screen' -import AddPlaylist from './components/add-playlist' -import DeletePlaylist from './components/delete-playlist' -import { ArtistScreen } from '../Artist' -import InstantMix from '../InstantMix/component' -import { useTheme } from 'tamagui' -const Stack = createNativeStackNavigator() - -export default function LibraryStack(): React.JSX.Element { - const theme = useTheme() - - return ( - - - - ({ - title: route.params.artist.Name ?? 'Unknown Artist', - headerTitleStyle: { - color: theme.background.val, - }, - })} - /> - - ({})} /> - - ({ - headerShown: true, - title: route.params.album.Name ?? 'Untitled Album', - headerTitleStyle: { - color: theme.background.val, - }, - })} - /> - - - - - - - - ({ - headerShown: true, - title: route.params.playlist.Name ?? 'Untitled Playlist', - headerTitleStyle: { - color: theme.background.val, - }, - })} - /> - - ({ - title: route.params.item.Name ? `${route.params.item.Name} Mix` : 'Instant Mix', - })} - /> - - - - - - {/* https://www.reddit.com/r/reactnative/comments/1dgktbn/comment/lxd23sj/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button */} - - - - - - - ) -} diff --git a/src/components/Library/tab-bar.tsx b/src/components/Library/tab-bar.tsx new file mode 100644 index 00000000..aedfaa7a --- /dev/null +++ b/src/components/Library/tab-bar.tsx @@ -0,0 +1,113 @@ +import { MaterialTopTabBar, MaterialTopTabBarProps } from '@react-navigation/material-top-tabs' +import React, { useEffect } from 'react' +import { Button, getToken, Separator, XStack, YStack } from 'tamagui' +import Icon from '../Global/helpers/icon' +import { useLibrarySortAndFilterContext } from '../../providers/Library/sorting-filtering' +import { Text } from '../Global/helpers/text' +import { FadeIn, FadeOut } from 'react-native-reanimated' +import Animated from 'react-native-reanimated' +import { useLibraryContext } from '../../providers/Library' + +export default function LibraryTabBar(props: MaterialTopTabBarProps) { + useEffect(() => { + console.debug(`LibraryTabBar:`, props.state.routes[props.state.index].name) + }, [props]) + + const { sortDescending, setSortDescending, isFavorites, setIsFavorites } = + useLibrarySortAndFilterContext() + + return ( + + + + + + {props.state.routes[props.state.index].name === 'Playlists' ? ( + props.navigation.navigate('AddPlaylist')} + alignItems={'center'} + justifyContent={'center'} + > + + + Create Playlist + + ) : ( + setIsFavorites(!isFavorites)} + alignItems={'center'} + justifyContent={'center'} + > + + + + {isFavorites ? 'Favorites' : 'All'} + + + )} + + + + + setSortDescending(!sortDescending)} + alignItems={'center'} + justifyContent={'center'} + > + + + + {sortDescending ? 'Descending' : 'Ascending'} + + + + + ) +} diff --git a/src/components/Login/screens/server-address.tsx b/src/components/Login/screens/server-address.tsx index f417d286..1b45bc14 100644 --- a/src/components/Login/screens/server-address.tsx +++ b/src/components/Login/screens/server-address.tsx @@ -14,7 +14,7 @@ import { SafeAreaView } from 'react-native-safe-area-context' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { StackParamList } from '../../../components/types' import Toast from 'react-native-toast-message' -import { useJellifyContext } from '../../provider' +import { useJellifyContext } from '../../../providers' export default function ServerAddress({ navigation, diff --git a/src/components/Login/screens/server-authentication.tsx b/src/components/Login/screens/server-authentication.tsx index 54473724..450e34aa 100644 --- a/src/components/Login/screens/server-authentication.tsx +++ b/src/components/Login/screens/server-authentication.tsx @@ -10,7 +10,7 @@ import { JellifyUser } from '../../../types/JellifyUser' import { StackParamList } from '../../../components/types' import Input from '../../../components/Global/helpers/input' import Icon from '../../../components/Global/helpers/icon' -import { useJellifyContext } from '../../provider' +import { useJellifyContext } from '../../../providers' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import Toast from 'react-native-toast-message' diff --git a/src/components/Login/screens/server-library.tsx b/src/components/Login/screens/server-library.tsx index 3b8c820b..8afa8692 100644 --- a/src/components/Login/screens/server-library.tsx +++ b/src/components/Login/screens/server-library.tsx @@ -4,7 +4,7 @@ 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 { useJellifyContext } from '../../provider' +import { useJellifyContext } from '../../../providers' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { QueryKeys } from '../../../enums/query-keys' import { fetchUserViews } from '../../../api/queries/libraries' diff --git a/src/components/Player/helpers/buttons.tsx b/src/components/Player/helpers/buttons.tsx index 948c760c..26f4e568 100644 --- a/src/components/Player/helpers/buttons.tsx +++ b/src/components/Player/helpers/buttons.tsx @@ -1,7 +1,7 @@ import { State } from 'react-native-track-player' import { Colors } from 'react-native/Libraries/NewAppScreen' import { Circle, Spinner, View } from 'tamagui' -import { usePlayerContext } from '../../../player/player-provider' +import { usePlayerContext } from '../../../providers/Player' import IconButton from '../../../components/Global/helpers/icon-button' export default function PlayPauseButton({ diff --git a/src/components/Player/helpers/controls.tsx b/src/components/Player/helpers/controls.tsx index 7f18d7e5..f98490c0 100644 --- a/src/components/Player/helpers/controls.tsx +++ b/src/components/Player/helpers/controls.tsx @@ -2,9 +2,9 @@ import React from 'react' import { XStack, getToken } from 'tamagui' import PlayPauseButton from './buttons' import Icon from '../../../components/Global/helpers/icon' -import { usePlayerContext } from '../../../player/player-provider' +import { usePlayerContext } from '../../../providers/Player' import { useSafeAreaFrame } from 'react-native-safe-area-context' -import { useQueueContext } from '../../../player/queue-provider' +import { useQueueContext } from '../../../providers/Player/queue' export default function Controls(): React.JSX.Element { const { width } = useSafeAreaFrame() diff --git a/src/components/Player/helpers/scrubber.tsx b/src/components/Player/helpers/scrubber.tsx index e342bc0d..62539625 100644 --- a/src/components/Player/helpers/scrubber.tsx +++ b/src/components/Player/helpers/scrubber.tsx @@ -5,11 +5,11 @@ import { Gesture, GestureDetector } from 'react-native-gesture-handler' import { trigger } from 'react-native-haptic-feedback' import { getToken, XStack, YStack } from 'tamagui' import { useSafeAreaFrame } from 'react-native-safe-area-context' -import { usePlayerContext } from '../../../player/player-provider' +import { usePlayerContext } from '../../../providers/Player' import { RunTimeSeconds } from '../../../components/Global/helpers/time-codes' import { UPDATE_INTERVAL } from '../../../player/config' import { ProgressMultiplier } from '../component.config' -import { useQueueContext } from '../../../player/queue-provider' +import { useQueueContext } from '../../../providers/Player/queue' import { Platform } from 'react-native' // Create a simple pan gesture diff --git a/src/components/Player/screens/index.tsx b/src/components/Player/index.tsx similarity index 90% rename from src/components/Player/screens/index.tsx rename to src/components/Player/index.tsx index e0959456..c496b391 100644 --- a/src/components/Player/screens/index.tsx +++ b/src/components/Player/index.tsx @@ -1,24 +1,24 @@ -import { StackParamList } from '../../../components/types' -import { usePlayerContext } from '../../../player/player-provider' +import { StackParamList } from '../types' +import { usePlayerContext } from '../../providers/Player' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import React, { useCallback, useMemo, useState } from 'react' import { SafeAreaView, useSafeAreaFrame } from 'react-native-safe-area-context' import { YStack, XStack, Spacer, getTokens, getToken, useTheme } from 'tamagui' -import { Text } from '../../../components/Global/helpers/text' -import Icon from '../../../components/Global/helpers/icon' -import FavoriteButton from '../../Global/components/favorite-button' +import { Text } from '../Global/helpers/text' +import Icon from '../Global/helpers/icon' +import FavoriteButton from '../Global/components/favorite-button' import TextTicker from 'react-native-text-ticker' -import { TextTickerConfig } from '../component.config' -import Scrubber from '../helpers/scrubber' -import Controls from '../helpers/controls' +import { TextTickerConfig } from './component.config' +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 { useQueueContext } from '../../../player/queue-provider' +import { useQueueContext } from '../../providers/Player/queue' import Toast from 'react-native-toast-message' -import JellifyToastConfig from '../../../constants/toast.config' +import JellifyToastConfig from '../../constants/toast.config' import { useColorScheme } from 'react-native' import { useFocusEffect } from '@react-navigation/native' -import { useJellifyContext } from '../../provider' +import { useJellifyContext } from '../../providers' export default function PlayerScreen({ navigation, }: { @@ -143,6 +143,7 @@ export default function PlayerScreen({ { diff --git a/src/components/Player/mini-player.tsx b/src/components/Player/mini-player.tsx index cd14403c..4c585df1 100644 --- a/src/components/Player/mini-player.tsx +++ b/src/components/Player/mini-player.tsx @@ -1,6 +1,6 @@ import React from 'react' import { getToken, getTokens, Image, useTheme, View, XStack, YStack } from 'tamagui' -import { usePlayerContext } from '../../player/player-provider' +import { usePlayerContext } from '../../providers/Player' import { BottomTabNavigationEventMap } from '@react-navigation/bottom-tabs' import { NavigationHelpers, ParamListBase } from '@react-navigation/native' import Icon from '../Global/helpers/icon' @@ -10,8 +10,8 @@ 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 { useQueueContext } from '../../player/queue-provider' -import { useJellifyContext } from '../provider' +import { useQueueContext } from '../../providers/Player/queue' +import { useJellifyContext } from '../../providers' export function Miniplayer({ navigation, }: { @@ -68,7 +68,7 @@ export function Miniplayer({ - + {nowPlaying?.artist ?? ''} diff --git a/src/components/Player/screens/queue.tsx b/src/components/Player/queue.tsx similarity index 90% rename from src/components/Player/screens/queue.tsx rename to src/components/Player/queue.tsx index 710dc421..e935469b 100644 --- a/src/components/Player/screens/queue.tsx +++ b/src/components/Player/queue.tsx @@ -1,12 +1,12 @@ -import Icon from '../../../components/Global/helpers/icon' -import Track from '../../../components/Global/components/track' -import { StackParamList } from '../../../components/types' -import { usePlayerContext } from '../../../player/player-provider' +import Icon from '../Global/helpers/icon' +import Track from '../Global/components/track' +import { StackParamList } from '../types' +import { usePlayerContext } from '../../providers/Player' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { useSafeAreaFrame } from 'react-native-safe-area-context' import DraggableFlatList from 'react-native-draggable-flatlist' import { Separator, XStack, YStack } from 'tamagui' -import { useQueueContext } from '../../../player/queue-provider' +import { useQueueContext } from '../../providers/Player/queue' import Animated from 'react-native-reanimated' import { Gesture, GestureDetector } from 'react-native-gesture-handler' import { useState } from 'react' diff --git a/src/components/Playlist/components/header.tsx b/src/components/Playlist/components/header.tsx index bc5f506c..12b5a4b4 100644 --- a/src/components/Playlist/components/header.tsx +++ b/src/components/Playlist/components/header.tsx @@ -6,11 +6,11 @@ import { getToken, getTokens, Separator, View, XStack, YStack } from 'tamagui' import { AnimatedH5 } from '../../Global/helpers/text' import InstantMixButton from '../../Global/components/instant-mix-button' import Icon from '../../Global/helpers/icon' -import { usePlaylistContext } from '../provider' +import { usePlaylistContext } from '../../../providers/Playlist' import Animated, { useAnimatedStyle, withSpring } from 'react-native-reanimated' import FastImage from 'react-native-fast-image' import { getImageApi } from '@jellyfin/sdk/lib/utils/api' -import { useJellifyContext } from '../../provider' +import { useJellifyContext } from '../../../providers' import { ImageType } from '@jellyfin/sdk/lib/generated-client/models' export default function PlayliistTracklistHeader( @@ -92,6 +92,7 @@ export default function PlayliistTracklistHeader( height: '100%', padding: getToken('$2'), alignSelf: 'center', + borderRadius: getToken('$2'), }} /> diff --git a/src/components/Playlist/index.tsx b/src/components/Playlist/index.tsx index 9de672ac..513c8643 100644 --- a/src/components/Playlist/index.tsx +++ b/src/components/Playlist/index.tsx @@ -5,7 +5,7 @@ import { trigger } from 'react-native-haptic-feedback' import { RefreshControl } from 'react-native' import { PlaylistProps } from './interfaces' import PlayliistTracklistHeader from './components/header' -import { usePlaylistContext } from './provider' +import { usePlaylistContext } from '../../providers/Playlist' import { useAnimatedScrollHandler } from 'react-native-reanimated' import AnimatedDraggableFlatList from '../Global/components/animated-draggable-flat-list' export default function Playlist({ playlist, navigation }: PlaylistProps): React.JSX.Element { @@ -89,6 +89,7 @@ export default function Playlist({ playlist, navigation }: PlaylistProps): React marginHorizontal: 2, }} onScroll={scrollOffsetHandler} + removeClippedSubviews /> ) } diff --git a/src/components/Playlists/component.tsx b/src/components/Playlists/component.tsx index bb5f384a..a09863c2 100644 --- a/src/components/Playlists/component.tsx +++ b/src/components/Playlists/component.tsx @@ -1,16 +1,20 @@ import { FlatList, RefreshControl } from 'react-native-gesture-handler' import { useSafeAreaFrame } from 'react-native-safe-area-context' import { ItemCard } from '../Global/components/item-card' -import { FavoritePlaylistsProps } from '../types' import Icon from '../Global/helpers/icon' import { getToken } from 'tamagui' import { fetchFavoritePlaylists } from '../../api/queries/favorites' import { QueryKeys } from '../../enums/query-keys' import { useQuery } from '@tanstack/react-query' -import { useJellifyContext } from '../provider' -export default function FavoritePlaylists({ +import { useJellifyContext } from '../../providers' +import { NativeStackNavigationProp } from '@react-navigation/native-stack' +import { StackParamList } from '../types' + +export default function Playlists({ navigation, -}: FavoritePlaylistsProps): React.JSX.Element { +}: { + navigation: NativeStackNavigationProp +}): React.JSX.Element { const { api, user, library } = useJellifyContext() navigation.setOptions({ headerRight: () => { @@ -54,6 +58,7 @@ export default function FavoritePlaylists({ squared /> )} + removeClippedSubviews /> ) } diff --git a/src/components/Playlists/screen.tsx b/src/components/Playlists/screen.tsx deleted file mode 100644 index b0e216c0..00000000 --- a/src/components/Playlists/screen.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { NativeStackScreenProps } from '@react-navigation/native-stack' -import { StackParamList } from '../types' -import FavoritePlaylists from './component' -import React from 'react' - -export default function PlaylistsScreen( - props: NativeStackScreenProps, -): React.JSX.Element { - return -} diff --git a/src/components/Search/component.tsx b/src/components/Search/index.tsx similarity index 98% rename from src/components/Search/component.tsx rename to src/components/Search/index.tsx index 99676101..cb323656 100644 --- a/src/components/Search/component.tsx +++ b/src/components/Search/index.tsx @@ -14,7 +14,7 @@ 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' +import { useJellifyContext } from '../../providers' export default function Search({ navigation, }: { diff --git a/src/components/Search/screen.tsx b/src/components/Search/screen.tsx deleted file mode 100644 index 30b9cd5c..00000000 --- a/src/components/Search/screen.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { RouteProp } from '@react-navigation/native' -import { StackParamList } from '../types' -import { NativeStackNavigationProp } from '@react-navigation/native-stack' -import React from 'react' -import Search from './component' - -export default function SearchScreen({ - route, - navigation, -}: { - route: RouteProp - navigation: NativeStackNavigationProp -}): React.JSX.Element { - return -} diff --git a/src/components/Settings/helpers/account-details.tsx b/src/components/Settings/account-details.tsx similarity index 63% rename from src/components/Settings/helpers/account-details.tsx rename to src/components/Settings/account-details.tsx index c8320687..21921104 100644 --- a/src/components/Settings/helpers/account-details.tsx +++ b/src/components/Settings/account-details.tsx @@ -1,8 +1,8 @@ import { XStack } from '@tamagui/stacks' import React from 'react' -import { Text } from '../../../components/Global/helpers/text' -import Icon from '../../../components/Global/helpers/icon' -import { useJellifyContext } from '../../provider' +import { Text } from '../../components/Global/helpers/text' +import Icon from '../../components/Global/helpers/icon' +import { useJellifyContext } from '../../providers' export default function AccountDetails(): React.JSX.Element { const { user } = useJellifyContext() diff --git a/src/components/Settings/component.tsx b/src/components/Settings/component.tsx index 838dd008..eab40697 100644 --- a/src/components/Settings/component.tsx +++ b/src/components/Settings/component.tsx @@ -1,11 +1,11 @@ import React from 'react' -import SignOut from './helpers/sign-out' +import SignOut from '../../components/Settings/sign-out' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { StackParamList } from '../types' import { useSafeAreaFrame } from 'react-native-safe-area-context' import { FlatList } from 'react-native' import IconCard from '../Global/helpers/icon-card' -import Categories from './categories' +import Categories from '../../components/Settings/categories' import StorageBar from '../Storage' export default function Root({ diff --git a/src/components/Settings/helpers/dev-tools.tsx b/src/components/Settings/dev-tools.tsx similarity index 100% rename from src/components/Settings/helpers/dev-tools.tsx rename to src/components/Settings/dev-tools.tsx diff --git a/src/components/Settings/helpers/library-details.tsx b/src/components/Settings/library-details.tsx similarity index 73% rename from src/components/Settings/helpers/library-details.tsx rename to src/components/Settings/library-details.tsx index b5810997..ec5d731c 100644 --- a/src/components/Settings/helpers/library-details.tsx +++ b/src/components/Settings/library-details.tsx @@ -1,7 +1,7 @@ -import { Text } from '../../../components/Global/helpers/text' +import { Text } from '../../components/Global/helpers/text' import React from 'react' import { View } from 'tamagui' -import { useJellifyContext } from '../../provider' +import { useJellifyContext } from '../../providers' export default function LibraryDetails(): React.JSX.Element { const { library } = useJellifyContext() diff --git a/src/components/Settings/helpers/sign-out.tsx b/src/components/Settings/sign-out.tsx similarity index 88% rename from src/components/Settings/helpers/sign-out.tsx rename to src/components/Settings/sign-out.tsx index f22a0e12..30dcbd5f 100644 --- a/src/components/Settings/helpers/sign-out.tsx +++ b/src/components/Settings/sign-out.tsx @@ -1,7 +1,7 @@ import React from 'react' -import Button from '../../Global/helpers/button' +import Button from '../Global/helpers/button' import TrackPlayer from 'react-native-track-player' -import { StackParamList } from '../../../components/types' +import { StackParamList } from '../types' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { useNavigation } from '@react-navigation/native' diff --git a/src/components/Storage/index.tsx b/src/components/Storage/index.tsx index 8e19a4f4..479f9fa8 100644 --- a/src/components/Storage/index.tsx +++ b/src/components/Storage/index.tsx @@ -2,8 +2,8 @@ import React, { useEffect, useState } from 'react' import { StyleSheet, Pressable, Alert, FlatList } from 'react-native' import RNFS from 'react-native-fs' import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated' -import { deleteAudioCache } from '../Network/offlineModeUtils' -import { useNetworkContext } from '../Network/provider' +import { deleteAudioCache } from '../../components/Network/offlineModeUtils' +import { useNetworkContext } from '../../providers/Network' import Icon from '../Global/helpers/icon' import { getToken, View } from 'tamagui' import { Text } from '../Global/helpers/text' diff --git a/src/components/Tracks/component.tsx b/src/components/Tracks/component.tsx new file mode 100644 index 00000000..8cb3d752 --- /dev/null +++ b/src/components/Tracks/component.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import Track from '../Global/components/track' +import { FlatList } from 'react-native' +import { getTokens, Separator } from 'tamagui' +import { StackParamList } from '../types' +import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' +import { NativeStackNavigationProp } from '@react-navigation/native-stack' +import { Queue } from '../../player/types/queue-item' +import { InfiniteData } from '@tanstack/react-query' + +export default function Tracks({ + tracks, + queue, + fetchNextPage, + hasNextPage, + navigation, +}: { + tracks: InfiniteData | undefined + queue: Queue + fetchNextPage: () => void + hasNextPage: boolean + navigation: NativeStackNavigationProp +}): React.JSX.Element { + return ( + } + numColumns={1} + data={tracks?.pages.flatMap((page) => page) ?? []} + renderItem={({ index, item: track }) => ( + page).slice(index, index + 50) : [] + } + queue={queue} + /> + )} + removeClippedSubviews + onEndReached={() => { + if (hasNextPage) fetchNextPage() + }} + onEndReachedThreshold={0.25} + /> + ) +} diff --git a/src/components/Tracks/screen.tsx b/src/components/Tracks/screen.tsx deleted file mode 100644 index 96e12527..00000000 --- a/src/components/Tracks/screen.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { TracksProps } from '../types' -import React from 'react' -import Track from '../Global/components/track' -import { FlatList } from 'react-native' -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(api, user, library), - }) - - return ( - } - numColumns={1} - data={route.params.tracks ? route.params.tracks : favoriteTracks ? favoriteTracks : []} - renderItem={({ index, item: track }) => ( - - )} - /> - ) -} diff --git a/src/components/jellify.tsx b/src/components/jellify.tsx index 4512ec00..6ae8143c 100644 --- a/src/components/jellify.tsx +++ b/src/components/jellify.tsx @@ -1,25 +1,24 @@ import _ from 'lodash' import React from 'react' import Navigation from './navigation' -import Login from './Login/component' -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 { PlayerProvider } from '../providers/Player' +import { JellifyProvider, useJellifyContext } from '../providers' +import { JellifyUserDataProvider } from '../providers/UserData' +import { NetworkContextProvider } from '../providers/Network' +import { QueueProvider } from '../providers/Player/queue' +import { DisplayProvider } from '../providers/Display/display-provider' /** * The main component for the Jellify app. Children are wrapped in the {@link JellifyProvider} * @returns The {@link Jellify} component */ export default function Jellify(): React.JSX.Element { - const isDarkMode = useColorScheme() === 'dark' - return ( - - - + + + + + ) } /** diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx index 5df1a308..0a9a5fd7 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -1,10 +1,10 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack' -import Player from './Player/stack' +import Player from '../screens/Player' import { Tabs } from './tabs' import { StackParamList } from './types' import { useTheme } from 'tamagui' -import { useJellifyContext } from './provider' -import Login from './Login/component' +import { useJellifyContext } from '../providers' +import Login from '../screens/Login' const RootStack = createNativeStackNavigator() export default function Navigation(): React.JSX.Element { diff --git a/src/components/tabs.tsx b/src/components/tabs.tsx index a3c63633..831c0647 100644 --- a/src/components/tabs.tsx +++ b/src/components/tabs.tsx @@ -1,14 +1,14 @@ import React from 'react' import { BottomTabBar, createBottomTabNavigator } from '@react-navigation/bottom-tabs' -import Home from './Home/stack' +import Home from '../screens/Home' import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons' -import Settings from './Settings/stack' -import { Discover } from './Discover/stack' +import SettingsScreen from '../screens/Settings' +import { Discover } from '../screens/Discover' import { Miniplayer } from './Player/mini-player' import { getToken, getTokens, Separator } from 'tamagui' -import { usePlayerContext } from '../player/player-provider' -import SearchStack from './Search/stack' -import LibraryStack from './Library/stack' +import { usePlayerContext } from '../providers/Player' +import SearchStack from '../screens/Search' +import LibraryStack from '../screens/Library' import { useColorScheme } from 'react-native' import InternetConnectionWatcher from './Network/internetConnectionWatcher' @@ -22,12 +22,14 @@ export function Tabs(): React.JSX.Element { ( <> @@ -66,7 +68,7 @@ export function Tabs(): React.JSX.Element { headerShown: false, tabBarIcon: ({ color, size }) => ( @@ -91,18 +93,14 @@ export function Tabs(): React.JSX.Element { options={{ headerShown: false, tabBarIcon: ({ color, size }) => ( - + ), }} /> ( diff --git a/src/components/types.d.ts b/src/components/types.d.ts index 6544c4f4..911e7009 100644 --- a/src/components/types.d.ts +++ b/src/components/types.d.ts @@ -2,7 +2,8 @@ import { QueryKeys } from '../enums/query-keys' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { NativeStackNavigationProp, NativeStackScreenProps } from '@react-navigation/native-stack' import { Queue } from '../player/types/queue-item' - +import { MaterialTopTabBarProps } from '@react-navigation/material-top-tabs' +import { InfiniteData } from '@tanstack/react-query' export type StackParamList = { Login: { screen: keyof StackParamList @@ -15,30 +16,52 @@ export type StackParamList = { Home: undefined AddPlaylist: undefined RecentArtists: { - artists: BaseItemDto[] + artists: InfiniteData | undefined + fetchNextPage: () => void + hasNextPage: boolean + isPending: boolean + } + MostPlayedArtists: { + artists: InfiniteData | undefined + fetchNextPage: () => void + hasNextPage: boolean + isPending: boolean } RecentTracks: { - tracks: BaseItemDto[] + tracks: InfiniteData | undefined + fetchNextPage: () => void + hasNextPage: boolean + isPending: boolean + } + MostPlayedTracks: { + tracks: InfiniteData | undefined + fetchNextPage: () => void + hasNextPage: boolean + isPending: boolean } UserPlaylists: { playlists: BaseItemDto[] } + Tracks: { + tracks: InfiniteData | undefined + queue: Queue + fetchNextPage: () => void + hasNextPage: boolean + isPending: boolean + } + Discover: undefined + RecentlyAdded: { + albums: InfiniteData | undefined + navigation: NativeStackNavigationProp + fetchNextPage: () => void + hasNextPage: boolean + isPending: boolean + } Library: undefined - Artists: { - artists: BaseItemDto[] | undefined - } - Albums: { - albums: BaseItemDto[] | undefined - } - Tracks: { - tracks: BaseItemDto[] | undefined - queue: Queue - } - Genres: undefined - Playlists: undefined + DeletePlaylist: { playlist: BaseItemDto } @@ -103,10 +126,12 @@ export type ProvidedHomeProps = NativeStackScreenProps export type AddPlaylistProps = NativeStackScreenProps export type RecentArtistsProps = NativeStackScreenProps export type RecentTracksProps = NativeStackScreenProps +export type MostPlayedArtistsProps = NativeStackScreenProps +export type MostPlayedTracksProps = NativeStackScreenProps export type UserPlaylistsProps = NativeStackScreenProps export type DiscoverProps = NativeStackScreenProps - +export type RecentlyAddedProps = NativeStackScreenProps export type HomeArtistProps = NativeStackScreenProps export type ArtistAlbumsProps = NativeStackScreenProps export type ArtistEpsProps = NativeStackScreenProps @@ -119,18 +144,38 @@ export type HomePlaylistProps = NativeStackScreenProps export type LibraryProps = NativeStackScreenProps - -export type ArtistsProps = NativeStackScreenProps - -export type AlbumsProps = NativeStackScreenProps - -export type FavoritePlaylistsProps = NativeStackScreenProps -export type DeletePlaylistProps = NativeStackScreenProps - export type TracksProps = NativeStackScreenProps -export type GenresProps = NativeStackScreenProps +export type ArtistsProps = { + artists: InfiniteData | undefined + navigation: NativeStackNavigationProp + fetchNextPage: () => void + hasNextPage: boolean + isPending: boolean +} +export type AlbumsProps = { + albums: InfiniteData | undefined + navigation: NativeStackNavigationProp + fetchNextPage: () => void + hasNextPage: boolean + isPending: boolean +} +export type GenresProps = { + genres: InfiniteData | undefined + navigation: NativeStackNavigationProp + fetchNextPage: () => void + hasNextPage: boolean + isPending: boolean +} +export type PlaylistsProps = { + playlists: InfiniteData | undefined + navigation: NativeStackNavigationProp + fetchNextPage: () => void + hasNextPage: boolean + isPending: boolean +} +export type DeletePlaylistProps = NativeStackScreenProps export type DetailsProps = NativeStackScreenProps export type AccountDetailsProps = NativeStackScreenProps diff --git a/src/constants/query-client.ts b/src/constants/query-client.ts index f6287028..6da9961e 100644 --- a/src/constants/query-client.ts +++ b/src/constants/query-client.ts @@ -16,9 +16,14 @@ export const queryClient = new QueryClient({ queries: { /** * Infinity, this needs to be greater than - * or higher than the `maxAge` + * or higher than the `maxAge` set in App.tsx */ gcTime: Infinity, + + /** + * 2 hours as a default. + */ + staleTime: 1000 * 60 * 60 * 2, // 2 hours }, }, }) diff --git a/src/enums/mmkv-storage-keys.ts b/src/enums/mmkv-storage-keys.ts index 5b778dac..dd758a06 100644 --- a/src/enums/mmkv-storage-keys.ts +++ b/src/enums/mmkv-storage-keys.ts @@ -10,4 +10,6 @@ export enum MMKVStorageKeys { Queue = 'Queue', CurrentIndex = 'CurrentIndex', Api = 'Api', + LibrarySortDescending = 'LibrarySortDescending', + LibraryIsFavorites = 'LibraryIsFavorites', } diff --git a/src/enums/query-keys.ts b/src/enums/query-keys.ts index c43d4604..cf74930a 100644 --- a/src/enums/query-keys.ts +++ b/src/enums/query-keys.ts @@ -75,4 +75,7 @@ export enum QueryKeys { FrequentlyPlayed = 'FrequentlyPlayed', InstantMix = 'InstantMix', ArtistFeaturedOn = 'ArtistFeaturedOn', + AllArtists = 'AllArtists', + AllTracks = 'AllTracks', + AllAlbums = 'AllAlbums', } diff --git a/src/player/config.ts b/src/player/config.ts index 6584c0e8..e2d335b0 100644 --- a/src/player/config.ts +++ b/src/player/config.ts @@ -3,7 +3,7 @@ * Lower value provides smoother scrubber movement but uses more resources * 60ms is approximately 16-17fps, which is a good balance of smoothness and performance */ -export const UPDATE_INTERVAL: number = 60 +export const UPDATE_INTERVAL: number = 100 /** * Indicates the seconds the progress position must be diff --git a/src/player/types/queue-item.ts b/src/player/types/queue-item.ts index 11d932b8..8f895c3a 100644 --- a/src/player/types/queue-item.ts +++ b/src/player/types/queue-item.ts @@ -7,3 +7,4 @@ export type Queue = | 'Favorite Tracks' | 'On Repeat' | 'Instant Mix' + | 'Library' diff --git a/src/components/Artist/provider.tsx b/src/providers/Artist/index.tsx similarity index 98% rename from src/components/Artist/provider.tsx rename to src/providers/Artist/index.tsx index d0e6c8c0..0dada313 100644 --- a/src/components/Artist/provider.tsx +++ b/src/providers/Artist/index.tsx @@ -4,7 +4,7 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' 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' +import { useJellifyContext } from '..' import { fetchArtistAlbums, fetchArtistFeaturedOn } from '../../api/queries/artist' interface ArtistContext { diff --git a/src/providers/Discover/index.tsx b/src/providers/Discover/index.tsx new file mode 100644 index 00000000..5fe42eda --- /dev/null +++ b/src/providers/Discover/index.tsx @@ -0,0 +1,94 @@ +import { InfiniteData, useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { fetchRecentlyAdded, fetchRecentlyPlayed } from '../../api/queries/recents' +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 '..' +interface DiscoverContext { + refreshing: boolean + refresh: () => void + recentlyAdded: InfiniteData | undefined + recentlyPlayed: InfiniteData | undefined + fetchNextRecentlyAdded: () => void + fetchNextRecentlyPlayed: () => void + hasNextRecentlyAdded: boolean + hasNextRecentlyPlayed: boolean + isPendingRecentlyAdded: boolean + isPendingRecentlyPlayed: boolean +} + +const DiscoverContextInitializer = () => { + const { api, library, user } = useJellifyContext() + const [refreshing, setRefreshing] = useState(false) + + const { + data: recentlyAdded, + refetch: refetchRecentlyAdded, + fetchNextPage: fetchNextRecentlyAdded, + hasNextPage: hasNextRecentlyAdded, + isPending: isPendingRecentlyAdded, + } = useInfiniteQuery({ + queryKey: [QueryKeys.RecentlyAdded], + queryFn: ({ pageParam }) => fetchRecentlyAdded(api, library, pageParam), + getNextPageParam: (lastPage, pages) => (lastPage.length > 0 ? pages.length + 1 : undefined), + initialPageParam: 0, + }) + + const { + data: recentlyPlayed, + refetch: refetchRecentlyPlayed, + fetchNextPage: fetchNextRecentlyPlayed, + hasNextPage: hasNextRecentlyPlayed, + isPending: isPendingRecentlyPlayed, + } = useInfiniteQuery({ + queryKey: [QueryKeys.RecentlyPlayed], + queryFn: ({ pageParam }) => fetchRecentlyPlayed(api, user, library, pageParam), + getNextPageParam: (lastPage, pages) => (lastPage.length > 0 ? pages.length + 1 : undefined), + initialPageParam: 0, + }) + + const refresh = async () => { + setRefreshing(true) + + await Promise.all([refetchRecentlyAdded(), refetchRecentlyPlayed()]) + setRefreshing(false) + } + + return { + refreshing, + refresh, + recentlyAdded, + recentlyPlayed, + fetchNextRecentlyAdded, + fetchNextRecentlyPlayed, + hasNextRecentlyAdded, + hasNextRecentlyPlayed, + isPendingRecentlyAdded, + isPendingRecentlyPlayed, + } +} + +const DiscoverContext = createContext({ + refreshing: false, + refresh: () => {}, + recentlyAdded: undefined, + recentlyPlayed: undefined, + fetchNextRecentlyAdded: () => {}, + fetchNextRecentlyPlayed: () => {}, + hasNextRecentlyAdded: false, + hasNextRecentlyPlayed: false, + isPendingRecentlyAdded: false, + isPendingRecentlyPlayed: false, +}) + +export const DiscoverProvider: ({ children }: { children: ReactNode }) => React.JSX.Element = ({ + children, +}: { + children: ReactNode +}) => { + const context = DiscoverContextInitializer() + + return {children} +} + +export const useDiscoverContext = () => useContext(DiscoverContext) diff --git a/src/providers/Display/display-provider.tsx b/src/providers/Display/display-provider.tsx new file mode 100644 index 00000000..29e63b8b --- /dev/null +++ b/src/providers/Display/display-provider.tsx @@ -0,0 +1,41 @@ +import { createContext, useContext, useState } from 'react' +import { useSafeAreaFrame } from 'react-native-safe-area-context' +import { getTokens } from 'tamagui' + +interface DisplayContext { + numberOfColumns: number + + display: 'grid' | 'list' + + setDisplay: React.Dispatch> +} + +const DisplayContextInitializer = () => { + const { width } = useSafeAreaFrame() + + const [numberOfColumns, setNumberOfColumns] = useState( + Math.floor(width / getTokens().size.$11.val), + ) + + const [display, setDisplay] = useState<'grid' | 'list'>('grid') + + return { + numberOfColumns, + display, + setDisplay, + } +} + +const DisplayContext = createContext({ + numberOfColumns: 0, + display: 'grid', + setDisplay: () => {}, +}) + +export const DisplayProvider = ({ children }: { children: React.ReactNode }) => { + const context = DisplayContextInitializer() + + return {children} +} + +export const useDisplayContext = () => useContext(DisplayContext) diff --git a/src/providers/Home/index.tsx b/src/providers/Home/index.tsx new file mode 100644 index 00000000..b0215cb1 --- /dev/null +++ b/src/providers/Home/index.tsx @@ -0,0 +1,192 @@ +import React, { createContext, ReactNode, useContext, useState } from 'react' +import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' +import { InfiniteData, useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { QueryKeys } from '../../enums/query-keys' +import { fetchRecentlyPlayed, fetchRecentlyPlayedArtists } from '../../api/queries/recents' +import { queryClient } from '../../constants/query-client' +import QueryConfig from '../../api/queries/query.config' +import { fetchFrequentlyPlayed, fetchFrequentlyPlayedArtists } from '../../api/queries/frequents' +import { useJellifyContext } from '..' +interface HomeContext { + refreshing: boolean + onRefresh: () => void + recentArtists: InfiniteData | undefined + recentTracks: InfiniteData | undefined + + fetchNextRecentTracks: () => void + hasNextRecentTracks: boolean + + fetchNextRecentArtists: () => void + hasNextRecentArtists: boolean + + fetchNextFrequentArtists: () => void + hasNextFrequentArtists: boolean + + fetchNextFrequentlyPlayed: () => void + hasNextFrequentlyPlayed: boolean + + frequentArtists: InfiniteData | undefined + frequentlyPlayed: InfiniteData | undefined + + isFetchingRecentTracks: boolean + isFetchingRecentArtists: boolean + isFetchingFrequentArtists: boolean + isFetchingFrequentlyPlayed: boolean +} + +const HomeContextInitializer = () => { + const { api, library, user } = useJellifyContext() + const [refreshing, setRefreshing] = useState(false) + + const { + data: recentTracks, + isFetching: isFetchingRecentTracks, + refetch: refetchRecentTracks, + isError: isErrorRecentTracks, + fetchNextPage: fetchNextRecentTracks, + hasNextPage: hasNextRecentTracks, + isPending: isPendingRecentTracks, + } = useInfiniteQuery({ + queryKey: [QueryKeys.RecentlyPlayed], + queryFn: ({ pageParam }) => fetchRecentlyPlayed(api, user, library, pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { + console.debug('Getting next page for recent tracks') + return lastPage.length === QueryConfig.limits.recents ? lastPageParam + 1 : undefined + }, + }) + const { + data: recentArtists, + isFetching: isFetchingRecentArtists, + refetch: refetchRecentArtists, + fetchNextPage: fetchNextRecentArtists, + hasNextPage: hasNextRecentArtists, + isPending: isPendingRecentArtists, + } = useInfiniteQuery({ + queryKey: [QueryKeys.RecentlyPlayedArtists], + queryFn: ({ pageParam }) => fetchRecentlyPlayedArtists(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { + console.debug('Getting next page for recent artists') + return lastPage.length > 0 ? lastPageParam + 1 : undefined + }, + enabled: !isErrorRecentTracks && recentTracks && recentTracks.pages.length > 0, + }) + + const { + data: frequentlyPlayed, + isFetching: isFetchingFrequentlyPlayed, + refetch: refetchFrequentlyPlayed, + fetchNextPage: fetchNextFrequentlyPlayed, + hasNextPage: hasNextFrequentlyPlayed, + isPending: isPendingFrequentlyPlayed, + } = useInfiniteQuery({ + queryKey: [QueryKeys.FrequentlyPlayed], + queryFn: ({ pageParam }) => fetchFrequentlyPlayed(api, library, pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { + console.debug('Getting next page for frequently played') + return lastPage.length === QueryConfig.limits.recents ? lastPageParam + 1 : undefined + }, + }) + + const { + data: frequentArtists, + isFetching: isFetchingFrequentArtists, + refetch: refetchFrequentArtists, + fetchNextPage: fetchNextFrequentArtists, + hasNextPage: hasNextFrequentArtists, + isPending: isPendingFrequentArtists, + } = useInfiniteQuery({ + queryKey: [QueryKeys.FrequentArtists], + queryFn: ({ pageParam }) => fetchFrequentlyPlayedArtists(api, library, pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { + console.debug('Getting next page for frequent artists') + return lastPage.length === 100 ? lastPageParam + 1 : undefined + }, + }) + + const onRefresh = async () => { + setRefreshing(true) + + queryClient.invalidateQueries({ + queryKey: [ + QueryKeys.RecentlyPlayedArtists, + QueryConfig.limits.recents * 4, + QueryConfig.limits.recents, + ], + }) + + queryClient.invalidateQueries({ + queryKey: [ + QueryKeys.RecentlyPlayed, + QueryConfig.limits.recents * 4, + QueryConfig.limits.recents, + ], + }) + + await Promise.all([ + refetchRecentTracks(), + refetchRecentArtists(), + refetchFrequentArtists(), + refetchFrequentlyPlayed(), + ]) + + setRefreshing(false) + } + + return { + refreshing, + onRefresh, + recentArtists, + recentTracks, + frequentArtists, + frequentlyPlayed, + fetchNextRecentTracks, + hasNextRecentTracks, + fetchNextRecentArtists, + hasNextRecentArtists, + fetchNextFrequentArtists, + hasNextFrequentArtists, + fetchNextFrequentlyPlayed, + hasNextFrequentlyPlayed, + isFetchingRecentTracks, + isFetchingRecentArtists, + isFetchingFrequentArtists, + isFetchingFrequentlyPlayed, + } +} + +const HomeContext = createContext({ + refreshing: false, + onRefresh: () => {}, + recentArtists: undefined, + recentTracks: undefined, + frequentArtists: undefined, + frequentlyPlayed: undefined, + fetchNextRecentTracks: () => {}, + hasNextRecentTracks: false, + fetchNextFrequentArtists: () => {}, + hasNextFrequentArtists: false, + fetchNextFrequentlyPlayed: () => {}, + hasNextFrequentlyPlayed: false, + fetchNextRecentArtists: () => {}, + hasNextRecentArtists: false, + isFetchingRecentTracks: false, + isFetchingRecentArtists: false, + isFetchingFrequentArtists: false, + isFetchingFrequentlyPlayed: false, +}) + +export const HomeProvider: ({ children }: { children: ReactNode }) => React.JSX.Element = ({ + children, +}: { + children: ReactNode +}) => { + const context = HomeContextInitializer() + + return {children} +} + +export const useHomeContext = () => useContext(HomeContext) diff --git a/src/providers/Library/index.tsx b/src/providers/Library/index.tsx new file mode 100644 index 00000000..361233ac --- /dev/null +++ b/src/providers/Library/index.tsx @@ -0,0 +1,164 @@ +import { QueryKeys } from '../../enums/query-keys' +import { BaseItemDto, ItemSortBy, SortOrder } from '@jellyfin/sdk/lib/generated-client/models' +import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query' +import { useJellifyContext } from '..' +import { fetchArtists } from '../../api/queries/artist' +import { createContext, useContext, useState } from 'react' +import { useDisplayContext } from '../Display/display-provider' +import QueryConfig from '../../api/queries/query.config' +import { fetchTracks } from '../../api/queries/tracks' +import { fetchAlbums } from '../../api/queries/album' +import { useLibrarySortAndFilterContext } from './sorting-filtering' + +interface LibraryContext { + artists: InfiniteData | undefined + albums: InfiniteData | undefined + tracks: InfiniteData | undefined + // genres: BaseItemDto[] | undefined + // playlists: BaseItemDto[] | undefined + + refetchArtists: () => void + refetchAlbums: () => void + refetchTracks: () => void + // refetchGenres: () => void + // refetchPlaylists: () => void + + fetchNextArtistsPage: () => void + hasNextArtistsPage: boolean + + fetchNextTracksPage: () => void + hasNextTracksPage: boolean + + fetchNextAlbumsPage: () => void + hasNextAlbumsPage: boolean + + isPendingArtists: boolean + isPendingTracks: boolean + isPendingAlbums: boolean +} + +const LibraryContextInitializer = () => { + const { api, user, library } = useJellifyContext() + + const { numberOfColumns } = useDisplayContext() + + const { sortDescending, isFavorites } = useLibrarySortAndFilterContext() + + const { + data: artists, + isPending: isPendingArtists, + refetch: refetchArtists, + fetchNextPage: fetchNextArtistsPage, + hasNextPage: hasNextArtistsPage, + } = useInfiniteQuery({ + queryKey: [QueryKeys.AllArtists, isFavorites, sortDescending], + queryFn: ({ pageParam }) => + fetchArtists( + api, + library, + pageParam, + isFavorites, + [ItemSortBy.SortName], + [sortDescending ? SortOrder.Descending : SortOrder.Ascending], + ), + initialPageParam: 0, + getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { + console.debug(`Artists last page length: ${lastPage.length}`) + return lastPage.length === QueryConfig.limits.library ? lastPageParam + 1 : undefined + }, + }) + + const { + data: tracks, + isPending: isPendingTracks, + refetch: refetchTracks, + fetchNextPage: fetchNextTracksPage, + hasNextPage: hasNextTracksPage, + } = useInfiniteQuery({ + queryKey: [QueryKeys.AllTracks, isFavorites, sortDescending], + queryFn: ({ pageParam }) => + fetchTracks( + api, + library, + pageParam, + isFavorites, + ItemSortBy.SortName, + sortDescending ? SortOrder.Descending : SortOrder.Ascending, + ), + initialPageParam: 0, + getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { + console.debug(`Tracks last page length: ${lastPage.length}`) + return lastPage.length === QueryConfig.limits.library * 2 + ? lastPageParam + 1 + : undefined + }, + }) + + const { + data: albums, + isPending: isPendingAlbums, + refetch: refetchAlbums, + fetchNextPage: fetchNextAlbumsPage, + hasNextPage: hasNextAlbumsPage, + } = useInfiniteQuery({ + queryKey: [QueryKeys.AllAlbums, isFavorites, sortDescending], + queryFn: ({ pageParam }) => + fetchAlbums( + api, + library, + pageParam, + isFavorites, + [ItemSortBy.SortName], + [sortDescending ? SortOrder.Descending : SortOrder.Ascending], + ), + initialPageParam: 0, + getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { + console.debug(`Albums last page length: ${lastPage.length}`) + return lastPage.length === QueryConfig.limits.library ? lastPageParam + 1 : undefined + }, + }) + + return { + artists, + refetchArtists, + fetchNextArtistsPage, + hasNextArtistsPage, + tracks, + refetchTracks, + fetchNextTracksPage, + hasNextTracksPage, + albums, + refetchAlbums, + fetchNextAlbumsPage, + hasNextAlbumsPage, + isPendingArtists, + isPendingTracks, + isPendingAlbums, + } +} + +const LibraryContext = createContext({ + artists: undefined, + refetchArtists: () => {}, + fetchNextArtistsPage: () => {}, + hasNextArtistsPage: false, + tracks: undefined, + refetchTracks: () => {}, + fetchNextTracksPage: () => {}, + hasNextTracksPage: false, + albums: undefined, + refetchAlbums: () => {}, + fetchNextAlbumsPage: () => {}, + hasNextAlbumsPage: false, + isPendingArtists: false, + isPendingTracks: false, + isPendingAlbums: false, +}) + +export const LibraryProvider = ({ children }: { children: React.ReactNode }) => { + const context = LibraryContextInitializer() + + return {children} +} + +export const useLibraryContext = () => useContext(LibraryContext) diff --git a/src/providers/Library/sorting-filtering.tsx b/src/providers/Library/sorting-filtering.tsx new file mode 100644 index 00000000..58d16042 --- /dev/null +++ b/src/providers/Library/sorting-filtering.tsx @@ -0,0 +1,49 @@ +import { storage } from '../../constants/storage' +import { MMKVStorageKeys } from '../../enums/mmkv-storage-keys' +import { useContext, useEffect, useState } from 'react' +import { createContext } from 'react' + +interface LibrarySortAndFilterContext { + sortDescending: boolean + setSortDescending: (sortDescending: boolean) => void + isFavorites: boolean + setIsFavorites: (isFavorites: boolean) => void +} + +const LibrarySortAndFilterContextInitializer = () => { + const sortDescendingInit = storage.getBoolean(MMKVStorageKeys.LibrarySortDescending) + const isFavoritesInit = storage.getBoolean(MMKVStorageKeys.LibraryIsFavorites) + + const [sortDescending, setSortDescending] = useState(sortDescendingInit ?? false) + const [isFavorites, setIsFavorites] = useState(isFavoritesInit ?? false) + + useEffect(() => { + storage.set(MMKVStorageKeys.LibrarySortDescending, sortDescending) + storage.set(MMKVStorageKeys.LibraryIsFavorites, isFavorites) + }, [sortDescending, isFavorites]) + + return { + sortDescending, + setSortDescending, + isFavorites, + setIsFavorites, + } +} +const LibrarySortAndFilterContext = createContext({ + sortDescending: false, + setSortDescending: () => {}, + isFavorites: false, + setIsFavorites: () => {}, +}) + +export const LibrarySortAndFilterProvider = ({ children }: { children: React.ReactNode }) => { + const context = LibrarySortAndFilterContextInitializer() + + return ( + + {children} + + ) +} + +export const useLibrarySortAndFilterContext = () => useContext(LibrarySortAndFilterContext) diff --git a/src/components/Network/provider.tsx b/src/providers/Network/index.tsx similarity index 93% rename from src/components/Network/provider.tsx rename to src/providers/Network/index.tsx index 8ee88d35..e1d48f42 100644 --- a/src/components/Network/provider.tsx +++ b/src/providers/Network/index.tsx @@ -3,11 +3,11 @@ import { JellifyDownload } from '../../types/JellifyDownload' import { useMutation, UseMutationResult, useQuery, useQueryClient } from '@tanstack/react-query' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { mapDtoToTrack } from '../../helpers/mappings' -import { deleteAudio, getAudioCache, saveAudio } from './offlineModeUtils' +import { deleteAudio, getAudioCache, saveAudio } from '../../components/Network/offlineModeUtils' import { QueryKeys } from '../../enums/query-keys' -import { networkStatusTypes } from './internetConnectionWatcher' +import { networkStatusTypes } from '../../components/Network/internetConnectionWatcher' import DownloadProgress from '../../types/DownloadProgress' -import { useJellifyContext } from '../provider' +import { useJellifyContext } from '..' import { isUndefined } from 'lodash' interface NetworkContext { useDownload: UseMutationResult diff --git a/src/player/player-provider.tsx b/src/providers/Player/index.tsx similarity index 83% rename from src/player/player-provider.tsx rename to src/providers/Player/index.tsx index f2452d3b..f7a1ac1d 100644 --- a/src/player/player-provider.tsx +++ b/src/providers/Player/index.tsx @@ -1,7 +1,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 { JellifyTrack } from '../../types/JellifyTrack' +import { storage } from '../../constants/storage' +import { MMKVStorageKeys } from '../../enums/mmkv-storage-keys' import TrackPlayer, { Event, Progress, @@ -9,17 +9,16 @@ import TrackPlayer, { usePlaybackState, useTrackPlayerEvents, } from 'react-native-track-player' -import { handlePlaybackProgress, handlePlaybackState } from './handlers' +import { handlePlaybackProgress, handlePlaybackState } from '../../player/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 { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api' -import { useNetworkContext } from '../components/Network/provider' -import { useQueueContext } from './queue-provider' +import { useNetworkContext } from '../Network' +import { useQueueContext } from './queue' import { PlaystateApi } from '@jellyfin/sdk/lib/generated-client/api/playstate-api' -import { networkStatusTypes } from '../components/Network/internetConnectionWatcher' -import { useJellifyContext } from '../components/provider' +import { networkStatusTypes } from '../../components/Network/internetConnectionWatcher' +import { useJellifyContext } from '..' import { isUndefined } from 'lodash' interface PlayerContext { @@ -79,17 +78,18 @@ const PlayerContextInitializer = () => { * A mutation to handle starting playback */ const useStartPlayback = useMutation({ - mutationFn: play, + mutationFn: TrackPlayer.play, }) /** * A mutation to handle toggling the playback state */ const useTogglePlayback = useMutation({ - mutationFn: () => { + mutationFn: async () => { trigger('impactMedium') - if (playbackState === State.Playing) return pause() - else return play() + if ((await TrackPlayer.getPlaybackState()).state === State.Playing) + return TrackPlayer.pause() + else return TrackPlayer.play() }, }) @@ -99,7 +99,7 @@ const PlayerContextInitializer = () => { const useSeekTo = useMutation({ mutationFn: async (position: number) => { trigger('impactLight') - await seekTo(position) + await TrackPlayer.seekTo(position) }, }) @@ -110,7 +110,7 @@ const PlayerContextInitializer = () => { mutationFn: async (seekSeconds: number) => { trigger('clockTick') - await seekBy(seekSeconds) + await TrackPlayer.seekBy(seekSeconds) }, }) @@ -140,32 +140,28 @@ const PlayerContextInitializer = () => { * 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.PlaybackState: { - usePlaybackStateChanged.mutate(event.state) - break - } - case Event.PlaybackProgressUpdated: { - usePlaybackProgressUpdated.mutate(event) - - // Cache playing track at 20 seconds if it's not already downloaded - if ( - Math.floor(event.position) === 20 && - downloadedTracks?.filter( - (download) => download.item.Id === nowPlaying!.item.Id, - ).length === 0 && - networkStatus === networkStatusTypes.ONLINE - ) - useDownload.mutate(nowPlaying!.item) - - break - } + useTrackPlayerEvents([Event.PlaybackProgressUpdated, Event.PlaybackState], (event) => { + switch (event.type) { + case Event.PlaybackState: { + usePlaybackStateChanged.mutate(event.state) + break } - }, - ) + case Event.PlaybackProgressUpdated: { + usePlaybackProgressUpdated.mutate(event) + + // Cache playing track at 20 seconds if it's not already downloaded + if ( + Math.floor(event.position) === 20 && + downloadedTracks?.filter((download) => download.item.Id === nowPlaying!.item.Id) + .length === 0 && + [networkStatusTypes.ONLINE, undefined].includes(networkStatus) + ) + useDownload.mutate(nowPlaying!.item) + + break + } + } + }) //#endregion RNTP Setup diff --git a/src/player/queue-provider.tsx b/src/providers/Player/queue.tsx similarity index 92% rename from src/player/queue-provider.tsx rename to src/providers/Player/queue.tsx index b2f1f513..5c69685a 100644 --- a/src/player/queue-provider.tsx +++ b/src/providers/Player/queue.tsx @@ -1,27 +1,27 @@ import React, { ReactNode, useContext, useEffect, useState } from 'react' import { createContext } from 'react' -import { Queue } from './types/queue-item' -import { Section } from '../components/Player/types' +import { Queue } from '../../player/types/queue-item' +import { Section } from '../../components/Player/types' import { useMutation, UseMutationResult } from '@tanstack/react-query' -import { AddToQueueMutation, QueueMutation, QueueOrderMutation } from './interfaces' -import { storage } from '../constants/storage' -import { MMKVStorageKeys } from '../enums/mmkv-storage-keys' -import { JellifyTrack } from '../types/JellifyTrack' +import { AddToQueueMutation, QueueMutation, QueueOrderMutation } from '../../player/interfaces' +import { storage } from '../../constants/storage' +import { MMKVStorageKeys } from '../../enums/mmkv-storage-keys' +import { JellifyTrack } from '../../types/JellifyTrack' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' -import { mapDtoToTrack } from '../helpers/mappings' -import { useNetworkContext } from '../components/Network/provider' -import { QueuingType } from '../enums/queuing-type' +import { mapDtoToTrack } from '../../helpers/mappings' +import { useNetworkContext } from '../Network' +import { QueuingType } from '../../enums/queuing-type' import TrackPlayer, { Event, useTrackPlayerEvents } from 'react-native-track-player' -import { findPlayQueueIndexStart } from './helpers' +import { findPlayQueueIndexStart } from '../../player/helpers' import { getQueue, play, seekTo } from 'react-native-track-player/lib/src/trackPlayer' import { trigger } from 'react-native-haptic-feedback' -import { markItemPlayed } from '../api/mutations/item' -import { filterTracksOnNetworkStatus } from './helpers/queue' -import { SKIP_TO_PREVIOUS_THRESHOLD } from './config' +import { markItemPlayed } from '../../api/mutations/item' +import { filterTracksOnNetworkStatus } from '../../player/helpers/queue' +import { SKIP_TO_PREVIOUS_THRESHOLD } from '../../player/config' import { isUndefined } from 'lodash' import Toast from 'react-native-toast-message' -import { useJellifyContext } from '../components/provider' +import { useJellifyContext } from '..' /** * @description The context for managing the queue @@ -357,7 +357,10 @@ const QueueContextInitailizer = () => { * Store play queue in storage when it changes */ useEffect(() => { - storage.set(MMKVStorageKeys.PlayQueue, JSON.stringify(playQueue)) + if (playQueue.length > 0) { + console.debug(`Storing play queue of ${playQueue.length} tracks`) + storage.set(MMKVStorageKeys.PlayQueue, JSON.stringify(playQueue)) + } }, [playQueue]) /** @@ -371,7 +374,7 @@ const QueueContextInitailizer = () => { * Store current index in storage when it changes */ useEffect(() => { - if (currentIndex !== -1) { + if (typeof currentIndex === 'number' && currentIndex !== -1) { console.debug(`Storing current index ${currentIndex}`) storage.set(MMKVStorageKeys.CurrentIndex, currentIndex) } diff --git a/src/components/Playlist/provider.tsx b/src/providers/Playlist/index.tsx similarity index 97% rename from src/components/Playlist/provider.tsx rename to src/providers/Playlist/index.tsx index cfa1b644..01dd4722 100644 --- a/src/components/Playlist/provider.tsx +++ b/src/providers/Playlist/index.tsx @@ -1,12 +1,12 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { useMutation, UseMutationResult, useQuery } from '@tanstack/react-query' import { QueryKeys } from '../../enums/query-keys' -import { useJellifyContext } from '../provider' +import { useJellifyContext } from '..' import { createContext, ReactNode, useContext, useEffect, useState } from 'react' import { getItemsApi } from '@jellyfin/sdk/lib/utils/api' import { trigger } from 'react-native-haptic-feedback' import { removeFromPlaylist, updatePlaylist } from '../../api/mutations/playlists' -import { RemoveFromPlaylistMutation } from './interfaces' +import { RemoveFromPlaylistMutation } from '../../components/Playlist/interfaces' import { SharedValue, useSharedValue } from 'react-native-reanimated' interface PlaylistContext { diff --git a/src/components/user-data-provider.tsx b/src/providers/UserData/index.tsx similarity index 94% rename from src/components/user-data-provider.tsx rename to src/providers/UserData/index.tsx index 53edb909..c9738896 100644 --- a/src/components/user-data-provider.tsx +++ b/src/providers/UserData/index.tsx @@ -4,10 +4,10 @@ import { useMutation } from '@tanstack/react-query' import { createContext, ReactNode, SetStateAction, useContext } from 'react' import { trigger } from 'react-native-haptic-feedback' -import { queryClient } from '../constants/query-client' -import { QueryKeys } from '../enums/query-keys' +import { queryClient } from '../../constants/query-client' +import { QueryKeys } from '../../enums/query-keys' import Toast from 'react-native-toast-message' -import { useJellifyContext } from './provider' +import { useJellifyContext } from '..' interface SetFavoriteMutation { item: BaseItemDto diff --git a/src/components/provider.tsx b/src/providers/index.tsx similarity index 98% rename from src/components/provider.tsx rename to src/providers/index.tsx index 69c5a912..eb15ee17 100644 --- a/src/components/provider.tsx +++ b/src/providers/index.tsx @@ -1,7 +1,7 @@ import { isUndefined } from 'lodash' import { createContext, ReactNode, SetStateAction, useContext, useEffect, useState } from 'react' import { CarPlay } from 'react-native-carplay' -import CarPlayNavigation from './CarPlay/Navigation' +import CarPlayNavigation from '../components/CarPlay/Navigation' import { Platform } from 'react-native' import { JellifyLibrary } from '../types/JellifyLibrary' import { JellifyServer } from '../types/JellifyServer' diff --git a/src/screens/Artist/index.tsx b/src/screens/Artist/index.tsx new file mode 100644 index 00000000..762dbfc5 --- /dev/null +++ b/src/screens/Artist/index.tsx @@ -0,0 +1,21 @@ +import { RouteProp } from '@react-navigation/native' +import { NativeStackNavigationProp } from '@react-navigation/native-stack' +import { StackParamList } from '../../components/types' +import { ArtistProvider } from '../../providers/Artist' +import ArtistNavigation from '../../components/Artist' + +export function ArtistScreen({ + route, + navigation, +}: { + route: RouteProp + navigation: NativeStackNavigationProp +}): React.JSX.Element { + const { artist } = route.params + + return ( + + + + ) +} diff --git a/src/components/ItemDetail/screen.tsx b/src/screens/Detail/index.tsx similarity index 89% rename from src/components/ItemDetail/screen.tsx rename to src/screens/Detail/index.tsx index eafd9cb8..e0119113 100644 --- a/src/components/ItemDetail/screen.tsx +++ b/src/screens/Detail/index.tsx @@ -1,4 +1,4 @@ -import ItemDetail from '../../components/ItemDetail/component' +import ItemDetail from '../../components/Detail/component' import { StackParamList } from '../../components/types' import { RouteProp } from '@react-navigation/native' import { NativeStackNavigationProp } from '@react-navigation/native-stack' diff --git a/src/screens/Discover/albums.tsx b/src/screens/Discover/albums.tsx new file mode 100644 index 00000000..8ecbe56a --- /dev/null +++ b/src/screens/Discover/albums.tsx @@ -0,0 +1,17 @@ +import Albums from '../../components/Albums/component' +import { RecentlyAddedProps } from '../../components/types' + +export default function RecentlyAdded({ + route, + navigation, +}: RecentlyAddedProps): React.JSX.Element { + return ( + + ) +} diff --git a/src/components/Discover/stack.tsx b/src/screens/Discover/index.tsx similarity index 78% rename from src/components/Discover/stack.tsx rename to src/screens/Discover/index.tsx index 4579cb36..4102e371 100644 --- a/src/components/Discover/stack.tsx +++ b/src/screens/Discover/index.tsx @@ -1,14 +1,13 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack' -import { StackParamList } from '../types' -import Index from './component' -import DetailsScreen from '../ItemDetail/screen' -import Player from '../Player/stack' -import Albums from '../Albums/component' -import { AlbumScreen } from '../Album' +import { StackParamList } from '../../components/types' +import Index from '../../components/Discover/component' +import DetailsScreen from '../Detail' +import { AlbumScreen } from '../../components/Album' import { ArtistScreen } from '../Artist' -import { DiscoverProvider } from './provider' -import InstantMix from '../InstantMix/component' +import { DiscoverProvider } from '../../providers/Discover' +import InstantMix from '../../components/InstantMix/component' import { useTheme } from 'tamagui' +import RecentlyAdded from './albums' export const DiscoverStack = createNativeStackNavigator() @@ -51,7 +50,7 @@ export function Discover(): React.JSX.Element { })} /> - + + ) + } + + return ( + + ) +} diff --git a/src/screens/Home/index.tsx b/src/screens/Home/index.tsx new file mode 100644 index 00000000..99a5258d --- /dev/null +++ b/src/screens/Home/index.tsx @@ -0,0 +1,110 @@ +import _ from 'lodash' +import { HomeProvider } from '../../providers/Home' +import { createNativeStackNavigator } from '@react-navigation/native-stack' +import { StackParamList } from '../../components/types' +import { AlbumScreen } from '../../components/Album' +import { PlaylistScreen } from '../Playlist' +import { ProvidedHome } from '../../components/Home' +import DetailsScreen from '../Detail' +import { ArtistScreen } from '../Artist' +import InstantMix from '../../components/InstantMix/component' +import { useTheme } from 'tamagui' +import TracksScreen from '../Tracks' +import HomeArtistsScreen from './artists' +import HomeTracksScreen from './tracks' + +const HomeStack = createNativeStackNavigator() + +/** + * The main screen for the home tab. + * @returns The {@link Home} component + */ +export default function Home(): React.JSX.Element { + const theme = useTheme() + + return ( + + + + + + ({ + title: route.params.artist.Name ?? 'Unknown Artist', + headerTitleStyle: { + color: theme.background.val, + }, + })} + /> + + + + + + + + + ({ + title: route.params.album.Name ?? 'Untitled Album', + headerTitleStyle: { + color: theme.background.val, + }, + })} + /> + + ({ + headerShown: true, + headerTitleStyle: { + color: theme.background.val, + }, + })} + /> + + ({ + title: route.params.item.Name + ? `${route.params.item.Name} Mix` + : 'Instant Mix', + })} + /> + + + + + + + + ) +} diff --git a/src/screens/Home/tracks.tsx b/src/screens/Home/tracks.tsx new file mode 100644 index 00000000..3381b823 --- /dev/null +++ b/src/screens/Home/tracks.tsx @@ -0,0 +1,39 @@ +import Tracks from '../../components/Tracks/component' +import { MostPlayedTracksProps, RecentTracksProps } from '../../components/types' +import { useHomeContext } from '../../providers/Home' + +export default function HomeTracksScreen({ + navigation, + route, +}: RecentTracksProps | MostPlayedTracksProps): React.JSX.Element { + const { + recentTracks, + frequentlyPlayed, + fetchNextRecentTracks, + hasNextRecentTracks, + fetchNextFrequentlyPlayed, + hasNextFrequentlyPlayed, + } = useHomeContext() + + if (route.name === 'MostPlayedTracks') { + return ( + + ) + } + + return ( + + ) +} diff --git a/src/screens/Library/index.tsx b/src/screens/Library/index.tsx new file mode 100644 index 00000000..59865540 --- /dev/null +++ b/src/screens/Library/index.tsx @@ -0,0 +1,124 @@ +import { createNativeStackNavigator } from '@react-navigation/native-stack' +import React from 'react' +import { StackParamList } from '../../components/types' +import Library from '../../components/Library/component' +import { AlbumScreen } from '../../components/Album' +import { PlaylistScreen } from '../Playlist' +import DetailsScreen from '../Detail' +import AddPlaylist from '../../components/Library/components/add-playlist' +import DeletePlaylist from '../../components/Library/components/delete-playlist' +import { ArtistScreen } from '../Artist' +import InstantMix from '../../components/InstantMix/component' +import { useTheme } from 'tamagui' +import { LibraryProvider } from '../../providers/Library' +import { useJellifyContext } from '../../providers' +import { LibrarySortAndFilterProvider } from '../../providers/Library/sorting-filtering' + +const Stack = createNativeStackNavigator() + +export default function LibraryStack(): React.JSX.Element { + const theme = useTheme() + + const { library, server } = useJellifyContext() + + return ( + + + + + + ({ + title: route.params.artist.Name ?? 'Unknown Artist', + headerTitleStyle: { + color: theme.background.val, + }, + })} + /> + + ({ + headerShown: true, + title: route.params.album.Name ?? 'Untitled Album', + headerTitleStyle: { + color: theme.background.val, + }, + })} + /> + + ({ + headerShown: true, + title: route.params.playlist.Name ?? 'Untitled Playlist', + headerTitleStyle: { + color: theme.background.val, + }, + })} + /> + + ({ + title: route.params.item.Name + ? `${route.params.item.Name} Mix` + : 'Instant Mix', + })} + /> + + + + + + {/* https://www.reddit.com/r/reactnative/comments/1dgktbn/comment/lxd23sj/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button */} + + + + + + + + + ) +} diff --git a/src/components/Login/component.tsx b/src/screens/Login/index.tsx similarity index 77% rename from src/components/Login/component.tsx rename to src/screens/Login/index.tsx index 22141942..91204648 100644 --- a/src/components/Login/component.tsx +++ b/src/screens/Login/index.tsx @@ -1,9 +1,9 @@ import _, { isUndefined } from 'lodash' -import ServerAuthentication from './screens/server-authentication' -import ServerAddress from './screens/server-address' +import ServerAuthentication from '../../components/Login/screens/server-authentication' +import ServerAddress from '../../components/Login/screens/server-address' import { createStackNavigator } from '@react-navigation/stack' -import ServerLibrary from './screens/server-library' -import { useJellifyContext } from '../provider' +import ServerLibrary from '../../components/Login/screens/server-library' +import { useJellifyContext } from '../../providers' const LoginStack = createStackNavigator() diff --git a/src/components/Player/stack.tsx b/src/screens/Player/index.tsx similarity index 78% rename from src/components/Player/stack.tsx rename to src/screens/Player/index.tsx index 5e79ba6b..bc262598 100644 --- a/src/components/Player/stack.tsx +++ b/src/screens/Player/index.tsx @@ -1,9 +1,9 @@ import React from 'react' import { createNativeStackNavigator } from '@react-navigation/native-stack' -import { StackParamList } from '../types' -import PlayerScreen from './screens' -import Queue from './screens/queue' -import DetailsScreen from '../ItemDetail/screen' +import { StackParamList } from '../../components/types' +import PlayerScreen from '../../components/Player' +import Queue from '../../components/Player/queue' +import DetailsScreen from '../Detail' export const PlayerStack = createNativeStackNavigator() diff --git a/src/components/Playlist/screen.tsx b/src/screens/Playlist/index.tsx similarity index 74% rename from src/components/Playlist/screen.tsx rename to src/screens/Playlist/index.tsx index 69e745e2..89665f3d 100644 --- a/src/components/Playlist/screen.tsx +++ b/src/screens/Playlist/index.tsx @@ -1,9 +1,9 @@ -import { StackParamList } from '../types' +import { StackParamList } from '../../components/types' import { RouteProp } from '@react-navigation/native' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import React from 'react' -import Playlist from './index' -import { PlaylistProvider } from './provider' +import Playlist from '../../components/Playlist/index' +import { PlaylistProvider } from '../../providers/Playlist' export function PlaylistScreen({ route, diff --git a/src/components/Search/stack.tsx b/src/screens/Search/index.tsx similarity index 83% rename from src/components/Search/stack.tsx rename to src/screens/Search/index.tsx index 8d7f3ffe..0f20efc1 100644 --- a/src/components/Search/stack.tsx +++ b/src/screens/Search/index.tsx @@ -1,12 +1,12 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack' -import SearchScreen from './screen' -import { StackParamList } from '../types' +import { StackParamList } from '../../components/types' import { ArtistScreen } from '../Artist' -import { AlbumScreen } from '../Album' -import { PlaylistScreen } from '../Playlist/screen' -import DetailsScreen from '../ItemDetail/screen' -import InstantMix from '../InstantMix/component' +import { AlbumScreen } from '../../components/Album' +import { PlaylistScreen } from '../Playlist' +import DetailsScreen from '../Detail' +import InstantMix from '../../components/InstantMix/component' import { useTheme } from 'tamagui' +import Search from '../../components/Search' const Stack = createNativeStackNavigator() @@ -17,7 +17,7 @@ export default function SearchStack(): React.JSX.Element { () -export default function Settings(): React.JSX.Element { +export default function SettingsScreen(): React.JSX.Element { return ( + ) +}