clean up jest folder between setup, contextual, and functional files (#451)

Clean up Jest and Maestro Folders

Reduction of the number of calls being made by components

Fix issue where a different track would display than the one that is currently playing

Update OTA dependency to fix an issue on iOS
This commit is contained in:
Violet Caulfield
2025-07-22 09:17:42 -05:00
committed by GitHub
parent 31e6ad965f
commit b958cfe37b
49 changed files with 1303 additions and 1237 deletions

View File

@@ -1788,7 +1788,7 @@ PODS:
- Yoga
- react-native-netinfo (11.4.1):
- React-Core
- react-native-ota-hot-update (2.3.0):
- react-native-ota-hot-update (2.3.1):
- boost
- DoubleConversion
- fast_float
@@ -2718,7 +2718,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- RNScreens (4.12.0):
- RNScreens (4.13.1):
- boost
- DoubleConversion
- fast_float
@@ -2746,10 +2746,10 @@ PODS:
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- RNScreens/common (= 4.12.0)
- RNScreens/common (= 4.13.1)
- SocketRocket
- Yoga
- RNScreens/common (4.12.0):
- RNScreens/common (4.13.1):
- boost
- DoubleConversion
- fast_float
@@ -3213,7 +3213,7 @@ SPEC CHECKSUMS:
react-native-config: 644074ab88db883fcfaa584f03520ec29589d7df
react-native-mmkv: 7fb4729ad5cb787a4394e6c4bd48e4b8ec30f25c
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-ota-hot-update: fe5bbc1e656018b0cf526adfcc92788e7f098110
react-native-ota-hot-update: 2242f369e2a38ddf44256ea68aed576a96fb8a0a
react-native-pager-view: 6e60acfd433ace1a7a1af75bd80b619a41478640
react-native-safe-area-context: 68d1363b8354472a961aa6861ba8451beaf9a810
react-native-track-player: 6dc2e2633265704b8ab6d8124b80239d4ed1f911
@@ -3258,7 +3258,7 @@ SPEC CHECKSUMS:
RNGestureHandler: 5e1a1605659c22098719fc2e8aee453fe728f52e
RNReactNativeHapticFeedback: 8eb91a6f48567d02ec8026e515102e18c41030cf
RNReanimated: bc1ddb7a5352648bcf0d592256069833bf935a46
RNScreens: ab490a252dd536fca261c72ab7e5538e656dcb2b
RNScreens: c63849403489bd068ea160f276fbc8416f19f2f7
RNSentry: 2b690575f638f588e51b6817e5f77c8ab62de2cf
RNVectorIcons: ef9b4b0b786053ebdd63ee2972f48de9633ba166
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d

View File

@@ -4,17 +4,17 @@ module.exports = {
testTimeout: 10000,
setupFiles: ['./node_modules/react-native-gesture-handler/jestSetup.js'],
setupFilesAfterEnv: [
'./jest/setup.ts',
'./jest/setup-blur.ts',
'./jest/setup-carplay.ts',
'./jest/setup-device-info.js', // JS to prevent Typescript implicit any warning
'./jest/setup-reanimated.ts',
'./jest/setup-rnfs.ts',
'./jest/setup-rntp.ts',
'./jest/setup-sentry.ts',
'./jest/setup-nitro-image.ts',
'./jest/setup/setup.ts',
'./jest/setup/blur.ts',
'./jest/setup/carplay.ts',
'./jest/setup/device-info.js', // JS to prevent Typescript implicit any warning
'./jest/setup/reanimated.ts',
'./jest/setup/rnfs.ts',
'./jest/setup/rntp.ts',
'./jest/setup/sentry.ts',
'./jest/setup/nitro-image.ts',
'./tamagui.config.ts',
'./jest/setup-native-modules.ts',
'./jest/setup/native-modules.ts',
],
extensionsToTreatAsEsm: ['.ts', '.tsx'],
transformIgnorePatterns: [

View File

@@ -1,6 +1,6 @@
import 'react-native'
import React from 'react'
import App from '../App'
import App from '../../App'
import { render } from '@testing-library/react-native'

View File

@@ -1,9 +1,8 @@
import { render, screen, waitFor } from '@testing-library/react-native'
import { JellifyProvider, useJellifyContext } from '../src/providers'
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'
import { useEffect } from 'react'
import { MMKVStorageKeys } from '../../src/enums/mmkv-storage-keys'
import { storage } from '../../src/constants/storage'
const JellifyConsumer = () => {
const { server, user, library } = useJellifyContext()

View File

@@ -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/providers/Player/queue'
import { PlayerProvider } from '../src/providers/Player'
import { QueueProvider } from '../../src/providers/Player/queue'
import { PlayerProvider } from '../../src/providers/Player'
import { View } from 'react-native'
const queryClient = new QueryClient()

View File

@@ -5,9 +5,9 @@ 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/providers/Player/queue'
import { eventHandler } from './setup-rntp'
import JellifyTrack from '../src/types/JellifyTrack'
import { QueueProvider, useQueueContext } from '../../src/providers/Player/queue'
import { eventHandler } from '../setup/rntp'
import JellifyTrack from '../../src/types/JellifyTrack'
const queryClient = new QueryClient()

View File

@@ -1,23 +1,20 @@
import 'react-native'
import React from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'
import { Event } from 'react-native-track-player'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Button, Text } from 'react-native'
import TrackPlayer from 'react-native-track-player'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { QueueProvider, useQueueContext } from '../src/providers/Player/queue'
import { PlayerProvider, usePlayerContext } from '../src/providers/Player'
import { eventHandler } from './setup-rntp'
import JellifyTrack from '../src/types/JellifyTrack'
import { QueuingType } from '../src/enums/queuing-type'
import { storage } from '../src/constants/storage'
import { MMKVStorageKeys } from '../src/enums/mmkv-storage-keys'
import { QueueProvider, useQueueContext } from '../../src/providers/Player/queue'
import { PlayerProvider, usePlayerContext } from '../../src/providers/Player'
import JellifyTrack from '../../src/types/JellifyTrack'
import { QueuingType } from '../../src/enums/queuing-type'
import { storage } from '../../src/constants/storage'
import { MMKVStorageKeys } from '../../src/enums/mmkv-storage-keys'
// Mock the JellifyProvider to avoid dependency issues
jest.mock('../src/providers', () => ({
...jest.requireActual('../src/providers'),
jest.mock('../../src/providers', () => ({
...jest.requireActual('../../src/providers'),
useJellifyContext: () => ({
api: {},
sessionId: 'test-session',
@@ -26,7 +23,7 @@ jest.mock('../src/providers', () => ({
}))
// Mock the NetworkProvider to avoid dependency issues
jest.mock('../src/providers/Network', () => ({
jest.mock('../../src/providers/Network', () => ({
useNetworkContext: () => ({
downloadedTracks: [],
networkStatus: 'ONLINE',
@@ -34,7 +31,7 @@ jest.mock('../src/providers/Network', () => ({
}))
// Mock the SettingsProvider to avoid dependency issues
jest.mock('../src/providers/Settings', () => ({
jest.mock('../../src/providers/Settings', () => ({
useSettingsContext: () => ({
autoDownload: false,
}),

View File

@@ -0,0 +1,42 @@
import move from '../../src/providers/Player/utils/move'
const playQueue = [
{ id: '1', index: 0, url: 'https://example.com', item: { Id: '1' } },
{ id: '2', index: 1, url: 'https://example.com', item: { Id: '2' } },
{ id: '3', index: 2, url: 'https://example.com', item: { Id: '3' } },
]
/**
* Tests the move track utility function
*
* Doesn't inspect the RNTP queue, only the play queue
*
* Doesn't inspect the track indexes, but rather the track IDs to ensure the correct track is moved
*/
describe('Move Track Util', () => {
afterEach(() => {
jest.clearAllMocks()
})
describe('moveTrack', () => {
it('should move the first track to the second index', () => {
const result = move(playQueue, 0, 1)
expect(result).toEqual([
{ id: '2', index: 1, url: 'https://example.com', item: { Id: '2' } },
{ id: '1', index: 0, url: 'https://example.com', item: { Id: '1' } },
{ id: '3', index: 2, url: 'https://example.com', item: { Id: '3' } },
])
})
it('should move the last track to the first index', () => {
const result = move(playQueue, 2, 0)
expect(result).toEqual([
{ id: '3', index: 2, url: 'https://example.com', item: { Id: '3' } },
{ id: '1', index: 0, url: 'https://example.com', item: { Id: '1' } },
{ id: '2', index: 1, url: 'https://example.com', item: { Id: '2' } },
])
})
})
})

View File

@@ -0,0 +1,146 @@
import { QueuingType } from '../../src/enums/queuing-type'
import { findPlayNextIndexStart, findPlayQueueIndexStart } from '../../src/providers/Player/utils'
describe('Queue Index Util', () => {
afterEach(() => {
jest.clearAllMocks()
})
describe('findPlayNextIndexStart', () => {
it('should return 0 if the queue is empty', async () => {
const result = await findPlayNextIndexStart([])
expect(result).toBe(0)
})
it('should return the index of the active track + 1', async () => {
const result = await findPlayNextIndexStart([
{ id: '1', index: 0, url: 'https://example.com', item: { Id: '1' } },
])
expect(result).toBe(1)
})
it('should return 0 if the active track is not in the queue', async () => {
const result = await findPlayNextIndexStart([
{ id: '1', index: 0, url: 'https://example.com', item: { Id: '2' } },
])
expect(result).toBe(0)
})
})
describe('findPlayQueueIndexStart', () => {
it('should return the index of the first track that is not from selection', async () => {
const result = await findPlayQueueIndexStart([
{
id: '1',
index: 0,
url: 'https://example.com',
item: { Id: '1' },
QueuingType: QueuingType.FromSelection,
},
{
id: '2',
index: 1,
url: 'https://example.com',
item: { Id: '2' },
QueuingType: QueuingType.PlayingNext,
},
{
id: '3',
index: 2,
url: 'https://example.com',
item: { Id: '3' },
QueuingType: QueuingType.DirectlyQueued,
},
])
expect(result).toBe(3)
})
it('should return the index of the first track that is not from selection and after other queued tracks', async () => {
const result = await findPlayQueueIndexStart([
{
id: '1',
index: 0,
url: 'https://example.com',
item: { Id: '1' },
QueuingType: QueuingType.FromSelection,
},
{
id: '2',
index: 1,
url: 'https://example.com',
item: { Id: '2' },
QueuingType: QueuingType.PlayingNext,
},
{
id: '3',
index: 2,
url: 'https://example.com',
item: { Id: '3' },
QueuingType: QueuingType.DirectlyQueued,
},
{
id: '4',
index: 3,
url: 'https://example.com',
item: { Id: '4' },
QueuingType: QueuingType.FromSelection,
},
{
id: '5',
index: 4,
url: 'https://example.com',
item: { Id: '5' },
QueuingType: QueuingType.FromSelection,
},
])
expect(result).toBe(3)
})
it('should add in relation to the active track if shuffled, but respect queue priority', async () => {
const result = await findPlayQueueIndexStart([
{
id: '2',
index: 0,
url: 'https://example.com',
item: { Id: '2' },
QueuingType: QueuingType.FromSelection,
},
{
id: '1',
index: 1,
url: 'https://example.com',
item: { Id: '1' },
QueuingType: QueuingType.PlayingNext,
},
{
id: '3',
index: 2,
url: 'https://example.com',
item: { Id: '3' },
QueuingType: QueuingType.DirectlyQueued,
},
{
id: '5',
index: 3,
url: 'https://example.com',
item: { Id: '5' },
QueuingType: QueuingType.FromSelection,
},
{
id: '4',
index: 4,
url: 'https://example.com',
item: { Id: '4' },
QueuingType: QueuingType.FromSelection,
},
])
expect(result).toBe(3)
})
})
})

View File

@@ -1,11 +1,11 @@
import 'react-native'
import { shuffleJellifyTracks } from '../src/providers/Player/utils/shuffle'
import { QueuingType } from '../src/enums/queuing-type'
import JellifyTrack from '../src/types/JellifyTrack'
import { shuffleJellifyTracks } from '../../src/providers/Player/utils/shuffle'
import { QueuingType } from '../../src/enums/queuing-type'
import JellifyTrack from '../../src/types/JellifyTrack'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
// Mock the network status types to avoid dependency issues
jest.mock('../src/components/Network/internetConnectionWatcher', () => ({
jest.mock('../../src/components/Network/internetConnectionWatcher', () => ({
networkStatusTypes: {
ONLINE: 'ONLINE',
OFFLINE: 'OFFLINE',

View File

@@ -35,8 +35,8 @@ jest.mock('react-native-track-player', () => {
// player getters
getQueue: jest.fn(),
getTrack: jest.fn(),
getActiveTrackIndex: jest.fn(),
getActiveTrack: jest.fn(),
getActiveTrackIndex: jest.fn().mockResolvedValue(0),
getActiveTrack: jest.fn().mockResolvedValue({ id: '1', index: 0, item: { Id: '1' } }),
getCurrentTrack: jest.fn(),
getVolume: jest.fn(),
getDuration: jest.fn(),
@@ -103,10 +103,16 @@ jest.mock('react-native-track-player', () => {
Track: 1,
Queue: 2,
},
TrackType: {
Default: 'default',
HLS: 'hls',
SmoothStreaming: 'smoothStreaming',
Dash: 'dash',
},
}
})
// Mock the gapless helper to avoid dynamic import issues in tests
jest.mock('../src/player/helpers/gapless', () => ({
jest.mock('../../src/player/helpers/gapless', () => ({
ensureUpcomingTracksInQueue: jest.fn().mockResolvedValue(undefined),
}))

View File

@@ -1,4 +1,4 @@
jest.mock('../src/api/info', () => {
jest.mock('../../src/api/info', () => {
return {
JellyfinInfo: {
clientInfo: {
@@ -33,7 +33,7 @@ jest.mock('react-native-haptic-feedback', () => {
jest.mock('react-native/Libraries/Components/RefreshControl/RefreshControl', () => ({
__esModule: true,
default: require('./setup-refresh-control'),
default: require('./refresh-control'),
}))
jest.mock('react-native-toast-message', () => {

View File

@@ -3,6 +3,5 @@ appId: com.jellify
- clearState # clears the state of the current app
- launchApp
- runFlow: ../tests/login.yaml
- launchApp
- runFlow: ../tests/musicplayer.yaml
- runFlow: ../tests/search.yaml

View File

@@ -25,7 +25,4 @@ appId: com.jellify
text: "Royalty Free Music"
- tapOn:
id: "let_s_go_button"
# Close the app to ensure app is logged in
# before next start
- stopApp

View File

@@ -1,6 +1,9 @@
appId: com.jellify
---
# Navigate to the home screen
- assertVisible:
id: "home-tab-icon"
- tapOn:
id: "home-tab-icon"

View File

@@ -23,8 +23,6 @@ appId: com.jellify
# Test Player (Playback) Tab
- tapOn:
text: "Player"
- assertVisible:
text: "Gapless Playback"
- assertVisible:
text: "Streaming Quality"

View File

@@ -45,7 +45,7 @@
"@react-navigation/native-stack": "^7.3.21",
"@sentry/react-native": "^6.17.0",
"@shopify/flash-list": "^2.0.0-rc.11",
"@tamagui/config": "^1.132.7",
"@tamagui/config": "^1.132.10",
"@tanstack/query-sync-storage-persister": "^5.83.0",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-query-persist-client": "^5.83.0",
@@ -76,7 +76,7 @@
"react-native-haptic-feedback": "^2.3.3",
"react-native-linear-gradient": "^2.8.3",
"react-native-mmkv": "3.3.0",
"react-native-ota-hot-update": "^2.3.0",
"react-native-ota-hot-update": "^2.3.1",
"react-native-pager-view": "^6.8.1",
"react-native-reanimated": "^3.18.0",
"react-native-safe-area-context": "^5.5.2",
@@ -89,7 +89,7 @@
"react-native-uuid": "^2.0.3",
"react-native-vector-icons": "^10.2.0",
"ruby": "^0.6.1",
"tamagui": "^1.132.7"
"tamagui": "^1.132.10"
},
"devDependencies": {
"@babel/core": "^7.28.0",
@@ -110,9 +110,9 @@
"@types/react-test-renderer": "19.1.0",
"babel-plugin-module-resolver": "^5.0.2",
"eslint": "^9.31.0",
"eslint-config-prettier": "^10.1.5",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-native": "^5.0.0",
"globals": "^16.3.0",

View File

@@ -11,7 +11,7 @@ if (!serverAddress || !username) {
process.exit(1)
}
// Function to recursively find all YAML files in maestro-tests directory
// Function to recursively find all YAML files in maestro/tests directory
function findYamlFiles(dir) {
const files = []
@@ -34,8 +34,8 @@ function findYamlFiles(dir) {
return files.sort() // Sort for consistent ordering
}
// Get all YAML files from maestro-tests directory
const MAESTRO_TESTS_DIR = './maestro-tests/flows'
// Get all YAML files from maestro/tests directory
const MAESTRO_TESTS_DIR = './maestro/tests'
const FLOW_FILES = findYamlFiles(MAESTRO_TESTS_DIR)
console.log(`🔍 Found ${FLOW_FILES.length} YAML test files:`)
@@ -44,7 +44,7 @@ FLOW_FILES.forEach((file, index) => {
})
if (FLOW_FILES.length === 0) {
console.error('❌ No YAML test files found in maestro-tests directory')
console.error('❌ No YAML test files found in maestro/testsdirectory')
process.exit(1)
}

View File

@@ -68,19 +68,15 @@ export function AlbumScreen({ route, navigation }: HomeAlbumProps): React.JSX.El
const allTracks = discs.flatMap((disc) => disc.data)
if (allTracks.length === 0) return
useLoadNewQueue.mutate(
{
track: allTracks[0],
index: 0,
tracklist: allTracks,
queue: album,
queuingType: QueuingType.FromSelection,
shuffled,
},
{
onSuccess: () => useStartPlayback.mutate(),
},
)
useLoadNewQueue({
track: allTracks[0],
index: 0,
tracklist: allTracks,
queue: album,
queuingType: QueuingType.FromSelection,
shuffled,
startPlayback: true,
})
}
return (

View File

@@ -15,7 +15,6 @@ import { StackParamList } from '../types'
import React from 'react'
import Icon from '../Global/components/icon'
import { useQueueContext } from '../../providers/Player/queue'
import { usePlayerContext } from '../../providers/Player'
import { QueuingType } from '../../enums/queuing-type'
import { fetchAlbumDiscs } from '../../api/queries/item'
@@ -26,7 +25,6 @@ export default function ArtistTabBar(
const { api } = useJellifyContext()
const { artist, scroll, albums } = useArtistContext()
const { useLoadNewQueue } = useQueueContext()
const { useStartPlayback } = usePlayerContext()
const { width } = useSafeAreaFrame()
@@ -47,19 +45,15 @@ export default function ArtistTabBar(
if (allTracks.length === 0) return
useLoadNewQueue.mutate(
{
track: allTracks[0],
index: 0,
tracklist: allTracks,
queue: artist,
queuingType: QueuingType.FromSelection,
shuffled,
},
{
onSuccess: () => useStartPlayback.mutate(),
},
)
useLoadNewQueue({
track: allTracks[0],
index: 0,
tracklist: allTracks,
queue: artist,
queuingType: QueuingType.FromSelection,
shuffled,
startPlayback: true,
})
} catch (error) {
console.error('Failed to play artist tracks:', error)
}

View File

@@ -5,9 +5,6 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { Text } from '../helpers/text'
import FastImage from 'react-native-fast-image'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../../enums/query-keys'
import { fetchMediaInfo } from '../../../api/queries/media'
import { useJellifyContext } from '../../../providers'
interface CardProps extends TamaguiCardProps {
caption?: string | null | undefined
@@ -25,14 +22,7 @@ interface CardProps extends TamaguiCardProps {
* @param props
*/
export function ItemCard(props: CardProps) {
const { api, user } = useJellifyContext()
const mediaInfo = useQuery({
queryKey: [QueryKeys.MediaSources, props.item.Id!],
queryFn: () => fetchMediaInfo(api, user, props.item),
staleTime: Infinity,
enabled: props.item.Type === 'Audio',
})
const { api } = useJellifyContext()
return (
<View alignItems='center' margin={'$1.5'}>

View File

@@ -37,24 +37,19 @@ export default function ItemRow({
onPress?: () => void
circular?: boolean
}): React.JSX.Element {
const { useStartPlayback } = usePlayerContext()
const { useLoadNewQueue } = useQueueContext()
const gestureCallback = () => {
switch (item.Type) {
case 'Audio': {
useLoadNewQueue.mutate(
{
track: item,
tracklist: [item],
index: 0,
queue: 'Search',
queuingType: QueuingType.FromSelection,
},
{
onSuccess: () => useStartPlayback.mutate(),
},
)
useLoadNewQueue({
track: item,
tracklist: [item],
index: 0,
queue: 'Search',
queuingType: QueuingType.FromSelection,
startPlayback: true,
})
break
}
default: {

View File

@@ -16,7 +16,6 @@ import { networkStatusTypes } from '../../../components/Network/internetConnecti
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 '../../../providers/Player/queue'
import { fetchItem } from '../../../api/queries/item'
import { useJellifyContext } from '../../../providers'
@@ -67,22 +66,6 @@ export default function Track({
const isOffline = networkStatus === networkStatusTypes.DISCONNECTED
// Fetch media info so it's available in the player
const mediaInfo = useQuery({
queryKey: [QueryKeys.MediaSources, track.Id!],
queryFn: () => fetchMediaInfo(api, user, track),
staleTime: Infinity,
enabled: track.Type === 'Audio',
})
// Fetch album so it's available in the Details screen
const { data: album } = useQuery({
queryKey: [QueryKeys.Item, track.Id!], // Different key
queryFn: () => fetchItem(api, track.Id!),
staleTime: 60 * 60 * 1000 * 24, // 24 hours
enabled: !!track.Id, // Add proper enabled condition
})
return (
<Theme name={invertedColors ? 'inverted_purple' : undefined}>
<XStack
@@ -95,18 +78,14 @@ export default function Track({
if (onPress) {
onPress()
} else {
useLoadNewQueue.mutate(
{
track,
index,
tracklist: tracklist ?? playQueue.map((track) => track.item),
queue,
queuingType: QueuingType.FromSelection,
},
{
onSuccess: () => useStartPlayback.mutate(),
},
)
useLoadNewQueue({
track,
index,
tracklist: tracklist ?? playQueue.map((track) => track.item),
queue,
queuingType: QueuingType.FromSelection,
startPlayback: true,
})
}
}}
onLongPress={

View File

@@ -8,7 +8,6 @@ import { QueuingType } from '../../../enums/queuing-type'
import { trigger } from 'react-native-haptic-feedback'
import Icon from '../../Global/components/icon'
import { useQueueContext } from '../../../providers/Player/queue'
import { usePlayerContext } from '../../../providers/Player'
import { H4 } from '../../../components/Global/helpers/text'
import { useDisplayContext } from '../../../providers/Display/display-provider'
export default function FrequentlyPlayedTracks({
@@ -23,7 +22,6 @@ export default function FrequentlyPlayedTracks({
isFetchingFrequentlyPlayed,
} = useHomeContext()
const { useStartPlayback } = usePlayerContext()
const { useLoadNewQueue } = useQueueContext()
const { horizontalItems } = useDisplayContext()
@@ -58,20 +56,16 @@ export default function FrequentlyPlayedTracks({
subCaption={`${track.Artists?.join(', ')}`}
squared
onPress={() => {
useLoadNewQueue.mutate(
{
useLoadNewQueue({
track,
index,
tracklist: frequentlyPlayed?.pages.flatMap((page) => page) ?? [
track,
index,
tracklist: frequentlyPlayed?.pages.flatMap((page) => page) ?? [
track,
],
queue: 'On Repeat',
queuingType: QueuingType.FromSelection,
},
{
onSuccess: () => useStartPlayback.mutate(),
},
)
],
queue: 'On Repeat',
queuingType: QueuingType.FromSelection,
startPlayback: true,
})
}}
onLongPress={() => {
trigger('impactMedium')

View File

@@ -18,7 +18,7 @@ export default function RecentlyPlayed({
}: {
navigation: NativeStackNavigationProp<StackParamList>
}): React.JSX.Element {
const { nowPlaying, useStartPlayback } = usePlayerContext()
const { nowPlaying } = usePlayerContext()
const { useLoadNewQueue } = useQueueContext()
@@ -59,20 +59,16 @@ export default function RecentlyPlayed({
testId={`recently-played-${index}`}
item={recentlyPlayedTrack}
onPress={() => {
useLoadNewQueue.mutate(
{
track: recentlyPlayedTrack,
index: index,
tracklist: recentTracks?.pages.flatMap((page) => page) ?? [
recentlyPlayedTrack,
],
queue: 'Recently Played',
queuingType: QueuingType.FromSelection,
},
{
onSuccess: () => useStartPlayback.mutate(),
},
)
useLoadNewQueue({
track: recentlyPlayedTrack,
index: index,
tracklist: recentTracks?.pages.flatMap((page) => page) ?? [
recentlyPlayedTrack,
],
queue: 'Recently Played',
queuingType: QueuingType.FromSelection,
startPlayback: true,
})
}}
onLongPress={() => {
trigger('impactMedium')

View File

@@ -21,7 +21,7 @@ export default function Scrubber(): React.JSX.Element {
const { width } = useSafeAreaFrame()
// Get progress from the track player with the specified update interval
const progress = useProgress(UPDATE_INTERVAL, false)
const { position, duration } = useProgress(UPDATE_INTERVAL)
// Single source of truth for the current position
const [displayPosition, setDisplayPosition] = useState<number>(0)
@@ -33,16 +33,13 @@ export default function Scrubber(): React.JSX.Element {
// Calculate maximum track duration in slider units
const maxDuration = useMemo(() => {
return progress?.duration
? Math.round(progress.duration * ProgressMultiplier)
: ProgressMultiplier
}, [progress?.duration])
return Math.round(duration * ProgressMultiplier)
}, [duration])
// Calculate current position in slider units
const calculatedPosition = useMemo(() => {
if (!progress?.position) return 0
return Math.round(progress.position * ProgressMultiplier)
}, [progress?.position])
return Math.round(position * ProgressMultiplier)
}, [position])
// Update display position from playback progress
useEffect(() => {
@@ -93,8 +90,8 @@ export default function Scrubber(): React.JSX.Element {
// Get total duration in seconds
const totalSeconds = useMemo(() => {
return progress?.duration ? Math.round(progress.duration) : 0
}, [progress?.duration])
return Math.round(duration)
}, [duration])
return (
<GestureDetector gesture={scrubGesture}>

View File

@@ -10,6 +10,7 @@ import Animated from 'react-native-reanimated'
import { Gesture } from 'react-native-gesture-handler'
import { useState } from 'react'
import { trigger } from 'react-native-haptic-feedback'
import { isUndefined } from 'lodash'
const gesture = Gesture.Pan().runOnJS(true)
@@ -95,7 +96,8 @@ export default function Queue({
showArtwork
testID={`queue-item-${getIndex()}`}
onPress={() => {
useSkip.mutate(getIndex())
const index = getIndex()
if (!isUndefined(index)) useSkip.mutate(index)
}}
onLongPress={() => {
trigger('impactLight')
@@ -104,7 +106,8 @@ export default function Queue({
isNested
showRemove
onRemove={() => {
if (getIndex()) useRemoveFromQueue.mutate(getIndex()!)
const index = getIndex()
if (!isUndefined(index)) useRemoveFromQueue.mutate(index)
}}
/>
</XStack>

View File

@@ -17,7 +17,6 @@ import { useSettingsContext } from '../../../../src/providers/Settings'
import { ActivityIndicator } from 'react-native'
import { mapDtoToTrack } from '../../../utils/mappings'
import { useQueueContext } from '../../../providers/Player/queue'
import { usePlayerContext } from '../../../providers/Player'
import { QueuingType } from '../../../enums/queuing-type'
export default function PlayliistTracklistHeader(
@@ -150,7 +149,6 @@ function PlaylistHeaderControls({
const { useDownloadMultiple, pendingDownloads } = useNetworkContext()
const { downloadQuality, streamingQuality } = useSettingsContext()
const { useLoadNewQueue } = useQueueContext()
const { useStartPlayback } = usePlayerContext()
const isDownloading = pendingDownloads.length != 0
const { sessionId, api } = useJellifyContext()
@@ -165,19 +163,15 @@ function PlaylistHeaderControls({
const playPlaylist = (shuffled: boolean = false) => {
if (!playlistTracks || playlistTracks.length === 0) return
useLoadNewQueue.mutate(
{
track: playlistTracks[0],
index: 0,
tracklist: playlistTracks,
queue: playlist,
queuingType: QueuingType.FromSelection,
shuffled,
},
{
onSuccess: () => useStartPlayback.mutate(),
},
)
useLoadNewQueue({
track: playlistTracks[0],
index: 0,
tracklist: playlistTracks,
queue: playlist,
queuingType: QueuingType.FromSelection,
shuffled,
startPlayback: true,
})
}
return (

View File

@@ -38,7 +38,7 @@ export default function InfoTabIndex({ navigation }: InfoTabNativeStackNavigatio
title: `Jellify`,
subTitle: version,
iconName: 'jellyfish',
iconColor: '$borderColor',
iconColor: '$secondary',
children: (
<YStack gap={'$2'}>
<Text

View File

@@ -14,22 +14,11 @@ export default function PlaybackTab(): React.JSX.Element {
return (
<SettingsListGroup
settingsList={[
{
title: 'Gapless Playback',
subTitle: 'Seamless transitions between tracks',
iconName: 'skip-next',
iconColor: '$borderColor',
children: (
<Text fontSize='$3' color='$color10' padding='$3'>
Gapless playback is automatically enabled for smooth music transitions.
</Text>
),
},
{
title: 'Streaming Quality',
subTitle: `Current: ${getQualityLabel(streamingQuality)}${getBandwidthEstimate(streamingQuality)}`,
iconName: 'wifi',
iconColor: '$primary',
iconName: 'sine-wave',
iconColor: getStreamingQualityIconColor(streamingQuality),
children: (
<YStack gap='$2' paddingVertical='$2'>
<Text bold fontSize='$4'>
@@ -72,3 +61,18 @@ export default function PlaybackTab(): React.JSX.Element {
/>
)
}
function getStreamingQualityIconColor(streamingQuality: StreamingQuality): string {
switch (streamingQuality) {
case 'original':
return '$success'
case 'high':
return '$success'
case 'medium':
return '$secondary'
case 'low':
return '$danger'
default:
return '$borderColor'
}
}

View File

View File

@@ -50,7 +50,7 @@ interface PlayerContext {
shuffled: boolean
useToggleRepeatMode: UseMutationResult<void, Error, void, unknown>
useToggleShuffle: UseMutationResult<void, Error, void, unknown>
useStartPlayback: UseMutationResult<void, Error, void, unknown>
useStartPlayback: () => void
useTogglePlayback: UseMutationResult<void, Error, void, unknown>
useSeekTo: UseMutationResult<void, Error, number, unknown>
useSeekBy: UseMutationResult<void, Error, number, unknown>
@@ -358,7 +358,7 @@ const PlayerContextInitializer = () => {
/**
* A mutation to handle starting playback
*/
const useStartPlayback = useMutation({
const { mutate: useStartPlayback } = useMutation({
mutationFn: TrackPlayer.play,
})
@@ -661,24 +661,7 @@ export const PlayerContext = createContext<PlayerContext>({
submittedAt: 0,
},
playbackState: undefined,
useStartPlayback: {
mutate: () => {},
mutateAsync: async () => {},
data: undefined,
error: null,
variables: undefined,
isError: false,
isIdle: true,
isPaused: false,
isPending: false,
isSuccess: false,
status: 'idle',
reset: () => {},
context: {},
failureCount: 0,
failureReason: null,
submittedAt: 0,
},
useStartPlayback: () => {},
useTogglePlayback: {
mutate: () => {},
mutateAsync: async () => {},

View File

@@ -1,7 +1,6 @@
import JellifyTrack from '../types/JellifyTrack'
import { QueuingType } from '../enums/queuing-type'
import { QueuingType } from '../../enums/queuing-type'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { Queue } from './types/queue-item'
import { Queue } from '../../player/types/queue-item'
/**
* A mutation to handle loading a new queue.
@@ -34,6 +33,11 @@ export interface QueueMutation {
* Whether the queue should be shuffled.
*/
shuffled?: boolean | undefined
/**
* Whether to start playback immediately.
*/
startPlayback?: boolean | undefined
}
/**

View File

@@ -3,7 +3,7 @@ import { createContext } from 'react'
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 '../../player/interfaces'
import { AddToQueueMutation, QueueMutation, QueueOrderMutation } from './interfaces'
import { storage } from '../../constants/storage'
import { MMKVStorageKeys } from '../../enums/mmkv-storage-keys'
import JellifyTrack from '../../types/JellifyTrack'
@@ -14,7 +14,6 @@ import { useSettingsContext } from '../Settings'
import { QueuingType } from '../../enums/queuing-type'
import TrackPlayer, { Event, useTrackPlayerEvents } from 'react-native-track-player'
import { findPlayQueueIndexStart } from './utils'
import { play, seekTo } from 'react-native-track-player/lib/src/trackPlayer'
import { trigger } from 'react-native-haptic-feedback'
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
@@ -27,6 +26,7 @@ import Toast from 'react-native-toast-message'
import { useJellifyContext } from '..'
import { networkStatusTypes } from '@/src/components/Network/internetConnectionWatcher'
import move from './utils/move'
import { ensureUpcomingTracksInQueue } from '../../player/helpers/gapless'
/**
* @description The context for managing the queue
@@ -76,7 +76,7 @@ interface QueueContext {
/**
* A hook that loads a new queue of tracks
*/
useLoadNewQueue: UseMutationResult<void, Error, QueueMutation, unknown>
useLoadNewQueue: (mutation: QueueMutation) => void
/**
* A hook that removes upcoming tracks from the queue
@@ -173,9 +173,7 @@ const QueueContextInitailizer = () => {
//#endregion Context
useTrackPlayerEvents([Event.PlaybackActiveTrackChanged], async ({ index, track }) => {
console.debug('Active track changed')
useTrackPlayerEvents([Event.PlaybackActiveTrackChanged], async ({ track }) => {
let newIndex = -1
if (!isUndefined(track)) {
@@ -191,26 +189,13 @@ const QueueContextInitailizer = () => {
if (!isUndefined(itemIndex) && itemIndex !== -1) {
newIndex = itemIndex
console.debug(`Active track changed to index ${itemIndex}`)
console.debug(`Active track changed to item at index: ${itemIndex}`)
// Ensure upcoming tracks are in correct order (important for shuffle)
// try {
// const { ensureUpcomingTracksInQueue } = await import(
// '../../player/helpers/gapless'
// )
// await ensureUpcomingTracksInQueue(playQueue, index)
// } catch (error) {
// console.debug('Failed to ensure upcoming tracks on track change:', error)
// }
} else if (!isUndefined(index) && index !== -1) {
newIndex = index
console.debug(`Active track changed to index ${index}`)
await ensureUpcomingTracksInQueue(playQueue, itemIndex)
} else {
console.warn('No index found for active track')
}
} else if (!isUndefined(index) && index !== -1) {
newIndex = index
console.debug(`Active track changed to index ${index}`)
} else {
console.warn('No active track found')
}
@@ -358,7 +343,7 @@ const QueueContextInitailizer = () => {
`Queued ${queue.length} tracks, starting at ${finalStartIndex}${shuffleQueue ? ' (shuffled)' : ''}`,
)
await play()
await TrackPlayer.play()
}
/**
@@ -452,7 +437,7 @@ const QueueContextInitailizer = () => {
if (currentIndex > 0 && Math.floor(position) < SKIP_TO_PREVIOUS_THRESHOLD) {
TrackPlayer.skipToPrevious()
} else await seekTo(0)
} else await TrackPlayer.seekTo(0)
}
const skip = async (index?: number | undefined) => {
@@ -532,7 +517,7 @@ const QueueContextInitailizer = () => {
},
})
const useLoadNewQueue = useMutation({
const { mutate: useLoadNewQueue } = useMutation({
mutationFn: async ({
index,
track,
@@ -541,9 +526,11 @@ const QueueContextInitailizer = () => {
queue,
shuffled,
}: QueueMutation) => loadQueue(tracklist, queue, index, shuffled),
onSuccess: async (data, { queue }: QueueMutation) => {
onSuccess: async (data, { queue, startPlayback }: QueueMutation) => {
trigger('notificationSuccess')
startPlayback && (await TrackPlayer.play())
if (typeof queue === 'object' && api && user) await markItemPlayed(api, user, queue)
},
})
@@ -736,24 +723,7 @@ export const QueueContext = createContext<QueueContext>({
failureReason: null,
submittedAt: 0,
},
useLoadNewQueue: {
mutate: () => {},
mutateAsync: async () => {},
data: undefined,
error: null,
variables: undefined,
isError: false,
isIdle: true,
isPaused: false,
isPending: false,
isSuccess: false,
status: 'idle',
reset: () => {},
context: {},
failureCount: 0,
failureReason: null,
submittedAt: 0,
},
useLoadNewQueue: () => {},
useSkip: {
mutate: () => {},
mutateAsync: async () => {},

View File

@@ -1,17 +1,22 @@
import { isEmpty } from 'lodash'
import { isEmpty, isUndefined } from 'lodash'
import { QueuingType } from '../../../enums/queuing-type'
import JellifyTrack from '../../../types/JellifyTrack'
import { getActiveTrackIndex } from 'react-native-track-player/lib/src/trackPlayer'
import TrackPlayer from 'react-native-track-player'
/**
* Finds and returns the index of the player queue to insert additional tracks into
* @param playQueue The current player queue
* @returns The index to insert songs to play next at
*/
export const findPlayNextIndexStart = async (playQueue: JellifyTrack[]) => {
export async function findPlayNextIndexStart(playQueue: JellifyTrack[]) {
if (isEmpty(playQueue)) return 0
return (await getActiveTrackIndex())! + 1
const activeTrack = (await TrackPlayer.getActiveTrack()) as JellifyTrack
const activeIndex = playQueue.findIndex((track) => track.item.Id === activeTrack?.item.Id)
if (isUndefined(activeTrack) || activeIndex === -1) return 0
else return activeIndex + 1
}
/**
@@ -19,16 +24,20 @@ export const findPlayNextIndexStart = async (playQueue: JellifyTrack[]) => {
* @param playQueue The current player queue
* @returns The index to insert songs to add to the user queue
*/
export const findPlayQueueIndexStart = async (playQueue: JellifyTrack[]) => {
export async function findPlayQueueIndexStart(playQueue: JellifyTrack[]) {
if (isEmpty(playQueue)) return 0
const activeIndex = await getActiveTrackIndex()
const activeTrack = (await TrackPlayer.getActiveTrack()) as JellifyTrack
if (playQueue.findIndex((track) => track.QueuingType === QueuingType.FromSelection) === -1)
return activeIndex! + 1
const activeIndex = playQueue.findIndex((track) => track.item.Id === activeTrack?.item.Id)
return playQueue.findIndex(
(queuedTrack, index) =>
queuedTrack.QueuingType === QueuingType.FromSelection && index > activeIndex!,
if (isUndefined(activeTrack) || activeIndex === -1) return 0
const insertIndex = playQueue.findIndex(
({ QueuingType: queuingType, index }) =>
queuingType === QueuingType.FromSelection && index > activeIndex,
)
if (insertIndex === -1) return playQueue.length
else return insertIndex
}

View File

@@ -79,6 +79,7 @@ export function Tabs({
name='music-box-multiple'
color={color}
size={size}
testID='library-tab-icon'
/>
),
}}
@@ -106,7 +107,12 @@ export function Tabs({
options={{
headerShown: false,
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons name='earth' color={color} size={size} />
<MaterialCommunityIcons
name='earth'
color={color}
size={size}
testID='discover-tab-icon'
/>
),
}}
/>

View File

@@ -4,16 +4,16 @@ import {
PlaybackInfoResponse,
} from '@jellyfin/sdk/lib/generated-client/models'
import JellifyTrack from '../types/JellifyTrack'
import { RatingType, TrackType } from 'react-native-track-player'
import { TrackType } from 'react-native-track-player'
import { QueuingType } from '../enums/queuing-type'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import { isUndefined } from 'lodash'
import { JellifyDownload } from '../types/JellifyDownload'
import { queryClient } from '../constants/query-client'
import { QueryKeys } from '../enums/query-keys'
import { Api } from '@jellyfin/sdk/lib/api'
import RNFS from 'react-native-fs'
import { DownloadQuality, StreamingQuality } from '../providers/Settings'
import { Platform } from 'react-native'
/**
* The container that the Jellyfin server will attempt to transcode to
@@ -25,6 +25,16 @@ import { DownloadQuality, StreamingQuality } from '../providers/Settings'
*/
const transcodingContainer = 'ts'
/**
* The type of track to use for the player
*
* iOS can use HLS, Android can't - and therefore uses Default
*
* Why? I'm not sure - someone way smarter than me can probably explain it
* - Violet Caulfield - 2025-07-20
*/
const type = Platform.OS === 'ios' ? TrackType.HLS : TrackType.Default
/**
* Gets quality-specific parameters for transcoding
*
@@ -97,15 +107,16 @@ export function mapDtoToTrack(
console.debug(
`Mapping BaseItemDTO to Track object with streaming quality: ${qualityForStreaming}`,
)
const isFavorite = !isUndefined(item.UserData) && (item.UserData.IsFavorite ?? false)
const downloads = downloadedTracks.filter((download) => download.item.Id === item.Id)
let url: string
let image: string | undefined
if (downloads.length > 0 && downloads[0].path)
if (downloads.length > 0 && downloads[0].path) {
url = `file://${RNFS.DocumentDirectoryPath}/${downloads[0].path.split('/').pop()}`
else {
image = `file://${RNFS.DocumentDirectoryPath}/${downloads[0].artwork?.split('/').pop()}`
} else {
const PlaybackInfoResponse = queryClient.getQueryData([
QueryKeys.MediaSources,
item.Id!,
@@ -118,12 +129,15 @@ export function mapDtoToTrack(
)
url = PlaybackInfoResponse.MediaSources![0].TranscodingUrl
else url = `${api.basePath}/Audio/${item.Id!}/universal?${new URLSearchParams(urlParams)}`
image = item.AlbumId
? getImageApi(api).getItemImageUrlById(item.AlbumId, ImageType.Primary)
: undefined
}
console.debug(url.length)
return {
url,
type: TrackType.Default,
type,
headers: {
'X-Emby-Token': api.accessToken,
},
@@ -131,14 +145,7 @@ export function mapDtoToTrack(
album: item.Album,
artist: item.Artists?.join(', '),
duration: item.RunTimeTicks,
artwork: item.AlbumId
? getImageApi(api).getItemImageUrlById(item.AlbumId, ImageType.Primary, {
width: 300,
height: 300,
})
: undefined,
rating: isFavorite ? RatingType.Heart : undefined,
artwork: image,
item,
QueuingType: queuingType ?? QueuingType.DirectlyQueued,
} as JellifyTrack

1816
yarn.lock

File diff suppressed because it is too large Load Diff