feat: Discover CarPlay Template (#1159)

* feat: Discover CarPlay Template

* fix quick actions maestro

* make me a recording in maestro plz tho

* update docs, maestro quick action flows

* update favoriting maestro flow

* maestro quick action flow fixes

* add recently added albums to discover on carplay

* search flow reset stuff

* adjust gd search maestro subflow

* we can't test the player after we've signed out lol

* run player flow eariler in full flow

* Update queue.yaml
This commit is contained in:
Violet Caulfield
2026-05-02 07:57:43 -05:00
committed by GitHub
parent 9c077aee36
commit d78438f8a8
28 changed files with 431 additions and 168 deletions
+92 -31
View File
@@ -1,22 +1,27 @@
# Contributing
We are open to any developer that wants to lend their hand at _Jellify_ development, and developers can join our [Discord server](https://discord.gg/jellify) to get in contact with us.
## Table of Contents
Here's the best way to get started:
- [Getting Started](#getting-started)
- [Running Locally](#running-locally)
- [Project Structure](#project-structure)
- [Code Style](#code-style)
- [Testing](#testing)
- [Submitting a Pull Request](#submitting-a-pull-request)
- Fork this repository
- Follow the instructions for [Running Locally](#running-locally)
- Check out the [issues](https://github.com/Jellify-Music/App/issues) if you need inspiration
- Hack, hack, hack
- ???
- Submit a Pull Request to sync the main repository with your fork
- Profit! 🎉
## Getting Started
1. Fork this repository
2. Follow the instructions for [Running Locally](#running-locally)
3. Check out the [issues](https://github.com/Jellify-Music/App/issues) if you need inspiration
4. Hack, hack, hack
5. Submit a Pull Request to sync the main repository with your fork
## Running Locally
### Universal Dependencies
- [NodeJS v22](https://nodejs.org/en/download) for React Native
- [Node.js v22](https://nodejs.org/en/download)
- [Bun](https://bun.sh/) for managing dependencies
### 🍎 iOS
@@ -25,28 +30,26 @@ Here's the best way to get started:
- [Xcode](https://developer.apple.com/xcode/) for building
#### Instructions
##### Setup
#### Setup
- Clone this repository
- Run `bun init-ios` to initialize the project
- This will install `npm` packages, install `bundler` and required gems, and install required CocoaPods with [React Native's New Architecture](https://reactnative.dev/blog/2024/10/23/the-new-architecture-is-here#what-is-the-new-architecture)
##### Running
#### Running
- Run `bun start` to start the dev server
- Open the `Jellify.xcodeworkspace` with Xcode, _not_ the `Jellify.xcodeproject`
- Run `bun start` to start the Metro dev server
- Open `Jellify.xcworkspace` with Xcode, _not_ `Jellify.xcodeproj`
- Run in the simulator
- _You will need to wait for Xcode to finish it's "Indexing" step_
- _You will need to wait for Xcode to finish its "Indexing" step before the build completes_
- To run on device, you will need access to the *Signing* Repository
- Setup a GitHub Personal Access Token and export it to a variable "MATCH_REPO_PAT"
- To run on a physical device, you will need access to the _Signing_ repository
- Create a GitHub Personal Access Token and export it as `MATCH_REPO_PAT`
- Run `bun fastlane:ios:match` to fetch the signing keys and certificates
##### Building
#### Building
- To create a build, run `bun fastlane:ios:build` to use fastlane to compile an `.ipa`
- Run `bun fastlane:ios:build` to use Fastlane to compile an `.ipa`
### 🤖 Android
@@ -55,27 +58,85 @@ Here's the best way to get started:
- [Android Studio](https://developer.android.com/studio)
- [Java Development Kit](https://www.oracle.com/th/java/technologies/downloads/)
#### Instructions
##### Setup
#### Setup
- Clone this repository
- Run `bun install` to install `npm` packages
##### Running
#### Running
- Run `bun start` to start the dev server
- Run `bun start` to start the Metro dev server
- Open the `android` folder with Android Studio
- _Android Studio should automatically grab the "Run Configurations" and initialize Gradle_
- Run either on a device or in the simulator
- _Android Studio should automatically detect the run configurations and initialize Gradle_
- Run on a device or in the emulator
##### Building
#### Building
- To create a build, run `bun fastlane:android:build` to use fastlane to compile an `.apk` for all architectures
- Alternatively, run `cd android; ./gradlew assembleRelease` to use Gradle to compile an `.apk`
- Run `bun fastlane:android:build` to use Fastlane to compile an `.apk` for all architectures
- Alternatively, run `cd android && ./gradlew assembleRelease` to use Gradle directly
#### References
- [Setting up Android SDK](https://developer.android.com/about/versions/14/setup-sdk)
- [ANDROID_HOME not being set](https://stackoverflow.com/questions/26356359/error-android-home-is-not-set-and-android-command-not-in-your-path-you-must/54888107#54888107)
- [Android Auto app not showing up](https://www.reddit.com/r/AndroidAuto/s/LGYHoSPdXm)
## Project Structure
```
src/
api/ # Jellyfin API calls and helpers
components/ # Shared UI components
configs/ # App configuration (Tamagui, etc.)
constants/ # App-wide constants
enums/ # TypeScript enums
hooks/ # Custom React hooks
providers/ # React context providers
screens/ # Top-level screen components
services/ # Background services (e.g. player)
stores/ # State management stores
types/ # TypeScript types and interfaces
utils/ # Utility / helper functions
jest/
contextual/ # Component and integration tests
functional/ # Unit tests for utilities and logic
setup/ # Jest setup and mock files
maestro/
flows/ # E2E UI test flows grouped by feature area
subflows/ # Reusable flows shared across multiple stacks
```
## Code Style
The project uses ESLint, Prettier, and TypeScript for code quality. Before submitting a PR, please ensure your changes pass all checks:
```sh
bun lint # Check for ESLint errors
bun format:check # Check Prettier formatting
bun format # Auto-fix Prettier formatting
bun tsc # Type-check with TypeScript
```
Notable style rules enforced by ESLint:
- No semicolons
- `@typescript-eslint/no-explicit-any` is an **error** — avoid `any` types
- `react/react-in-jsx-scope` is disabled (React 17+ JSX transform)
## Testing
Tests are written with [Jest](https://jestjs.io/) and [React Native Testing Library](https://callstack.github.io/react-native-testing-library/).
```sh
bun test # Run the full test suite
```
Tests live in `jest/contextual/` (component / integration tests) and `jest/functional/` (unit tests). When adding new functionality, please include relevant tests.
Jellify also has an end-to-end UI test suite powered by [Maestro](https://maestro.mobile.dev). See the [Maestro README](maestro/README.md) for details on the flow structure and how to run the tests.
## Submitting a Pull Request
- PRs are submitted against the `main` branch
- Fill out the [pull request template](.github/pull_request_template.md) — include a clear description of what changed and what issue it addresses
- Tag `@anultravioletaurora` as a reviewer
- CI will automatically run the Jest test suite, TypeScript type-check, and ESLint on your PR — make sure all checks pass before requesting review
+6 -3
View File
@@ -1,6 +1,7 @@
appId: com.cosmonautical.jellify
---
# Full test suite flow - runs all tests in order
- startRecording: full_test_suite
# Run setup and login to Jellyfin
# State is always cleared in the setup flow
@@ -9,6 +10,9 @@ appId: com.cosmonautical.jellify
# Home screen operations
- runFlow: flows/home/flow.yaml
# Full-screen player (requires a track to be playing from a prior flow)
- runFlow: flows/player/flow.yaml
# Run the Quick Actions flow from the home screen
- runFlow: flows/quick-actions/flow.yaml
@@ -21,8 +25,7 @@ appId: com.cosmonautical.jellify
# Discover tab
- runFlow: flows/discover/flow.yaml
# Settings tab
# Settings tab - and sign out at the end to complete the test run
- runFlow: flows/settings/flow.yaml
# Full-screen player (requires a track to be playing from a prior flow)
- runFlow: flows/player/flow.yaml
- stopRecording
+1 -1
View File
@@ -5,7 +5,7 @@ appId: com.cosmonautical.jellify
- takeScreenshot: screenshots/player/queue_screen_initial_state
- assertVisible:
id: "queue-item-0"
id: "queue-item-1"
# Try scrolling in queue
- scrollUntilVisible:
+9 -9
View File
@@ -14,14 +14,14 @@ appId: com.cosmonautical.jellify
- tapOn:
id: "library-tracks-tab-button"
# Wait for tracks to load
- extendedWaitUntil:
visible:
id: "track-item-0"
timeout: 30000
- scrollUntilVisible:
element:
id: "track-item-1"
direction: UP
timeout: 60000
# Take screenshot of tracks list
- takeScreenshot: screenshots/40_tracks_before_favorite
- takeScreenshot: screenshots/quick_actions/tracks_list_screen
# Swipe right on first track to reveal quick actions (may include favorite toggle)
- swipe:
@@ -33,7 +33,7 @@ appId: com.cosmonautical.jellify
- waitForAnimationToEnd
# Take screenshot showing quick action buttons
- takeScreenshot: screenshots/41_swipe_actions
- takeScreenshot: screenshots/quick_actions/swipe_actions
# Check if quick action buttons appeared
- assertVisible:
@@ -49,7 +49,7 @@ appId: com.cosmonautical.jellify
- waitForAnimationToEnd
# Take screenshot after action
- takeScreenshot: screenshots/42_after_favorite_action
- takeScreenshot: screenshots/quick_actions/after_favorite_action
# Test swipe in the other direction
- swipe:
@@ -60,7 +60,7 @@ appId: com.cosmonautical.jellify
- waitForAnimationToEnd
# Take screenshot of right swipe actions
- takeScreenshot: screenshots/43_right_swipe_actions
- takeScreenshot: screenshots/quick_actions/right_swipe_actions
# Tap quick action if visible
- tapOn:
+2 -2
View File
@@ -150,7 +150,7 @@ appId: com.cosmonautical.jellify
- tapOn: "Tracks"
# Wait for tracks to load
- assertVisible: "Tracks"
- waitForAnimationToEnd
# Scroll to ensure we're not at the top
- scroll
@@ -183,7 +183,7 @@ appId: com.cosmonautical.jellify
id: "search-tab-button"
# Wait for search screen
- assertVisible: "Search"
- waitForAnimationToEnd
# Type a search query
- tapOn:
+1 -1
View File
@@ -11,4 +11,4 @@ appId: com.cosmonautical.jellify
- takeScreenshot: screenshots/search/search_initial_state_screen
# Test Grateful Dead lookup
- runFlow: "gd-search.yaml"
- runFlow: "gd-search.yaml"
+17 -4
View File
@@ -1,13 +1,24 @@
appId: com.cosmonautical.jellify
---
- assertVisible:
id: "search-input"
# Quick actions may have brought up some search results already, so scroll to the top of the search tab to ensure we're in a consistent state
- scrollUntilVisible:
element:
id: "search-input"
direction: UP
timeout: 180000
# Tap on the search input to focus it and bring up the keyboard
- tapOn:
id: "search-input"
# Erase any existing text in the search input
- eraseText
# Search for the Grateful Dead
- inputText: "Grateful Dead"
# Hide the keyboard to reveal search results
- hideKeyboard
# Wait for results to load
@@ -16,6 +27,7 @@ appId: com.cosmonautical.jellify
# Take screenshot of search results
- takeScreenshot: screenshots/search/search_results_screen
# Ensure the Grateful Dead artist result is visible and tap on it
- assertVisible:
id: "artist-search-result-0"
- tapOn:
@@ -24,4 +36,5 @@ appId: com.cosmonautical.jellify
# Take screenshot of artist from search
- takeScreenshot: screenshots/search/artist_from_search_screen
- runFlow: ../../subflows/artist/flow.yaml
# Run the artist subflow to verify we can navigate to the artist page from search results and that the page loads correctly
- runFlow: ../../subflows/artist/flow.yaml
+8 -11
View File
@@ -12,14 +12,13 @@ import { RefObject, useRef } from 'react'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import flattenInfiniteQueryPages from '../../../utils/query-selectors'
import { ApiLimits, MaxPages } from '../../../configs/query.config'
import { fetchRecentlyAdded } from '../recents/utils'
import { queryClient } from '../../../constants/query-client'
import { getApi, getUser, useJellifyLibrary } from '../../../stores'
import useLibraryStore from '../../../stores/library'
import { fetchAlbumDiscs } from '../item'
import { Api } from '@jellyfin/sdk/lib/api'
import { AlbumDiscsQueryKey } from './keys'
import { AlbumQuery } from './queries'
import { AlbumQuery, RecentlyAddedQuery } from './queries'
export const useAlbum = (album: BaseItemDto) => useQuery(AlbumQuery(album))
@@ -110,16 +109,11 @@ export default useAlbums
export const useRecentlyAddedAlbums = () => {
const api = getApi()
const user = getUser()
const [library] = useJellifyLibrary()
return useInfiniteQuery({
queryKey: [QueryKeys.RecentlyAddedAlbums, library?.musicLibraryId],
queryFn: ({ pageParam }) => fetchRecentlyAdded(api, library, pageParam),
select: (data) => data.pages.flatMap((page) => page),
getNextPageParam: (lastPage, allPages, lastPageParam) =>
lastPage.length > 0 ? lastPageParam + 1 : undefined,
initialPageParam: 0,
})
return useInfiniteQuery(RecentlyAddedQuery(api, user, library))
}
export const useRefetchRecentlyAdded: () => () => void = () => {
@@ -137,7 +131,10 @@ export const useAlbumDiscs = (album: BaseItemDto) => {
return useQuery(AlbumDiscsQuery(api, album))
}
export const AlbumDiscsQuery = (api: Api | undefined, album: BaseItemDto) => ({
export const ensureAlbumDiscsQuery = async (album: BaseItemDto) =>
await queryClient.ensureQueryData(AlbumDiscsQuery(getApi(), album))
const AlbumDiscsQuery = (api: Api | undefined, album: BaseItemDto) => ({
queryKey: AlbumDiscsQueryKey(album),
queryFn: () => fetchAlbumDiscs(api, album),
})
+31 -2
View File
@@ -1,8 +1,12 @@
import { ONE_DAY } from '../../../constants/query-client'
import { getApi } from '../../../stores'
import { UndefinedInitialDataInfiniteOptions } from '@tanstack/react-query'
import { ONE_DAY, queryClient } from '../../../constants/query-client'
import { getApi, getLibrary, getUser } from '../../../stores'
import { fetchItem } from '../item'
import { RecentlyAddedQueryKey } from '../recents/keys'
import { fetchRecentlyAdded } from '../recents/utils'
import { AlbumQueryKey } from './keys'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import { Api } from '@jellyfin/sdk'
export const AlbumQuery = (album: BaseItemDto) => {
const api = getApi()
@@ -14,3 +18,28 @@ export const AlbumQuery = (album: BaseItemDto) => {
staleTime: ONE_DAY,
}
}
export const RecentlyAddedQuery = (
api: Api | undefined = getApi(),
user = getUser(),
library = getLibrary(),
) => {
return {
queryKey: RecentlyAddedQueryKey(user, library),
queryFn: ({ pageParam }) => fetchRecentlyAdded(api, library, pageParam),
select: (data) => data.pages.flatMap((page) => page),
getNextPageParam: (lastPage, allPages, lastPageParam) =>
lastPage.length > 0 ? lastPageParam + 1 : undefined,
initialPageParam: 0,
} as UndefinedInitialDataInfiniteOptions<
BaseItemDto[],
Error,
BaseItemDto[],
(string | undefined)[],
number
>
}
export const ensureRecentlyAddedQueryData = async () => {
return queryClient.ensureInfiniteQueryData(RecentlyAddedQuery())
}
+5 -12
View File
@@ -7,14 +7,15 @@ import {
useQuery,
} from '@tanstack/react-query'
import { isUndefined } from 'lodash'
import { fetchArtistAlbums, fetchArtistFeaturedOn, fetchArtists } from './utils/artist'
import { fetchArtistFeaturedOn, fetchArtists } from './utils/artist'
import { ApiLimits, MaxPages } from '../../../configs/query.config'
import { RefObject, useRef } from 'react'
import flattenInfiniteQueryPages from '../../../utils/query-selectors'
import { getApi, useApi, useJellifyLibrary, useJellifyUser } from '../../../stores'
import { getApi, useJellifyLibrary, useJellifyUser } from '../../../stores'
import useLibraryStore from '../../../stores/library'
import { fetchItem } from '../item'
import { ArtistQueryKey } from './keys'
import { artistAlbumsQuery } from './queries'
export const useArtist = (artistId: string | undefined | null) => {
const api = getApi()
@@ -27,23 +28,17 @@ export const useArtist = (artistId: string | undefined | null) => {
}
export const useArtistAlbums = (artist: BaseItemDto) => {
const api = useApi()
const [library] = useJellifyLibrary()
return useQuery({
queryKey: [QueryKeys.ArtistAlbums, library?.musicLibraryId, artist.Id],
queryFn: () => fetchArtistAlbums(api, library?.musicLibraryId, artist),
enabled: !isUndefined(artist.Id),
})
return useQuery(artistAlbumsQuery(library!, artist))
}
export const useArtistFeaturedOn = (artist: BaseItemDto) => {
const api = useApi()
const [library] = useJellifyLibrary()
return useQuery({
queryKey: [QueryKeys.ArtistFeaturedOn, library?.musicLibraryId, artist.Id],
queryFn: () => fetchArtistFeaturedOn(api, library?.musicLibraryId, artist),
queryFn: () => fetchArtistFeaturedOn(library?.musicLibraryId, artist),
enabled: !isUndefined(artist.Id),
})
}
@@ -52,7 +47,6 @@ export const useAlbumArtists: () => [
RefObject<Set<string>>,
UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error>,
] = () => {
const api = useApi()
const [user] = useJellifyUser()
const [library] = useJellifyLibrary()
@@ -93,7 +87,6 @@ export const useAlbumArtists: () => [
],
queryFn: ({ pageParam }: { pageParam: number }) =>
fetchArtists(
api,
user,
library,
pageParam,
+10 -2
View File
@@ -1,6 +1,14 @@
import { QueryKeys } from '../../../enums/query-keys'
enum ArtistQueryKeys {
ArtistById = 'ARTIST_BY_ID',
ArtistAlbums = 'ARTIST_ALBUMS',
}
export const ArtistQueryKey = (artistId: string | undefined | null) => [
QueryKeys.ArtistById,
ArtistQueryKeys.ArtistById,
artistId,
]
export const ArtistAlbumsQueryKey = (artistId: string | undefined | null) => [
ArtistQueryKeys.ArtistAlbums,
artistId,
]
+18
View File
@@ -0,0 +1,18 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { isUndefined } from 'lodash'
import { ArtistAlbumsQueryKey } from './keys'
import { JellifyLibrary } from '@/src/types/JellifyLibrary'
import { fetchArtistAlbums } from './utils/artist'
import { queryClient } from '../../../constants/query-client'
import { getLibrary } from '../../../stores'
export const artistAlbumsQuery = (library: JellifyLibrary, artist: BaseItemDto) => ({
queryKey: ArtistAlbumsQueryKey(artist.Id),
queryFn: () => fetchArtistAlbums(library?.musicLibraryId, artist),
enabled: !isUndefined(artist.Id),
})
export async function ensureArtistAlbumsQueryData(artist: BaseItemDto) {
const library = getLibrary()
return await queryClient.ensureQueryData(artistAlbumsQuery(library!, artist))
}
+8 -4
View File
@@ -12,9 +12,9 @@ import { getArtistsApi, getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { JellifyUser } from '../../../../types/JellifyUser'
import { ApiLimits } from '../../../../configs/query.config'
import { setQueryUserDataForItems } from '../../user-data'
import { getApi } from '../../../../stores'
export function fetchArtists(
api: Api | undefined,
user: JellifyUser | undefined,
library: JellifyLibrary | undefined,
page: number,
@@ -23,6 +23,8 @@ export function fetchArtists(
sortOrder: SortOrder[] = [SortOrder.Ascending],
): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
const api = getApi()
if (!api) return reject('No API instance provided')
if (!user) return reject('No user provided')
if (!library) return reject('Library has not been set')
@@ -55,16 +57,17 @@ export function fetchArtists(
/**
* Fetches all albums for an artist
* @param api The Jellyfin {@link Api} instance
* @param libraryId The ID of the library to fetch albums from
* @param artist The artist to fetch albums for
* @returns A promise that resolves to an array of {@link BaseItemDto}s
*/
export function fetchArtistAlbums(
api: Api | undefined,
libraryId: string | undefined,
artist: BaseItemDto,
): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
const api = getApi()
if (!api) return reject('No API instance provided')
if (!libraryId) return reject('Library has not been set')
@@ -98,11 +101,12 @@ export function fetchArtistAlbums(
* @returns A promise that resolves to an array of {@link BaseItemDto}s
*/
export function fetchArtistFeaturedOn(
api: Api | undefined,
libraryId: string | undefined,
artist: BaseItemDto,
): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
const api = getApi()
if (!api) return reject('No API instance provided')
if (!libraryId) return reject('Library has not been set')
+1 -1
View File
@@ -1,5 +1,5 @@
import { RecentlyPlayedArtistsQueryKey, RecentlyPlayedTracksQueryKey } from './keys'
import { InfiniteData, useInfiniteQuery, useQueries } from '@tanstack/react-query'
import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query'
import { fetchRecentlyPlayed, fetchRecentlyPlayedArtists } from './utils'
import { ApiLimits, MaxPages } from '../../../configs/query.config'
import { isUndefined } from 'lodash'
+5
View File
@@ -22,3 +22,8 @@ export const RecentlyPlayedArtistsQueryKey = (
user: JellifyUser | undefined,
library: JellifyLibrary | undefined,
) => RecentsQueryKey(RecentsQueryKeys.RecentlyPlayedArtists, user, library)
export const RecentlyAddedQueryKey = (
user: JellifyUser | undefined,
library: JellifyLibrary | undefined,
) => RecentsQueryKey(RecentsQueryKeys.RecentlyAdded, user, library)
+8 -41
View File
@@ -1,83 +1,50 @@
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { SuggestionQueryKeys } from './keys'
import {
fetchAlbumSuggestions,
fetchArtistSuggestions,
fetchSearchSuggestions,
} from './utils/suggestions'
import { getApi, getUser, useJellifyLibrary } from '../../../stores'
import { fetchSearchSuggestions } from './utils/suggestions'
import { getUser, useJellifyLibrary } from '../../../stores'
import { isUndefined } from 'lodash'
import fetchSimilarArtists, { fetchSimilarItems } from './utils/similar'
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client'
import { ONE_DAY } from '../../../constants/query-client'
import { DiscoverAlbumsQuery, DiscoverArtistsQuery } from './queries'
export const useSearchSuggestions = () => {
const api = getApi()
const [library] = useJellifyLibrary()
const user = getUser()
return useQuery({
queryKey: [SuggestionQueryKeys.SearchSuggestions, library?.musicLibraryId],
queryFn: () => fetchSearchSuggestions(api, user, library?.musicLibraryId),
queryFn: () => fetchSearchSuggestions(user, library?.musicLibraryId),
enabled: !isUndefined(library),
})
}
export const useDiscoverArtists = () => {
const api = getApi()
const [library] = useJellifyLibrary()
const user = getUser()
return useInfiniteQuery({
queryKey: [
SuggestionQueryKeys.InfiniteArtistSuggestions,
user?.id,
library?.musicLibraryId,
],
queryFn: ({ pageParam }) =>
fetchArtistSuggestions(api, user, library?.musicLibraryId, pageParam),
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPage.length > 0 ? lastPageParam + 1 : undefined,
select: (data) => data.pages.flatMap((page) => page),
initialPageParam: 0,
maxPages: 2,
})
return useInfiniteQuery(DiscoverArtistsQuery(user, library))
}
export const useDiscoverAlbums = () => {
const api = getApi()
const [library] = useJellifyLibrary()
const user = getUser()
return useInfiniteQuery({
queryKey: [SuggestionQueryKeys.InfiniteAlbumSuggestions, user?.id, library?.musicLibraryId],
queryFn: ({ pageParam }) =>
fetchAlbumSuggestions(api, user, library?.musicLibraryId, pageParam),
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPage.length > 0 ? lastPageParam + 1 : undefined,
select: (data) => data.pages.flatMap((page) => page),
initialPageParam: 0,
maxPages: 2,
})
return useInfiniteQuery(DiscoverAlbumsQuery(user, library))
}
export const useSimilarItems = (item: BaseItemDto) => {
const api = getApi()
const user = getUser()
return useQuery({
queryKey: [SuggestionQueryKeys.SimilarItems, item.Id],
queryFn: () =>
item.Type === BaseItemKind.MusicArtist
? fetchSimilarArtists(api, user, item.Id!)
: fetchSimilarItems(api, user, item.Id!),
? fetchSimilarArtists(user, item.Id!)
: fetchSimilarItems(user, item.Id!),
enabled: !isUndefined(item.Id),
staleTime: ONE_DAY,
})
+4 -4
View File
@@ -1,6 +1,6 @@
export enum SuggestionQueryKeys {
InfiniteArtistSuggestions,
SearchSuggestions,
SimilarItems,
InfiniteAlbumSuggestions,
InfiniteArtistSuggestions = 'INFINITE_ARTIST_SUGGESTIONS',
SearchSuggestions = 'SEARCH_SUGGESTIONS',
SimilarItems = 'SIMILAR_ITEMS',
InfiniteAlbumSuggestions = 'INFINITE_ALBUM_SUGGESTIONS',
}
+68
View File
@@ -0,0 +1,68 @@
import { JellifyUser } from '@/src/types/JellifyUser'
import { SuggestionQueryKeys } from './keys'
import { fetchAlbumSuggestions, fetchArtistSuggestions } from './utils/suggestions'
import { JellifyLibrary } from '@/src/types/JellifyLibrary'
import { UndefinedInitialDataInfiniteOptions } from '@tanstack/react-query'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import { queryClient } from '../../../constants/query-client'
export const DiscoverArtistsQuery = (
user: JellifyUser | undefined,
library: JellifyLibrary | undefined,
) => {
return {
queryKey: [
SuggestionQueryKeys.InfiniteArtistSuggestions,
user?.id,
library?.musicLibraryId,
],
queryFn: ({ pageParam }) =>
fetchArtistSuggestions(user, library?.musicLibraryId, pageParam),
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPage.length > 0 ? lastPageParam + 1 : undefined,
select: (data) => data.pages.flatMap((page) => page),
initialPageParam: 0,
maxPages: 2,
} as UndefinedInitialDataInfiniteOptions<
BaseItemDto[],
Error,
BaseItemDto[],
(string | undefined)[],
number
>
}
export async function ensureDiscoverArtistsQueryData(
user: JellifyUser | undefined,
library: JellifyLibrary | undefined,
) {
return await queryClient.ensureInfiniteQueryData(DiscoverArtistsQuery(user, library))
}
export const DiscoverAlbumsQuery = (
user: JellifyUser | undefined,
library: JellifyLibrary | undefined,
) => {
return {
queryKey: [SuggestionQueryKeys.InfiniteAlbumSuggestions, user?.id, library?.musicLibraryId],
queryFn: ({ pageParam }) => fetchAlbumSuggestions(user, library?.musicLibraryId, pageParam),
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPage.length > 0 ? lastPageParam + 1 : undefined,
select: (data) => data.pages.flatMap((page) => page),
initialPageParam: 0,
maxPages: 2,
} as UndefinedInitialDataInfiniteOptions<
BaseItemDto[],
Error,
BaseItemDto[],
(string | undefined)[],
number
>
}
export async function ensureDiscoverAlbumsQueryData(
user: JellifyUser | undefined,
library: JellifyLibrary | undefined,
) {
return await queryClient.ensureInfiniteQueryData(DiscoverAlbumsQuery(user, library))
}
+5 -2
View File
@@ -4,14 +4,16 @@ import { ApiLimits } from '../../../../configs/query.config'
import { Api } from '@jellyfin/sdk'
import { isUndefined } from 'lodash'
import { JellifyUser } from '../../../../types/JellifyUser'
import { getApi } from '../../../../stores'
export default function fetchSimilarArtists(
api: Api | undefined,
user: JellifyUser | undefined,
itemId: string,
limit: number = ApiLimits.Similar,
): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
const api = getApi()
if (isUndefined(api)) return reject('Client has not been set')
if (isUndefined(user)) return reject('User has not been set')
@@ -31,12 +33,13 @@ export default function fetchSimilarArtists(
}
export function fetchSimilarItems(
api: Api | undefined,
user: JellifyUser | undefined,
itemId: string,
limit: number = ApiLimits.Similar,
): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
const api = getApi()
if (isUndefined(api)) return reject('Client has not been set')
if (isUndefined(user)) return reject('User has not been set')
@@ -10,6 +10,7 @@ import {
import { Api } from '@jellyfin/sdk'
import { isUndefined } from 'lodash'
import { JellifyUser } from '../../../../types/JellifyUser'
import { getApi } from '../../../../stores'
/**
* Fetches search suggestions from the Jellyfin server
@@ -17,11 +18,12 @@ import { JellifyUser } from '../../../../types/JellifyUser'
* @returns A promise of a {@link BaseItemDto} array, be it empty or not
*/
export async function fetchSearchSuggestions(
api: Api | undefined,
user: JellifyUser | undefined,
libraryId: string | undefined,
): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
const api = getApi()
if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(user)) return reject('User has not been set')
if (isUndefined(libraryId)) return reject('Library has not been set')
@@ -52,12 +54,13 @@ export async function fetchSearchSuggestions(
}
export async function fetchArtistSuggestions(
api: Api | undefined,
user: JellifyUser | undefined,
libraryId: string | undefined,
page: number,
): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
const api = getApi()
if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(user)) return reject('User has not been set')
if (isUndefined(libraryId)) return reject('Library has not been set')
@@ -85,12 +88,13 @@ export async function fetchArtistSuggestions(
}
export async function fetchAlbumSuggestions(
api: Api | undefined,
user: JellifyUser | undefined,
libraryId: string | undefined,
page: number,
): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
const api = getApi()
if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(user)) return reject('User has not been set')
if (isUndefined(libraryId)) return reject('Library has not been set')
+1 -2
View File
@@ -17,8 +17,7 @@ const AlbumTemplate = (
sectionIndexTitle: disc.title,
items: disc.data.map(({ Name, Artists }) => ({
text: Name ?? 'Untitled Track',
detailText:
(Artists?.length ?? 0) > 2 ? formatArtistNames(Artists ?? []) : undefined,
detailText: formatArtistNames(Artists ?? []),
})),
})),
onItemSelect: async ({ templateId, index }) => {
+26
View File
@@ -0,0 +1,26 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { CarPlay } from 'react-native-carplay'
import { ListTemplate } from 'react-native-carplay/lib/templates/ListTemplate'
import AlbumTemplate from './Album'
import { ensureAlbumDiscsQuery } from '../../api/queries/album'
const ArtistTemplate = (artist: BaseItemDto, albums: BaseItemDto[]) =>
new ListTemplate({
title: artist.Name ?? 'Unknown Artist',
sections: [
{
items: albums.map(({ Name }) => ({
text: Name ?? 'Untitled Album',
})),
},
],
onItemSelect: async ({ templateId, index }) => {
const selectedAlbum = albums[index]
const albumDiscs = await ensureAlbumDiscsQuery(selectedAlbum)
CarPlay.pushTemplate(AlbumTemplate(selectedAlbum, albumDiscs), true)
},
})
export default ArtistTemplate
+15 -6
View File
@@ -1,22 +1,31 @@
import { ensureArtistAlbumsQueryData } from '../../api/queries/artist/queries'
import { formatArtistName } from '../../utils/formatting/artist-names'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { ListTemplate } from 'react-native-carplay'
import { CarPlay, ListTemplate } from 'react-native-carplay'
import uuid from 'react-native-uuid'
import ArtistTemplate from './Artist'
const ArtistsTemplate = (items: BaseItemDto[]) =>
const ArtistsTemplate = (artists: BaseItemDto[]) =>
new ListTemplate({
id: uuid.v4(),
sections: [
{
items:
items?.map((item) => {
artists?.map((artist) => {
return {
id: item.Id!,
text: item.Name ?? 'Untitled',
id: artist.Id!,
text: formatArtistName(artist.Name),
}
}) ?? [],
},
],
onItemSelect: async (item) => {},
onItemSelect: async ({ index }) => {
const artist = artists[index]
const albums = await ensureArtistAlbumsQueryData(artist)
CarPlay.pushTemplate(ArtistTemplate(artist, albums), true)
},
})
export default ArtistsTemplate
+64 -1
View File
@@ -1,10 +1,73 @@
import { ListTemplate } from 'react-native-carplay'
import { QueryKeys } from '../../enums/query-keys'
import { getLibrary, getUser } from '../../stores'
import { CarPlay, ListTemplate } from 'react-native-carplay'
import uuid from 'react-native-uuid'
import { SuggestionQueryKeys } from '../../api/queries/suggestions/keys'
import {
ensureDiscoverAlbumsQueryData,
ensureDiscoverArtistsQueryData,
} from '../../api/queries/suggestions/queries'
import ArtistsTemplate from './Artists'
import TracksTemplate from './Tracks'
import { ensureRecentlyAddedQueryData } from '../../api/queries/album/queries'
const CarPlayDiscover = new ListTemplate({
id: uuid.v4(),
tabTitle: 'Discover',
tabSystemImageName: 'globe',
sections: [
{
header: 'Discover',
items: [
{ id: QueryKeys.RecentlyAdded, text: 'Recently Added' },
{ id: SuggestionQueryKeys.InfiniteArtistSuggestions, text: 'Suggested Artists' },
{ id: SuggestionQueryKeys.InfiniteAlbumSuggestions, text: 'Suggested Albums' },
],
},
],
onItemSelect: async ({ index }) => {
const user = getUser()
const library = getLibrary()
switch (index) {
case 0: {
// Recently Added
const { pages: recentlyAddedPages } = await ensureRecentlyAddedQueryData()
CarPlay.pushTemplate(
TracksTemplate(recentlyAddedPages.flat(), 'Recently Added'),
true,
)
break
}
case 1: {
// Suggested Artists
const { pages: suggestedArtistsPages } = await ensureDiscoverArtistsQueryData(
user,
library,
)
CarPlay.pushTemplate(ArtistsTemplate(suggestedArtistsPages.flat()), true)
break
}
case 2: {
// Suggested Albums
const { pages: suggestedAlbumsPages } = await ensureDiscoverAlbumsQueryData(
user,
library,
)
CarPlay.pushTemplate(
TracksTemplate(suggestedAlbumsPages.flat(), 'More from the Vault'),
true,
)
break
}
}
},
})
export default CarPlayDiscover
+2 -2
View File
@@ -15,7 +15,7 @@ import {
FrequentlyPlayedTracksQueryKey,
} from '../../api/queries/frequents/keys'
import { PlayItAgainQuery } from '../../api/queries/recents'
import useJellifyStore, { getUser } from '../../stores'
import { getLibrary, getUser } from '../../stores'
const CarPlayHome = new ListTemplate({
id: uuid.v4(),
@@ -40,7 +40,7 @@ const CarPlayHome = new ListTemplate({
],
onItemSelect: async ({ index }) => {
const user = getUser()
const library = useJellifyStore.getState().library
const library = getLibrary()
switch (index) {
case 0: {
+12 -14
View File
@@ -1,27 +1,28 @@
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'
import { BaseItemDto, BaseItemKind, ImageType } from '@jellyfin/sdk/lib/generated-client/models'
import { CarPlay, ListTemplate } from 'react-native-carplay'
import uuid from 'react-native-uuid'
import CarPlayNowPlaying from './NowPlaying'
import { Queue } from '../../services/types/queue-item'
import { queryClient } from '../../constants/query-client'
import { AlbumDiscsQuery } from '../../api/queries/album'
import { getApi } from '../../stores'
import AlbumTemplate from './Album'
import { AlbumDiscsQueryKey } from '../../api/queries/album/keys'
import { loadNewQueue } from '../../hooks/player/functions/queue'
import { ensureAlbumDiscsQuery } from '../../api/queries/album'
import { formatArtistNames } from '../../utils/formatting/artist-names'
import { getItemImageUrl } from '../../api/queries/image/utils'
const TracksTemplate = (items: BaseItemDto[], queuingRef: Queue) =>
new ListTemplate({
id: uuid.v4(),
sections: [
{
items: items.map(({ Id, Name, Type }) => {
const isAlbum = Type === BaseItemKind.MusicAlbum
items: items.map((item) => {
const isAlbum = item.Type === BaseItemKind.MusicAlbum
return {
id: Id!,
text: Name ?? `Untitled ${isAlbum ? 'Album' : 'Track'}`,
id: item.Id!,
text: item.Name ?? `Untitled ${isAlbum ? 'Album' : 'Track'}`,
detailText: formatArtistNames(item.Artists),
browsable: isAlbum,
accessoryType: isAlbum ? 'disclosure-indicator' : undefined,
}
}),
},
@@ -34,12 +35,9 @@ const TracksTemplate = (items: BaseItemDto[], queuingRef: Queue) =>
const startIndex = tracks.indexOf(item)
if (startIndex === -1) {
await queryClient.ensureQueryData(AlbumDiscsQuery(getApi(), item))
const albumDiscs = await ensureAlbumDiscsQuery(item)
CarPlay.pushTemplate(
AlbumTemplate(item, queryClient.getQueryData(AlbumDiscsQueryKey(item))!),
true,
)
CarPlay.pushTemplate(AlbumTemplate(item, albumDiscs), true)
} else {
await loadNewQueue({
index: startIndex,
+3 -6
View File
@@ -3,13 +3,14 @@ import { JellifyUser } from '../types/JellifyUser'
import { Api } from '@jellyfin/sdk'
import { ONE_DAY, ONE_HOUR, ONE_MINUTE, queryClient } from '../constants/query-client'
import { QueryKeys } from '../enums/query-keys'
import { fetchAlbumDiscs, fetchItem } from '../api/queries/item'
import { fetchItem } from '../api/queries/item'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import fetchUserData from '../api/queries/user-data/utils'
import UserDataQueryKey from '../api/queries/user-data/keys'
import { getApi, getUser } from '../stores'
import { ArtistQueryKey } from '../api/queries/artist/keys'
import { AlbumQuery } from '../api/queries/album/queries'
import { ensureAlbumDiscsQuery } from '../api/queries/album'
// Module-level dedup guard — no hook needed, this is just a long-lived Set
const prefetchedContext = new Set<string>()
@@ -81,11 +82,7 @@ function warmAlbumContext(api: Api | undefined, album: BaseItemDto): void {
const albumDiscsQueryKey = [QueryKeys.ItemTracks, Id]
if (queryClient.getQueryState(albumDiscsQueryKey)?.status !== 'success')
queryClient.ensureQueryData({
queryKey: albumDiscsQueryKey,
queryFn: () => fetchAlbumDiscs(api, album),
staleTime: ONE_DAY,
})
ensureAlbumDiscsQuery(album)
}
function warmArtistContext(api: Api | undefined, artistId: string): void {
+2 -4
View File
@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query'
import { createContext, ReactNode, use } from 'react'
import { isUndefined } from 'lodash'
import { useArtistAlbums, useArtistFeaturedOn } from '../../api/queries/artist'
import { useJellifyLibrary, getApi, getUser } from '../../stores'
import { getUser } from '../../stores'
interface ArtistContext {
fetchingAlbums: boolean
@@ -36,9 +36,7 @@ export const ArtistProvider = ({
artist: BaseItemDto
children: ReactNode
}) => {
const api = getApi()
const user = getUser()
const [library] = useJellifyLibrary()
const {
data: albums,
@@ -58,7 +56,7 @@ export const ArtistProvider = ({
isPending: fetchingSimilarArtists,
} = useQuery({
queryKey: [QueryKeys.SimilarItems, artist.Id],
queryFn: () => fetchSimilarArtists(api, user, artist.Id!),
queryFn: () => fetchSimilarArtists(user, artist.Id!),
enabled: !isUndefined(artist.Id),
})