mirror of
https://github.com/Jellify-Music/App.git
synced 2026-01-15 07:20:51 -06:00
Merge branch 'main' of github.com:Jellify-Music/App
This commit is contained in:
2
.github/workflows/build-android.yml
vendored
2
.github/workflows/build-android.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
~/.cache/turbo
|
||||
android/.gradle
|
||||
android/app/build
|
||||
key: ${{ runner.os }}-gradle-turbo-${{ hashFiles('**/yarn.lock', '**/build.gradle', '**/package.json', 'android/gradle/wrapper/**') }}
|
||||
key: ${{ runner.os }}-gradle-turbo-${{ hashFiles('**/yarn.lock', '**/build.gradle') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-turbo-
|
||||
|
||||
|
||||
10
.github/workflows/build-ios.yml
vendored
10
.github/workflows/build-ios.yml
vendored
@@ -4,11 +4,11 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'ios/**'
|
||||
- Gemfile
|
||||
- package.json
|
||||
- yarn.lock
|
||||
- .github/workflows/build-ios.yml
|
||||
- .github/workflows/publish-beta.yml
|
||||
- 'Gemfile'
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
- '.github/workflows/build-ios.yml'
|
||||
- '.github/workflows/publish-beta.yml'
|
||||
|
||||
jobs:
|
||||
build-ios:
|
||||
|
||||
2
.github/workflows/maestro-test.yml
vendored
2
.github/workflows/maestro-test.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
~/.cache/turbo
|
||||
android/.gradle
|
||||
android/app/build
|
||||
key: ${{ runner.os }}-gradle-turbo-${{ hashFiles('**/yarn.lock', '**/build.gradle', '**/package.json', 'android/gradle/wrapper/**') }}
|
||||
key: ${{ runner.os }}-gradle-turbo-${{ hashFiles('**/yarn.lock', '**/build.gradle') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-turbo-
|
||||
|
||||
|
||||
@@ -95,8 +95,8 @@ android {
|
||||
applicationId "com.jellify"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 87
|
||||
versionName "0.14.3"
|
||||
versionCode 88
|
||||
versionName "0.14.4"
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
|
||||
@@ -667,7 +667,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 199;
|
||||
CURRENT_PROJECT_VERSION = 200;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -678,7 +678,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.14.3;
|
||||
MARKETING_VERSION = 0.14.4;
|
||||
NEW_SETTING = "";
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
@@ -709,7 +709,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 199;
|
||||
CURRENT_PROJECT_VERSION = 200;
|
||||
DEVELOPMENT_TEAM = WAH9CZ8BPG;
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -719,7 +719,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.14.3;
|
||||
MARKETING_VERSION = 0.14.4;
|
||||
NEW_SETTING = "";
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
42
jest/functional/Move-Track.test.ts
Normal file
42
jest/functional/Move-Track.test.ts
Normal 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' } },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
146
jest/functional/Player-Index.test.ts
Normal file
146
jest/functional/Player-Index.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
@@ -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),
|
||||
}))
|
||||
@@ -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', () => {
|
||||
@@ -1,9 +0,0 @@
|
||||
appId: com.jellify
|
||||
---
|
||||
- clearState # clears the state of the current app
|
||||
- launchApp
|
||||
- runFlow: login.yaml
|
||||
- launchApp
|
||||
- runFlow: musicplayer.yaml
|
||||
- runFlow: search.yaml
|
||||
- runFlow: settings.yaml
|
||||
7
maestro/flows/flow-0.yaml
Normal file
7
maestro/flows/flow-0.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
appId: com.jellify
|
||||
---
|
||||
- clearState # clears the state of the current app
|
||||
- launchApp
|
||||
- runFlow: ../tests/login.yaml
|
||||
- runFlow: ../tests/musicplayer.yaml
|
||||
- runFlow: ../tests/search.yaml
|
||||
3
maestro/flows/flow-1.yaml
Normal file
3
maestro/flows/flow-1.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
appId: com.jellify
|
||||
---
|
||||
- runFlow: ../tests/settings.yaml
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
appId: com.jellify
|
||||
---
|
||||
# Navigate to the home screen
|
||||
- assertVisible:
|
||||
id: "home-tab-icon"
|
||||
|
||||
- tapOn:
|
||||
id: "home-tab-icon"
|
||||
|
||||
@@ -23,8 +23,6 @@ appId: com.jellify
|
||||
# Test Player (Playback) Tab
|
||||
- tapOn:
|
||||
text: "Player"
|
||||
- assertVisible:
|
||||
text: "Gapless Playback"
|
||||
- assertVisible:
|
||||
text: "Streaming Quality"
|
||||
|
||||
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellify",
|
||||
"version": "0.14.3",
|
||||
"version": "0.14.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"init-android": "yarn install --network-concurrency 1",
|
||||
@@ -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",
|
||||
@@ -137,4 +137,4 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const { execSync, exec, spawn } = require('child_process')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
// Read arguments from CLI
|
||||
const [, , serverAddress, username] = process.argv
|
||||
@@ -10,11 +11,48 @@ if (!serverAddress || !username) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Function to recursively find all YAML files in maestro/tests directory
|
||||
function findYamlFiles(dir) {
|
||||
const files = []
|
||||
|
||||
function scanDirectory(currentDir) {
|
||||
const items = fs.readdirSync(currentDir)
|
||||
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(currentDir, item)
|
||||
const stat = fs.statSync(fullPath)
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
scanDirectory(fullPath)
|
||||
} else if (item.endsWith('.yaml') || item.endsWith('.yml')) {
|
||||
files.push(fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scanDirectory(dir)
|
||||
return files.sort() // Sort for consistent ordering
|
||||
}
|
||||
|
||||
// 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:`)
|
||||
FLOW_FILES.forEach((file, index) => {
|
||||
console.log(` ${index + 1}. ${file}`)
|
||||
})
|
||||
|
||||
if (FLOW_FILES.length === 0) {
|
||||
console.error('❌ No YAML test files found in maestro/testsdirectory')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function stopRecording(pid) {
|
||||
async function stopRecording(pid, videoName, deviceVideoPath) {
|
||||
try {
|
||||
// Kill the adb screenrecord process
|
||||
process.kill(pid, 'SIGINT')
|
||||
@@ -22,28 +60,31 @@ async function stopRecording(pid) {
|
||||
// Wait 3 seconds for file to finalize
|
||||
await sleep(3000)
|
||||
|
||||
// Pull the recorded file
|
||||
execSync('adb pull /sdcard/screen.mp4 video.mp4', { stdio: 'inherit' })
|
||||
// Pull the recorded file with custom name
|
||||
execSync(`adb pull ${deviceVideoPath} ${videoName}`, { stdio: 'inherit' })
|
||||
|
||||
// Optionally delete the file on device
|
||||
execSync('adb shell rm /sdcard/screen.mp4')
|
||||
execSync(`adb shell rm ${deviceVideoPath}`)
|
||||
|
||||
console.log('✅ Recording pulled and cleaned up')
|
||||
console.log(`✅ Recording pulled and saved as ${videoName}`)
|
||||
} catch (err) {
|
||||
console.error('❌ Failed to stop or pull recording:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
execSync('adb install ./artifacts/app-universal-release.apk', {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
})
|
||||
execSync(`adb shell monkey -p com.jellify 1`, { stdio: 'inherit' })
|
||||
async function runSingleTest(flowPath, serverAddress, username, testIndex) {
|
||||
const flowName = path.basename(flowPath, '.yaml')
|
||||
const relativePath = path.relative(MAESTRO_TESTS_DIR, flowPath)
|
||||
const videoName = `test_${testIndex}_${flowName}.mp4`
|
||||
const deviceVideoPath = `/sdcard/screen_${testIndex}_${flowName}.mp4`
|
||||
|
||||
console.log(`\n🚀 Starting test ${testIndex + 1}/${FLOW_FILES.length}: ${relativePath}`)
|
||||
console.log(`📹 Video will be saved as: ${videoName}`)
|
||||
|
||||
// Start screen recording
|
||||
const recording = spawn(
|
||||
'adb',
|
||||
['shell', 'screenrecord', '--time-limit=1800', '/sdcard/screen.mp4'],
|
||||
['shell', 'screenrecord', '--time-limit=1800', deviceVideoPath],
|
||||
{
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
@@ -53,21 +94,85 @@ async function stopRecording(pid) {
|
||||
|
||||
try {
|
||||
const MAESTRO_PATH = path.join(process.env.HOME, '.maestro', 'bin', 'maestro')
|
||||
const FLOW_PATH = './maestro-tests/flow.yaml'
|
||||
|
||||
const command = `${MAESTRO_PATH} test ${FLOW_PATH} \
|
||||
const command = `${MAESTRO_PATH} test ${flowPath} \
|
||||
--env server_address=${serverAddress} \
|
||||
--env username=${username}`
|
||||
|
||||
const output = execSync(command, { stdio: 'inherit', env: process.env })
|
||||
console.log('✅ Maestro test completed')
|
||||
console.log(output)
|
||||
await stopRecording(pid)
|
||||
process.exit(0)
|
||||
console.log(`✅ Test ${testIndex + 1} (${relativePath}) completed successfully`)
|
||||
|
||||
await stopRecording(pid, videoName, deviceVideoPath)
|
||||
return { success: true, flowName, relativePath, videoName }
|
||||
} catch (error) {
|
||||
await stopRecording(pid)
|
||||
execSync('pwd', { stdio: 'inherit' })
|
||||
console.error(`❌ Error: ${error.message}`)
|
||||
await stopRecording(pid, videoName, deviceVideoPath)
|
||||
console.error(`❌ Test ${testIndex + 1} (${relativePath}) failed: ${error.message}`)
|
||||
return { success: false, flowName, relativePath, videoName, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
console.log('📱 Installing app...')
|
||||
execSync('adb install ./artifacts/app-universal-release.apk', {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
})
|
||||
|
||||
console.log('🚀 Launching app...')
|
||||
execSync(`adb shell monkey -p com.jellify 1`, { stdio: 'inherit' })
|
||||
|
||||
// Wait a bit for app to launch
|
||||
await sleep(2000)
|
||||
|
||||
const results = []
|
||||
|
||||
console.log(`\n🔄 Starting test suite with ${FLOW_FILES.length} tests...`)
|
||||
|
||||
for (let i = 0; i < FLOW_FILES.length; i++) {
|
||||
const flowPath = FLOW_FILES[i]
|
||||
|
||||
// Check if flow file exists
|
||||
if (!fs.existsSync(flowPath)) {
|
||||
console.log(`⚠️ Skipping ${flowPath} - file not found`)
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await runSingleTest(flowPath, serverAddress, username, i)
|
||||
results.push(result)
|
||||
|
||||
// Wait between tests to ensure clean state
|
||||
if (i < FLOW_FILES.length - 1) {
|
||||
console.log('⏳ Waiting 3 seconds before next test...')
|
||||
await sleep(3000)
|
||||
}
|
||||
}
|
||||
|
||||
// Print summary
|
||||
console.log('\n📊 Test Results Summary:')
|
||||
console.log('========================')
|
||||
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const status = result.success ? '✅ PASS' : '❌ FAIL'
|
||||
console.log(`${index + 1}. ${result.relativePath}: ${status}`)
|
||||
if (result.success) {
|
||||
passed++
|
||||
} else {
|
||||
failed++
|
||||
console.log(` Error: ${result.error}`)
|
||||
}
|
||||
console.log(` Video: ${result.videoName}`)
|
||||
})
|
||||
|
||||
console.log(`\n📈 Final Results: ${passed} passed, ${failed} failed`)
|
||||
|
||||
if (failed === 0) {
|
||||
console.log('🎉 All tests passed!')
|
||||
process.exit(0)
|
||||
} else {
|
||||
console.log('⚠️ Some tests failed. Check the videos for details.')
|
||||
process.exit(1)
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'}>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
@@ -192,17 +192,10 @@ const QueueContextInitailizer = () => {
|
||||
|
||||
if (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)
|
||||
// }
|
||||
await ensureUpcomingTracksInQueue(playQueue, itemIndex)
|
||||
} else {
|
||||
console.warn('No index found for active track')
|
||||
}
|
||||
@@ -356,7 +349,7 @@ const QueueContextInitailizer = () => {
|
||||
`Queued ${queue.length} tracks, starting at ${finalStartIndex}${shuffleQueue ? ' (shuffled)' : ''}`,
|
||||
)
|
||||
|
||||
await play()
|
||||
await TrackPlayer.play()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -450,7 +443,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) => {
|
||||
@@ -530,7 +523,7 @@ const QueueContextInitailizer = () => {
|
||||
},
|
||||
})
|
||||
|
||||
const useLoadNewQueue = useMutation({
|
||||
const { mutate: useLoadNewQueue } = useMutation({
|
||||
mutationFn: async ({
|
||||
index,
|
||||
track,
|
||||
@@ -539,9 +532,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)
|
||||
},
|
||||
})
|
||||
@@ -734,24 +729,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 () => {},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user