Introduction of Transcoding Support (#490)

Implements proper server negotation for performing transcoding

Depending on the user's platform (i.e. iOS, Android) and the desired quality specified in the settings, Jellify will playback a transcoded audio file from Jellyfin

This is helpful when the source audio file is not compatible with the user's device (i.e. playing back an ALAC encoded .M4A on Android) or if the user want to stream at a lower quality to save bandwidth

This also drives downloads in different qualities, meaning the download quality selector in the Settings is now working properly. When a track is downloaded, it will download at the quality selected by the user, and in a format compatible with the device

There is also a toggle in the "Player" Settings for displaying a badge in the player that shows the quality and container of the audio being played
This commit is contained in:
Violet Caulfield
2025-08-28 13:04:38 -05:00
committed by GitHub
parent 36a26e6d42
commit 2961ca39bb
85 changed files with 1565 additions and 793 deletions

40
App.tsx
View File

@@ -77,9 +77,21 @@ export default function App(): React.JSX.Element {
<SafeAreaProvider>
<OTAUpdateScreen />
<ErrorBoundary reloader={reloader} onRetry={handleRetry}>
<SettingsProvider>
<Container playerIsReady={playerIsReady} />
</SettingsProvider>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: clientPersister,
/**
* Maximum query data age of one day
*/
maxAge: Infinity,
}}
>
<SettingsProvider>
<Container playerIsReady={playerIsReady} />
</SettingsProvider>
</PersistQueryClientProvider>
</ErrorBoundary>
</SafeAreaProvider>
</React.StrictMode>
@@ -104,23 +116,11 @@ function Container({ playerIsReady }: { playerIsReady: boolean }): React.JSX.Ele
: JellifyLightTheme
}
>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: clientPersister,
/**
* Maximum query data age of one day
*/
maxAge: Infinity,
}}
>
<GestureHandlerRootView>
<TamaguiProvider config={jellifyConfig}>
{playerIsReady && <Jellify />}
</TamaguiProvider>
</GestureHandlerRootView>
</PersistQueryClientProvider>
<GestureHandlerRootView>
<TamaguiProvider config={jellifyConfig}>
{playerIsReady && <Jellify />}
</TamaguiProvider>
</GestureHandlerRootView>
</NavigationContainer>
)
}

View File

@@ -225,7 +225,8 @@ Install via [Altstore](https://altstore.io) or your favorite sideloading utility
[React Native MMKV](https://github.com/mrousavy/react-native-mmkv)\
[React Native OTA Hot Update](https://github.com/vantuan88291/react-native-ota-hot-update)\
[React Native Track Player](https://github.com/doublesymmetry/react-native-track-player)\
[React Native URL Polyfill](https://github.com/charpeni/react-native-url-polyfill)
[React Native URL Polyfill](https://github.com/charpeni/react-native-url-polyfill)\
[Zustand](https://github.com/pmndrs/zustand)
### 👩‍💻 Opt-In Monitoring
@@ -253,9 +254,10 @@ This allows me to prioritize specific features, acquire additional hardware for
- The [Jellyfin Team](https://jellyfin.org/) for making this possible with their software, SDKs, and unequivocal helpfulness.
- Extra thanks to [Niels](https://github.com/nielsvanvelzen) and [Bill](https://github.com/thornbill)
- They taught me the ways of the AudioAPI and how to do audio transcoding with Jellyfin
- [James](https://github.com/jmshrv), [Chaphasilor](https://github.com/Chaphasilor) and all other contributors of [Finamp](https://github.com/jmshrv/finamp) - another music app for Jellyfin
- James [API Blog Post](https://jmshrv.com/posts/jellyfin-api/) proved to be exceptionally valuable during development
- Chaphasilor taught me everything they know about audio normalization and LUFS
- Chaphasilor taught me everything they know about audio normalization and LUFS, and their math was referenced in _Jellify_'s audio normalization algorithm
- Marc and the rest of the [Margelo Community](https://discord.com/invite/6CSHz2qAvA) for their amazing modules and support
- [Nicolas Charpentier](https://github.com/charpeni) for his [React Native URL Polyfill](https://github.com/charpeni/react-native-url-polyfill) module and for his assistance with getting Jest working
- The team behind [Podverse](https://github.com/podverse/podverse-rn) for their incredible open source project, of which was used as a reference extensively during development

View File

@@ -4,7 +4,6 @@ import { render } from '@testing-library/react-native'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { PlayerProvider } from '../../src/providers/Player'
import { View } from 'react-native'
import { JellifyProvider } from '../../src/providers'
const queryClient = new QueryClient()

View File

@@ -1,12 +1,16 @@
import JellifyTrack from '../../src/types/JellifyTrack'
import calculateTrackVolume from '../../src/providers/Player/utils/normalization'
describe('Normalization Module', () => {
it('should calculate the volume for a track with a normalization gain of 6', () => {
const track = {
const track: JellifyTrack = {
url: 'https://example.com/track.mp3',
item: {
NormalizationGain: 6, // 6 Gain means the track is quieter than the target volume
},
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
}
const volume = calculateTrackVolume(track)
@@ -15,11 +19,14 @@ describe('Normalization Module', () => {
})
it('should calculate the volume for a track with a normalization gain of 0', () => {
const track = {
const track: JellifyTrack = {
url: 'https://example.com/track.mp3',
item: {
NormalizationGain: 0, // 0 Gain means the track is at the target volume
},
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
}
const volume = calculateTrackVolume(track)
@@ -28,11 +35,14 @@ describe('Normalization Module', () => {
})
it('should calculate the volume for a track with a normalization gain of -10', () => {
const track = {
const track: JellifyTrack = {
url: 'https://example.com/track.mp3',
item: {
NormalizationGain: -10, // -10 Gain means the track is louder than the target volume
},
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
}
const volume = calculateTrackVolume(track)

View File

@@ -15,7 +15,15 @@ describe('Queue Index Util', () => {
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' } },
{
id: '1',
index: 0,
url: 'https://example.com',
item: { Id: '1' },
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
},
])
expect(result).toBe(1)
@@ -23,7 +31,15 @@ describe('Queue Index Util', () => {
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' } },
{
id: '1',
index: 0,
url: 'https://example.com',
item: { Id: '2' },
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
},
])
expect(result).toBe(0)
@@ -40,6 +56,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com',
item: { Id: '1' },
QueuingType: QueuingType.FromSelection,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
},
{
id: '2',
@@ -47,6 +66,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com',
item: { Id: '2' },
QueuingType: QueuingType.PlayingNext,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
},
{
id: '3',
@@ -54,6 +76,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com',
item: { Id: '3' },
QueuingType: QueuingType.DirectlyQueued,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
},
],
0,
@@ -71,6 +96,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com',
item: { Id: '1' },
QueuingType: QueuingType.FromSelection,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
},
{
id: '2',
@@ -78,6 +106,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com',
item: { Id: '2' },
QueuingType: QueuingType.PlayingNext,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
},
{
id: '3',
@@ -85,6 +116,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com',
item: { Id: '3' },
QueuingType: QueuingType.DirectlyQueued,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
},
{
id: '4',
@@ -92,6 +126,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com',
item: { Id: '4' },
QueuingType: QueuingType.FromSelection,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
},
{
id: '5',
@@ -99,6 +136,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com',
item: { Id: '5' },
QueuingType: QueuingType.FromSelection,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
},
],
0,
@@ -116,6 +156,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com',
item: { Id: '2' },
QueuingType: QueuingType.FromSelection,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
},
{
id: '1',
@@ -123,6 +166,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com',
item: { Id: '1' },
QueuingType: QueuingType.PlayingNext,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
},
{
id: '3',
@@ -130,6 +176,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com',
item: { Id: '3' },
QueuingType: QueuingType.DirectlyQueued,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
},
{
id: '5',
@@ -137,6 +186,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com',
item: { Id: '5' },
QueuingType: QueuingType.FromSelection,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
},
{
id: '4',
@@ -144,6 +196,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com',
item: { Id: '4' },
QueuingType: QueuingType.FromSelection,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
},
{
id: '6',
@@ -151,6 +206,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com',
item: { Id: '6' },
QueuingType: QueuingType.FromSelection,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
},
],
0,

View File

@@ -12,6 +12,9 @@ describe('Shuffle Utility Function', () => {
id: `track-${i + 1}`,
title: `Track ${i + 1}`,
artist: `Artist ${i + 1}`,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
item: {
Id: `${i + 1}`,
Name: `Track ${i + 1}`,

View File

@@ -1,5 +1,5 @@
import isPlaybackFinished from '../../src/api/mutations/playback/utils'
import { Progress } from 'react-native-track-player'
import { shouldMarkPlaybackFinished } from '../../src/providers/Player/utils/handlers'
describe('Playback Event Handlers', () => {
it('should determine that the track has finished', () => {
@@ -9,7 +9,9 @@ describe('Playback Event Handlers', () => {
buffered: 98.2345568679345,
}
const playbackFinished = shouldMarkPlaybackFinished(progress.duration, progress.position)
const { position, duration } = progress
const playbackFinished = isPlaybackFinished(position, duration)
expect(playbackFinished).toBeTruthy()
})
@@ -21,7 +23,9 @@ describe('Playback Event Handlers', () => {
buffered: 98.2345568679345,
}
const playbackFinished = shouldMarkPlaybackFinished(progress.duration, progress.position)
const { position, duration } = progress
const playbackFinished = isPlaybackFinished(position, duration)
expect(playbackFinished).toBeFalsy()
})

View File

@@ -0,0 +1,86 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { useJellifyContext } from '../../../providers'
import { useDownloadingDeviceProfile } from '../../../stores/device-profile'
import { UseMutateFunction, useMutation } from '@tanstack/react-query'
import { mapDtoToTrack } from '../../../utils/mappings'
import { deleteAudio, saveAudio } from './offlineModeUtils'
import { useState } from 'react'
import { JellifyDownloadProgress } from '../../../types/JellifyDownload'
import { useAllDownloadedTracks } from '../../queries/download'
export const useDownloadAudioItem: () => [
JellifyDownloadProgress,
UseMutateFunction<boolean, Error, { item: BaseItemDto; autoCached: boolean }, void>,
] = () => {
const { api } = useJellifyContext()
const { data: downloadedTracks, refetch } = useAllDownloadedTracks()
const deviceProfile = useDownloadingDeviceProfile()
const [downloadProgress, setDownloadProgress] = useState<JellifyDownloadProgress>({})
return [
downloadProgress,
useMutation({
onMutate: () => console.debug('Downloading audio track from Jellyfin'),
mutationFn: async ({
item,
autoCached,
}: {
item: BaseItemDto
autoCached: boolean
}) => {
if (!api) return Promise.reject('API Instance not set')
// If we already have this track downloaded, resolve the promise
if (
downloadedTracks?.filter((download) => download.item.Id === item.Id).length ??
0 > 0
)
return Promise.resolve(false)
const track = mapDtoToTrack(api, item, downloadedTracks ?? [], deviceProfile)
return saveAudio(track, setDownloadProgress, autoCached)
},
onError: (error) =>
console.error('Downloading audio track from Jellyfin failed', error),
onSuccess: (data) =>
console.error(
`${data ? 'Downloaded' : 'Did not download'} audio track from Jellyfin`,
),
onSettled: () => refetch(),
}).mutate,
]
}
export const useClearAllDownloads = () => {
const { data: downloadedTracks, refetch: refetchDownloadedTracks } = useAllDownloadedTracks()
return useMutation({
mutationFn: async () => {
return downloadedTracks?.forEach((track) => {
deleteAudio(track.item.Id)
})
},
onSuccess: () => {
refetchDownloadedTracks()
},
}).mutate
}
export const useDeleteDownloads = () => {
const { refetch } = useAllDownloadedTracks()
return useMutation({
mutationFn: async (itemIds: (string | undefined | null)[]) => {
itemIds.forEach((Id) => deleteAudio(Id))
},
onError: (error, itemIds) =>
console.error(`Unable to delete ${itemIds.length} downloads`, error),
onSuccess: (_, itemIds) =>
console.debug(`Successfully deleted ${itemIds.length} downloads`),
onSettled: () => refetch(),
}).mutate
}

View File

@@ -1,16 +1,16 @@
import { MMKV } from 'react-native-mmkv'
import RNFS from 'react-native-fs'
import JellifyTrack from '../../types/JellifyTrack'
import JellifyTrack from '../../../types/JellifyTrack'
import axios from 'axios'
import {
JellifyDownload,
JellifyDownloadProgress,
JellifyDownloadProgressState,
} from '../../types/JellifyDownload'
} from '../../../types/JellifyDownload'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { queryClient } from '../../constants/query-client'
import { QueryKeys } from '../../enums/query-keys'
import { queryClient } from '../../../constants/query-client'
import { QueryKeys } from '../../../enums/query-keys'
export async function downloadJellyfinFile(
url: string,
@@ -162,10 +162,10 @@ export const saveAudio = async (
return true
}
export const deleteAudio = async (trackItem: BaseItemDto) => {
export const deleteAudio = async (itemId: string | undefined | null) => {
const downloads = getAudioCache()
const download = downloads.filter((download) => download.item.Id === trackItem.Id)
const download = downloads.filter((download) => download.item.Id === itemId)
if (download.length === 1) {
RNFS.unlink(`${RNFS.DocumentDirectoryPath}/${download[0].item.Id}`)

View File

@@ -0,0 +1,21 @@
import { Api } from '@jellyfin/sdk'
import JellifyTrack from '../../../../types/JellifyTrack'
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api'
import { AxiosResponse } from 'axios'
export default async function reportPlaybackCompleted(
api: Api | undefined,
track: JellifyTrack,
): Promise<AxiosResponse<void, unknown>> {
if (!api) return Promise.reject('API instance not set')
const { sessionId, item, mediaSourceInfo } = track
return await getPlaystateApi(api).reportPlaybackStopped({
playbackStopInfo: {
SessionId: sessionId,
ItemId: item.Id,
PositionTicks: mediaSourceInfo?.RunTimeTicks || item.RunTimeTicks,
},
})
}

View File

@@ -0,0 +1,23 @@
import JellifyTrack from '../../../../types/JellifyTrack'
import { convertSecondsToRunTimeTicks } from '../../../../utils/runtimeticks'
import { Api } from '@jellyfin/sdk'
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api'
import { AxiosResponse } from 'axios'
export default async function reportPlaybackProgress(
api: Api | undefined,
track: JellifyTrack,
position: number,
): Promise<AxiosResponse<void, unknown>> {
if (!api) return Promise.reject('API instance not set')
const { sessionId, item } = track
return await getPlaystateApi(api).reportPlaybackProgress({
playbackProgressInfo: {
SessionId: sessionId,
ItemId: item.Id,
PositionTicks: convertSecondsToRunTimeTicks(position),
},
})
}

View File

@@ -0,0 +1,16 @@
import { Api } from '@jellyfin/sdk'
import JellifyTrack from '../../../../types/JellifyTrack'
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api'
export default async function reportPlaybackStarted(api: Api | undefined, track: JellifyTrack) {
if (!api) return Promise.reject('API instance not set')
const { sessionId, item } = track
return await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: {
SessionId: sessionId,
ItemId: item.Id,
},
})
}

View File

@@ -0,0 +1,20 @@
import { Api } from '@jellyfin/sdk'
import JellifyTrack from '../../../../types/JellifyTrack'
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api'
import { AxiosResponse } from 'axios'
export default async function reportPlaybackStopped(
api: Api | undefined,
track: JellifyTrack,
): Promise<AxiosResponse<void, unknown>> {
if (!api) return Promise.reject('API instance not set')
const { sessionId, item } = track
return await getPlaystateApi(api).reportPlaybackStopped({
playbackStopInfo: {
SessionId: sessionId,
ItemId: item.Id,
},
})
}

View File

@@ -0,0 +1,69 @@
import { useJellifyContext } from '../../../providers'
import JellifyTrack from '../../../types/JellifyTrack'
import { useMutation } from '@tanstack/react-query'
import reportPlaybackCompleted from './functions/playback-completed'
import reportPlaybackStopped from './functions/playback-stopped'
import isPlaybackFinished from './utils'
import reportPlaybackProgress from './functions/playback-progress'
import reportPlaybackStarted from './functions/playback-started'
interface PlaybackStartedMutation {
track: JellifyTrack
}
export const useReportPlaybackStarted = () => {
const { api } = useJellifyContext()
return useMutation({
onMutate: () => {},
mutationFn: async ({ track }: PlaybackStartedMutation) => reportPlaybackStarted(api, track),
onError: (error) => console.error(`Reporting playback started failed`, error),
onSuccess: () => console.debug(`Reported playback started`),
})
}
interface PlaybackStoppedMutation {
track: JellifyTrack
lastPosition: number
duration: number
}
export const useReportPlaybackStopped = () => {
const { api } = useJellifyContext()
return useMutation({
onMutate: ({ lastPosition, duration }) =>
console.debug(
`Reporting playback ${isPlaybackFinished(lastPosition, duration) ? 'completed' : 'stopped'} for track`,
),
mutationFn: async ({ track, lastPosition, duration }: PlaybackStoppedMutation) => {
return isPlaybackFinished(lastPosition, duration)
? await reportPlaybackCompleted(api, track)
: await reportPlaybackStopped(api, track)
},
onError: (error, { lastPosition, duration }) =>
console.error(
`Reporting playback ${isPlaybackFinished(lastPosition, duration) ? 'completed' : 'stopped'} failed`,
error,
),
onSuccess: (_, { lastPosition, duration }) =>
console.debug(
`Reported playback ${isPlaybackFinished(lastPosition, duration) ? 'completed' : 'stopped'} successfully`,
),
})
}
interface PlaybackProgressMutation {
track: JellifyTrack
position: number
}
export const useReportPlaybackProgress = () => {
const { api } = useJellifyContext()
return useMutation({
onMutate: ({ position }) => console.debug(`Reporting progress at ${position}`),
mutationFn: async ({ track, position }: PlaybackProgressMutation) =>
reportPlaybackProgress(api, track, position),
})
}

View File

@@ -0,0 +1,10 @@
/**
* Determines whether playback for a track was finished
*
* @param lastPosition The last known position in the track the user was at
* @param duration The duration of the track
* @returns Whether the user has made it through 80% of the track
*/
export default function isPlaybackFinished(lastPosition: number, duration: number): boolean {
return lastPosition / duration > 0.8
}

View File

@@ -9,7 +9,7 @@ import {
} from '@jellyfin/sdk/lib/generated-client/models'
import { getArtistsApi, getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { JellifyUser } from '../../types/JellifyUser'
import QueryConfig from './query.config'
import { ApiLimits } from './query.config'
export function fetchArtists(
api: Api | undefined,
@@ -31,13 +31,13 @@ export function fetchArtists(
.getAlbumArtists({
parentId: library.musicLibraryId,
userId: user.id,
enableUserData: true,
enableUserData: false, // This data is fetched lazily on component render
sortBy: sortBy,
sortOrder: sortOrder,
startIndex: page * QueryConfig.limits.library,
limit: QueryConfig.limits.library,
startIndex: page * ApiLimits.Library,
limit: ApiLimits.Library,
isFavorite: isFavorite,
fields: [ItemFields.SortName, ItemFields.ChildCount],
fields: [ItemFields.SortName],
})
.then((response) => {
console.debug('Artists Response received')

View File

@@ -0,0 +1,27 @@
import { QueryKeys } from '../../../enums/query-keys'
import { useQuery } from '@tanstack/react-query'
import fetchStorageInUse from './utils/storage-in-use'
import { getAudioCache } from '../../mutations/download/offlineModeUtils'
import DownloadQueryKeys from './keys'
export const useStorageInUse = () =>
useQuery({
queryKey: [QueryKeys.StorageInUse],
queryFn: fetchStorageInUse,
})
export const useAllDownloadedTracks = () =>
useQuery({
queryKey: [DownloadQueryKeys.DownloadedTracks],
queryFn: getAudioCache,
staleTime: Infinity, // Never stale, we will manually refetch when downloads are completed
})
export const useDownloadedTracks = (itemIds: (string | null | undefined)[]) =>
useAllDownloadedTracks().data?.filter((download) => itemIds.includes(download.item.Id))
export const useDownloadedTrack = (itemId: string | null | undefined) =>
useDownloadedTracks([itemId])?.at(0)
export const useIsDownloaded = (itemIds: (string | null | undefined)[]) =>
useDownloadedTracks(itemIds)?.length === itemIds.length

View File

@@ -0,0 +1,6 @@
enum DownloadQueryKeys {
DownloadedTrack = 'DOWNLOADED_TRACK',
DownloadedTracks = 'DownloadedTracks',
}
export default DownloadQueryKeys

View File

@@ -0,0 +1,20 @@
import RNFS from 'react-native-fs'
type JellifyStorage = {
totalStorage: number
freeSpace: number
storageInUseByJellify: number
}
const fetchStorageInUse: () => Promise<JellifyStorage> = async () => {
const totalStorage = await RNFS.getFSInfo()
const storageInUse = await RNFS.stat(RNFS.DocumentDirectoryPath)
return {
totalStorage: totalStorage.totalSpace,
freeSpace: totalStorage.freeSpace,
storageInUseByJellify: storageInUse.size,
}
}
export default fetchStorageInUse

View File

@@ -0,0 +1,76 @@
import { Api } from '@jellyfin/sdk'
import { useJellifyContext } from '../../../../src/providers'
import { useQuery } from '@tanstack/react-query'
import { JellifyUser } from '@/src/types/JellifyUser'
import { DeviceProfile } from '@jellyfin/sdk/lib/generated-client'
import useStreamingDeviceProfile, {
useDownloadingDeviceProfile,
} from '../../../stores/device-profile'
import { fetchMediaInfo } from './utils'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
interface MediaInfoQueryProps {
api: Api | undefined
user: JellifyUser | undefined
deviceProfile: DeviceProfile | undefined
itemId: string | null | undefined
}
const mediaInfoQueryKey = ({ api, user, deviceProfile, itemId }: MediaInfoQueryProps) => [
api,
user,
deviceProfile?.Name,
itemId,
]
/**
* A React hook that will retrieve the latest media info
* for streaming a given track
*
* Depends on the {@link useStreamingDeviceProfile} hook for retrieving
* the currently configured device profile
*
* Depends on the {@link useJellifyContext} hook for retrieving
* the currently configured {@link Api} and {@link JellifyUser}
* instance
*
* @param itemId The Id of the {@link BaseItemDto}
* @returns
*/
const useStreamedMediaInfo = (itemId: string | null | undefined) => {
const { api, user } = useJellifyContext()
const deviceProfile = useStreamingDeviceProfile()
return useQuery({
queryKey: mediaInfoQueryKey({ api, user, deviceProfile, itemId }),
queryFn: () => fetchMediaInfo(api, user, deviceProfile, itemId),
})
}
export default useStreamedMediaInfo
/**
* A React hook that will retrieve the latest media info
* for downloading a given track
*
* Depends on the {@link useDownloadingDeviceProfile} hook for retrieving
* the currently configured device profile
*
* Depends on the {@link useJellifyContext} hook for retrieving
* the currently configured {@link Api} and {@link JellifyUser}
* instance
*
* @param itemId The Id of the {@link BaseItemDto}
* @returns
*/
export const useDownloadedMediaInfo = (itemId: string | null | undefined) => {
const { api, user } = useJellifyContext()
const deviceProfile = useDownloadingDeviceProfile()
return useQuery({
queryKey: mediaInfoQueryKey({ api, user, deviceProfile, itemId }),
queryFn: () => fetchMediaInfo(api, user, deviceProfile, itemId),
})
}

View File

@@ -1,17 +1,16 @@
import { Api } from '@jellyfin/sdk'
import { PlaybackInfoResponse } from '@jellyfin/sdk/lib/generated-client/models'
import { DeviceProfile, PlaybackInfoResponse } from '@jellyfin/sdk/lib/generated-client/models'
import { getMediaInfoApi } from '@jellyfin/sdk/lib/utils/api'
import { isUndefined } from 'lodash'
import { JellifyUser } from '../../types/JellifyUser'
import { AudioQuality } from '../../types/AudioQuality'
import { JellifyUser } from '../../../../types/JellifyUser'
export async function fetchMediaInfo(
api: Api | undefined,
user: JellifyUser | undefined,
bitrate: AudioQuality | undefined,
itemId: string,
deviceProfile: DeviceProfile | undefined,
itemId: string | null | undefined,
): Promise<PlaybackInfoResponse> {
console.debug(`Fetching media info of quality ${JSON.stringify(bitrate)}`)
console.debug(`Fetching media info of with ${deviceProfile?.Name} profile`)
return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set')
@@ -22,12 +21,7 @@ export async function fetchMediaInfo(
itemId: itemId!,
userId: user.id,
playbackInfoDto: {
MaxAudioChannels: bitrate?.MaxAudioBitDepth
? parseInt(bitrate.MaxAudioBitDepth)
: undefined,
MaxStreamingBitrate: bitrate?.AudioBitRate
? parseInt(bitrate.AudioBitRate)
: undefined,
DeviceProfile: deviceProfile,
},
})
.then(({ data }) => {

View File

@@ -1,5 +1,9 @@
import { ImageFormat } from '@jellyfin/sdk/lib/generated-client/models'
export enum ApiLimits {
Library = 100,
}
const QueryConfig = {
/**
* Defines the limits for the number of items returned by a query

View File

@@ -9,21 +9,23 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import InstantMixButton from '../Global/components/instant-mix-button'
import ItemImage from '../Global/components/image'
import React, { useCallback, useEffect, useMemo } from 'react'
import React, { useCallback, useMemo } from 'react'
import { useJellifyContext } from '../../providers'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import Icon from '../Global/components/icon'
import { mapDtoToTrack } from '../../utils/mappings'
import { useNetworkContext } from '../../providers/Network'
import { useDownloadQualityContext, useStreamingQualityContext } from '../../providers/Settings'
import { useDownloadQualityContext } from '../../providers/Settings'
import { useLoadNewQueue } from '../../providers/Player/hooks/mutations'
import { QueuingType } from '../../enums/queuing-type'
import { useAlbumContext } from '../../providers/Album'
import { useNavigation } from '@react-navigation/native'
import HomeStackParamList from '@/src/screens/Home/types'
import LibraryStackParamList from '@/src/screens/Library/types'
import DiscoverStackParamList from '@/src/screens/Discover/types'
import { BaseStackParamList } from '@/src/screens/types'
import HomeStackParamList from '../../screens/Home/types'
import LibraryStackParamList from '../../screens/Library/types'
import DiscoverStackParamList from '../../screens/Discover/types'
import { BaseStackParamList } from '../../screens/types'
import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../../stores/device-profile'
import { useAllDownloadedTracks } from '../../api/queries/download'
/**
* The screen for an Album's track list
@@ -38,19 +40,21 @@ export function Album(): React.JSX.Element {
const { album, discs, isPending } = useAlbumContext()
const { api, sessionId } = useJellifyContext()
const { useDownloadMultiple, pendingDownloads, networkStatus, downloadedTracks } =
useNetworkContext()
const { api } = useJellifyContext()
const { useDownloadMultiple, pendingDownloads, networkStatus } = useNetworkContext()
const downloadQuality = useDownloadQualityContext()
const streamingQuality = useStreamingQualityContext()
const streamingDeviceProfile = useStreamingDeviceProfile()
const downloadingDeviceProfile = useDownloadingDeviceProfile()
const { mutate: loadNewQueue } = useLoadNewQueue()
const { data: downloadedTracks } = useAllDownloadedTracks()
const downloadAlbum = (item: BaseItemDto[]) => {
if (!api || !sessionId) return
if (!api) return
const jellifyTracks = item.map((item) =>
mapDtoToTrack(api, item, [], undefined, downloadQuality, streamingQuality),
mapDtoToTrack(api, item, [], downloadingDeviceProfile),
)
useDownloadMultiple.mutate(jellifyTracks)
useDownloadMultiple(jellifyTracks)
}
const playAlbum = useCallback(
@@ -64,7 +68,7 @@ export function Album(): React.JSX.Element {
api,
downloadedTracks,
networkStatus,
streamingQuality,
deviceProfile: streamingDeviceProfile,
downloadQuality,
track: allTracks[0],
index: 0,

View File

@@ -11,7 +11,7 @@ import LibraryStackParamList from '../../screens/Library/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { warmItemContext } from '../../hooks/use-item-context'
import { useJellifyContext } from '../../providers'
import { useStreamingQualityContext } from '../../providers/Settings'
import useStreamingDeviceProfile from '../../stores/device-profile'
interface AlbumsProps {
albums: (string | number | BaseItemDto)[] | undefined
@@ -33,13 +33,13 @@ export default function Albums({
const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext()
const deviceProfile = useStreamingDeviceProfile()
const onViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: ViewToken<string | number | BaseItemDto>[] }) => {
viewableItems.forEach(({ isViewable, item }) => {
if (isViewable && typeof item === 'object')
warmItemContext(api, user, item, streamingQuality)
warmItemContext(api, user, item, deviceProfile)
})
},
)

View File

@@ -12,14 +12,14 @@ import navigationRef from '../../../navigation'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import { warmItemContext } from '../../hooks/use-item-context'
import { useJellifyContext } from '../../providers'
import { useStreamingQualityContext } from '../../providers/Settings'
import useStreamingDeviceProfile from '../../stores/device-profile'
export default function Albums({
route,
navigation,
}: ArtistAlbumsProps | ArtistEpsProps | ArtistFeaturedOnProps): React.JSX.Element {
const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext()
const deviceProfile = useStreamingDeviceProfile()
const { width } = useSafeAreaFrame()
const { albums, fetchingAlbums, featuredOn, scroll } = useArtistContext()
@@ -33,7 +33,7 @@ export default function Albums({
const onViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: ViewToken<BaseItemDto>[] }) => {
viewableItems.forEach(({ isViewable, item }) => {
if (isViewable) warmItemContext(api, user, item, streamingQuality)
if (isViewable) warmItemContext(api, user, item, deviceProfile)
})
},
)

View File

@@ -16,9 +16,11 @@ import { useLoadNewQueue } from '../../providers/Player/hooks/mutations'
import { QueuingType } from '../../enums/queuing-type'
import { fetchAlbumDiscs } from '../../api/queries/item'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { BaseStackParamList } from '@/src/screens/types'
import { useDownloadQualityContext, useStreamingQualityContext } from '../../providers/Settings'
import { BaseStackParamList } from '../../screens/types'
import { useDownloadQualityContext } from '../../providers/Settings'
import { useNetworkContext } from '../../providers/Network'
import useStreamingDeviceProfile from '../../stores/device-profile'
import { useAllDownloadedTracks } from '../../api/queries/download'
export default function ArtistTabBar({
stackNavigation,
@@ -31,11 +33,13 @@ export default function ArtistTabBar({
const { artist, scroll, albums } = useArtistContext()
const { mutate: loadNewQueue } = useLoadNewQueue()
const streamingQuality = useStreamingQualityContext()
const deviceProfile = useStreamingDeviceProfile()
const downloadQuality = useDownloadQualityContext()
const { downloadedTracks, networkStatus } = useNetworkContext()
const { networkStatus } = useNetworkContext()
const { data: downloadedTracks } = useAllDownloadedTracks()
const { width } = useSafeAreaFrame()
@@ -60,7 +64,7 @@ export default function ArtistTabBar({
api,
downloadedTracks,
networkStatus,
streamingQuality,
deviceProfile,
downloadQuality,
track: allTracks[0],
index: 0,

View File

@@ -15,7 +15,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import LibraryStackParamList from '../../screens/Library/types'
import { warmItemContext } from '../../hooks/use-item-context'
import { useJellifyContext } from '../../providers'
import { useStreamingQualityContext } from '../../providers/Settings'
import useStreamingDeviceProfile from '../../stores/device-profile'
/**
* @param artistsInfiniteQuery - The infinite query for artists
@@ -33,7 +33,7 @@ export default function Artists({
const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext()
const deviceProfile = useStreamingDeviceProfile()
const { isFavorites } = useLibrarySortAndFilterContext()
@@ -48,7 +48,7 @@ export default function Artists({
({ viewableItems }: { viewableItems: ViewToken<string | number | BaseItemDto>[] }) => {
viewableItems.forEach(({ isViewable, item }) => {
if (isViewable && typeof item === 'object')
warmItemContext(api, user, item, streamingQuality)
warmItemContext(api, user, item, deviceProfile)
})
},
)
@@ -185,6 +185,7 @@ export default function Artists({
if (artistsInfiniteQuery.hasNextPage && !artistsInfiniteQuery.isFetching)
artistsInfiniteQuery.fetchNextPage()
}}
// onEndReachedThreshold default is 0.5
removeClippedSubviews
onViewableItemsChanged={onViewableItemsChangedRef.current}
/>

View File

@@ -0,0 +1,101 @@
import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { ListItem, View, YGroup, YStack } from 'tamagui'
import Icon from '../Global/components/icon'
import { Text } from '../Global/helpers/text'
import { RootStackParamList } from '../../screens/types'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useEffect } from 'react'
import { parseBitrateFromTranscodingUrl } from '../../utils/url-parsers'
import { SourceType } from '../../types/JellifyTrack'
import { capitalize } from 'lodash'
interface AudioSpecsProps {
item: BaseItemDto
streamingMediaSourceInfo?: MediaSourceInfo
downloadedMediaSourceInfo?: MediaSourceInfo
navigation: NativeStackNavigationProp<RootStackParamList>
}
export default function AudioSpecs({
item,
streamingMediaSourceInfo,
downloadedMediaSourceInfo,
navigation,
}: AudioSpecsProps): React.JSX.Element {
const { bottom } = useSafeAreaInsets()
return (
<View paddingBottom={bottom}>
{streamingMediaSourceInfo && (
<MediaSourceInfoView type='stream' mediaInfo={streamingMediaSourceInfo} />
)}
{downloadedMediaSourceInfo && (
<MediaSourceInfoView type='download' mediaInfo={downloadedMediaSourceInfo} />
)}
</View>
)
}
function MediaSourceInfoView({
mediaInfo,
type,
}: {
mediaInfo: MediaSourceInfo
type: SourceType
}): React.JSX.Element {
const { Bitrate, Container, TranscodingUrl, TranscodingContainer } = mediaInfo
const bitrate = TranscodingUrl ? parseBitrateFromTranscodingUrl(TranscodingUrl) : Bitrate
const container = TranscodingContainer || Container
useEffect(() => {
console.debug(bitrate)
}, [])
return (
<YGroup>
<ListItem justifyContent='flex-start'>
<Text bold>{`${capitalize(type)} Specs`}</Text>
</ListItem>
<ListItem gap={'$2'} justifyContent='flex-start'>
<Icon
small
name={type === 'download' ? 'file-music' : 'radio-tower'}
color='$primary'
/>
<Text bold>
{type === 'download'
? 'Downloaded File'
: TranscodingUrl
? 'Transcoded Stream'
: 'Direct Stream'}
</Text>
</ListItem>
{bitrate && (
<YGroup.Item>
<ListItem gap={'$2'} justifyContent='flex-start'>
<Icon small name='sine-wave' color={'$primary'} />
<Text
bold
fontVariant={['tabular-nums']}
>{`${Math.floor(bitrate / 1000)}kbps`}</Text>
</ListItem>
</YGroup.Item>
)}
{container && (
<YGroup.Item>
<ListItem gap={'$2'} justifyContent='flex-start'>
<Icon small name='music-box-outline' color={'$primary'} />
<Text bold>{container.toUpperCase()}</Text>
</ListItem>
</YGroup.Item>
)}
</YGroup>
)
}

View File

@@ -1,7 +1,7 @@
import { QueryKeys } from '../../enums/query-keys'
import { CarPlay, ListTemplate } from 'react-native-carplay'
import { queryClient } from '../../constants/query-client'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { BaseItemDto, DeviceProfile } from '@jellyfin/sdk/lib/generated-client/models'
import TracksTemplate from './Tracks'
import ArtistsTemplate from './Artists'
import uuid from 'react-native-uuid'
@@ -11,7 +11,7 @@ import { JellifyLibrary } from '../../types/JellifyLibrary'
import { Api } from '@jellyfin/sdk'
import { JellifyDownload } from '../../types/JellifyDownload'
import { networkStatusTypes } from '../Network/internetConnectionWatcher'
import { DownloadQuality, StreamingQuality } from '../../providers/Settings'
import { DownloadQuality } from '../../providers/Settings'
const CarPlayHome = (
library: JellifyLibrary,
@@ -19,7 +19,7 @@ const CarPlayHome = (
api: Api | undefined,
downloadedTracks: JellifyDownload[] | undefined,
networkStatus: networkStatusTypes | null,
streamingQuality: StreamingQuality,
deviceProfile: DeviceProfile | undefined,
downloadQuality: DownloadQuality,
) =>
new ListTemplate({
@@ -71,7 +71,7 @@ const CarPlayHome = (
api,
downloadedTracks,
networkStatus,
streamingQuality,
deviceProfile,
downloadQuality,
),
)
@@ -102,7 +102,7 @@ const CarPlayHome = (
api,
downloadedTracks,
networkStatus,
streamingQuality,
deviceProfile,
downloadQuality,
),
)

View File

@@ -7,7 +7,8 @@ import { JellifyLibrary } from '../../types/JellifyLibrary'
import { Api } from '@jellyfin/sdk'
import { JellifyDownload } from '@/src/types/JellifyDownload'
import { networkStatusTypes } from '../Network/internetConnectionWatcher'
import { DownloadQuality, StreamingQuality } from '../../providers/Settings'
import { DownloadQuality } from '../../providers/Settings'
import { DeviceProfile } from '@jellyfin/sdk/lib/generated-client'
const CarPlayNavigation = (
library: JellifyLibrary,
@@ -15,7 +16,7 @@ const CarPlayNavigation = (
api: Api | undefined,
downloadedTracks: JellifyDownload[] | undefined,
networkStatus: networkStatusTypes | null,
streamingQuality: StreamingQuality,
deviceProfile: DeviceProfile | undefined,
downloadQuality: DownloadQuality,
) =>
new TabBarTemplate({
@@ -28,7 +29,7 @@ const CarPlayNavigation = (
api,
downloadedTracks,
networkStatus,
streamingQuality,
deviceProfile,
downloadQuality,
),
CarPlayDiscover,

View File

@@ -1,4 +1,4 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { BaseItemDto, DeviceProfile } from '@jellyfin/sdk/lib/generated-client/models'
import { CarPlay, ListTemplate } from 'react-native-carplay'
import uuid from 'react-native-uuid'
import CarPlayNowPlaying from './NowPlaying'
@@ -8,7 +8,7 @@ import { QueuingType } from '../../enums/queuing-type'
import { Api } from '@jellyfin/sdk'
import { JellifyDownload } from '../../types/JellifyDownload'
import { networkStatusTypes } from '../Network/internetConnectionWatcher'
import { DownloadQuality, StreamingQuality } from '../../providers/Settings'
import { DownloadQuality } from '../../providers/Settings'
const TracksTemplate = (
items: BaseItemDto[],
@@ -17,7 +17,7 @@ const TracksTemplate = (
api: Api | undefined,
downloadedTracks: JellifyDownload[] | undefined,
networkStatus: networkStatusTypes | null,
streamingQuality: StreamingQuality,
deviceProfile: DeviceProfile | undefined,
downloadQuality: DownloadQuality,
) =>
new ListTemplate({
@@ -37,7 +37,7 @@ const TracksTemplate = (
loadQueue({
api,
networkStatus,
streamingQuality,
deviceProfile,
downloadQuality,
downloadedTracks,
queuingType: QueuingType.FromSelection,

View File

@@ -1,14 +1,14 @@
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'
import { getToken, ListItem, ScrollView, Spinner, View, XStack, YGroup } from 'tamagui'
import {
BaseItemDto,
BaseItemKind,
MediaSourceInfo,
} from '@jellyfin/sdk/lib/generated-client/models'
import { getToken, ListItem, ScrollView, Spinner, View, YGroup } from 'tamagui'
import { BaseStackParamList, RootStackParamList } from '../../screens/types'
import { Text } from '../Global/helpers/text'
import FavoriteContextMenuRow from '../Global/components/favorite-context-menu-row'
import { useColorScheme } from 'react-native'
import {
useDownloadQualityContext,
useStreamingQualityContext,
useThemeSettingContext,
} from '../../providers/Settings'
import { useDownloadQualityContext, useThemeSettingContext } from '../../providers/Settings'
import LinearGradient from 'react-native-linear-gradient'
import Icon from '../Global/components/icon'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
@@ -32,17 +32,27 @@ import { trigger } from 'react-native-haptic-feedback'
import { useAddToQueue } from '../../providers/Player/hooks/mutations'
import { useNetworkContext } from '../../providers/Network'
import { mapDtoToTrack } from '../../utils/mappings'
import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../../stores/device-profile'
import { useAllDownloadedTracks, useIsDownloaded } from '../../api/queries/download'
import { useDeleteDownloads } from '../../api/mutations/download'
type StackNavigation = Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
interface ContextProps {
item: BaseItemDto
streamingMediaSourceInfo?: MediaSourceInfo
downloadedMediaSourceInfo?: MediaSourceInfo
stackNavigation?: StackNavigation
navigation: NativeStackNavigationProp<RootStackParamList>
navigationCallback?: (screen: 'Album' | 'Artist', item: BaseItemDto) => void
}
export default function ItemContext({ item, stackNavigation }: ContextProps): React.JSX.Element {
export default function ItemContext({
item,
streamingMediaSourceInfo,
downloadedMediaSourceInfo,
stackNavigation,
}: ContextProps): React.JSX.Element {
const { api } = useJellifyContext()
const { bottom } = useSafeAreaInsets()
@@ -110,6 +120,14 @@ export default function ItemContext({ item, stackNavigation }: ContextProps): Re
{renderAddToPlaylistRow && <AddToPlaylistRow track={item} />}
{(streamingMediaSourceInfo || downloadedMediaSourceInfo) && (
<StatsRow
item={item}
streamingMediaSourceInfo={streamingMediaSourceInfo}
downloadedMediaSourceInfo={downloadedMediaSourceInfo}
/>
)}
{renderViewAlbumRow && (
<ViewAlbumMenuRow
album={isAlbum ? item : album!}
@@ -131,7 +149,7 @@ function AddToPlaylistRow({ track }: { track: BaseItemDto }): React.JSX.Element
animation={'quick'}
backgroundColor={'transparent'}
flex={1}
gap={'$2'}
gap={'$2.5'}
justifyContent='flex-start'
onPress={() => {
navigationRef.goBack()
@@ -149,11 +167,13 @@ function AddToPlaylistRow({ track }: { track: BaseItemDto }): React.JSX.Element
function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Element {
const { api } = useJellifyContext()
const { networkStatus, downloadedTracks } = useNetworkContext()
const { networkStatus } = useNetworkContext()
const { data: downloadedTracks } = useAllDownloadedTracks()
const downloadQuality = useDownloadQualityContext()
const streamingQuality = useStreamingQualityContext()
const deviceProfile = useStreamingDeviceProfile()
const { mutate: addToQueue } = useAddToQueue()
@@ -161,7 +181,7 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele
api,
networkStatus,
downloadedTracks,
streamingQuality,
deviceProfile,
downloadQuality,
tracks,
queuingType: QueuingType.DirectlyQueued,
@@ -172,7 +192,7 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele
animation={'quick'}
backgroundColor={'transparent'}
flex={1}
gap={'$2'}
gap={'$2.5'}
justifyContent='flex-start'
onPress={() => {
addToQueue(mutation)
@@ -203,42 +223,24 @@ function BackgroundGradient(): React.JSX.Element {
function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element {
const { api } = useJellifyContext()
const { useDownloadMultiple, downloadedTracks, useRemoveDownload, pendingDownloads } =
useNetworkContext()
const { useDownloadMultiple, pendingDownloads } = useNetworkContext()
const { mutate: downloadMultiple } = useDownloadMultiple
const useRemoveDownload = useDeleteDownloads()
const streamingQuality = useStreamingQualityContext()
const downloadQuality = useDownloadQualityContext()
const deviceProfile = useDownloadingDeviceProfile()
const isDownloaded = useIsDownloaded(items.map(({ Id }) => Id))
const downloadItems = useCallback(() => {
if (!api) return
const tracks = items.map((item) =>
mapDtoToTrack(
api,
item,
downloadedTracks ?? [],
QueuingType.FromSelection,
downloadQuality,
streamingQuality,
),
)
downloadMultiple(tracks)
const tracks = items.map((item) => mapDtoToTrack(api, item, [], deviceProfile))
useDownloadMultiple(tracks)
}, [useDownloadMultiple, items])
const removeDownloads = useCallback(() => {
items.forEach((download) => useRemoveDownload.mutate(download))
}, [useRemoveDownload, items])
const isDownloaded = useMemo(
() =>
items.filter(
(item) =>
(downloadedTracks ?? []).filter((track) => item.Id === track.item.Id).length >
0,
).length === items.length,
[items, downloadedTracks],
const removeDownloads = useCallback(
() => useRemoveDownload(items.map(({ Id }) => Id)),
[useRemoveDownload, items],
)
const isPending = useMemo(
@@ -269,7 +271,7 @@ function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element
<ListItem
animation={'quick'}
backgroundColor={'transparent'}
gap={'$2'}
gap={'$2.5'}
justifyContent='flex-start'
onPress={downloadItems}
pressStyle={{ opacity: 0.5 }}
@@ -286,7 +288,7 @@ function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element
<ListItem
animation={'quick'}
backgroundColor={'transparent'}
gap={'$2'}
gap={'$2.5'}
justifyContent='flex-start'
onPress={removeDownloads}
pressStyle={{ opacity: 0.5 }}
@@ -383,3 +385,34 @@ function ViewArtistMenuRow({
<></>
)
}
function StatsRow({
item,
streamingMediaSourceInfo,
downloadedMediaSourceInfo,
}: {
item: BaseItemDto
streamingMediaSourceInfo?: MediaSourceInfo
downloadedMediaSourceInfo?: MediaSourceInfo
}): React.JSX.Element {
return (
<ListItem
backgroundColor={'transparent'}
gap={'$2.5'}
justifyContent='flex-start'
onPress={() => {
navigationRef.goBack() // dismiss context modal
navigationRef.navigate('AudioSpecs', {
item,
streamingMediaSourceInfo,
downloadedMediaSourceInfo,
})
}}
pressStyle={{ opacity: 0.5 }}
>
<Icon small name='sine-wave' color='$primary' />
<Text bold>Open Audio Specs</Text>
</ListItem>
)
}

View File

@@ -1,7 +1,6 @@
import React from 'react'
import { getToken, ScrollView, Separator, View } from 'tamagui'
import RecentlyAdded from './helpers/just-added'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useDiscoverContext } from '../../providers/Discover'
import { RefreshControl } from 'react-native'
import PublicPlaylists from './helpers/public-playlists'

View File

@@ -1,10 +1,9 @@
import { RootStackParamList } from '../../../screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import HorizontalCardList from '../../../components/Global/components/horizontal-list'
import { ItemCard } from '../../../components/Global/components/item-card'
import { useDiscoverContext } from '../../../providers/Discover'
import { View, XStack } from 'tamagui'
import { H2, H4 } from '../../../components/Global/helpers/text'
import { H4 } from '../../../components/Global/helpers/text'
import Icon from '../../Global/components/icon'
import { useNavigation } from '@react-navigation/native'
import DiscoverStackParamList from '../../../screens/Discover/types'

View File

@@ -1,6 +1,5 @@
import { View, XStack } from 'tamagui'
import { useDiscoverContext } from '../../../providers/Discover'
import { RootStackParamList } from '../../../screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import Icon from '../../Global/components/icon'
import { useJellifyContext } from '../../../providers'

View File

@@ -1,17 +1,12 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { useNetworkContext } from '../../../providers/Network'
import { Spacer } from 'tamagui'
import Icon from './icon'
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
import { memo, useMemo } from 'react'
import { memo } from 'react'
import { useIsDownloaded } from '../../../api/queries/download'
function DownloadedIcon({ item }: { item: BaseItemDto }) {
const { downloadedTracks } = useNetworkContext()
const isDownloaded = useMemo(
() => downloadedTracks?.find((downloadedTrack) => downloadedTrack.item.Id === item.Id),
[downloadedTracks, item.Id],
)
const isDownloaded = useIsDownloaded([item.Id])
return isDownloaded ? (
<Animated.View entering={FadeIn} exiting={FadeOut}>

View File

@@ -39,7 +39,7 @@ export default function FavoriteContextMenuRow({ item }: { item: BaseItemDto }):
exiting={FadeOut}
key={`${item.Id}-remove-favorite-row`}
>
<XStack alignItems='center' justifyContent='flex-start' gap={'$2'}>
<XStack alignItems='center' justifyContent='flex-start' gap={'$2.5'}>
<Icon name={'heart'} small color={'$primary'} />
<Text bold>Remove from favorites</Text>
@@ -51,7 +51,6 @@ export default function FavoriteContextMenuRow({ item }: { item: BaseItemDto }):
animation={'quick'}
backgroundColor={'transparent'}
justifyContent='flex-start'
gap={'$2'}
onPress={() => {
toggleFavorite(!!isFavorite, {
item,
@@ -61,7 +60,7 @@ export default function FavoriteContextMenuRow({ item }: { item: BaseItemDto }):
pressStyle={{ opacity: 0.5 }}
>
<Animated.View entering={FadeIn} exiting={FadeOut} key={`${item.Id}-favorite-row`}>
<XStack alignItems='center' justifyContent='flex-start' gap={'$2'}>
<XStack alignItems='center' justifyContent='flex-start' gap={'$2.5'}>
<Icon small name={'heart-outline'} color={'$primary'} />
<Text bold>Add to favorites</Text>

View File

@@ -1,6 +1,6 @@
import useStreamingDeviceProfile from '../../../stores/device-profile'
import { warmItemContext } from '../../../hooks/use-item-context'
import { useJellifyContext } from '../../../providers'
import { useStreamingQualityContext } from '../../../providers/Settings'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import { FlashList, FlashListProps, ViewToken } from '@shopify/flash-list'
import React, { useRef } from 'react'
@@ -18,15 +18,13 @@ export default function HorizontalCardList({
}: HorizontalCardListProps): React.JSX.Element {
const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext()
const deviceProfile = useStreamingDeviceProfile()
const onViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: ViewToken<BaseItemDto>[] }) => {
viewableItems
.filter(({ isViewable }) => isViewable)
.forEach(({ isViewable, item }) => {
if (isViewable) warmItemContext(api, user, item, streamingQuality)
})
.forEach(({ item }) => warmItemContext(api, user, item, deviceProfile))
},
)

View File

@@ -14,7 +14,9 @@ import { BaseStackParamList } from '../../../screens/types'
import { useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
import { useJellifyContext } from '../../../providers'
import { useNetworkContext } from '../../../providers/Network'
import { useDownloadQualityContext, useStreamingQualityContext } from '../../../providers/Settings'
import { useDownloadQualityContext } from '../../../providers/Settings'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import { useAllDownloadedTracks } from '../../../api/queries/download'
interface ItemRowProps {
item: BaseItemDto
@@ -43,9 +45,11 @@ export default function ItemRow({
}: ItemRowProps): React.JSX.Element {
const { api } = useJellifyContext()
const { downloadedTracks, networkStatus } = useNetworkContext()
const { networkStatus } = useNetworkContext()
const streamingQuality = useStreamingQualityContext()
const { data: downloadedTracks } = useAllDownloadedTracks()
const deviceProfile = useStreamingDeviceProfile()
const downloadQuality = useDownloadQualityContext()
@@ -58,7 +62,7 @@ export default function ItemRow({
api,
downloadedTracks,
networkStatus,
streamingQuality,
deviceProfile,
downloadQuality,
track: item,
tracklist: [item],

View File

@@ -17,8 +17,11 @@ import ItemImage from './image'
import useItemContext from '../../../hooks/use-item-context'
import { useNowPlaying, useQueue } from '../../../providers/Player/hooks/queries'
import { useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
import { useDownloadQualityContext, useStreamingQualityContext } from '../../../providers/Settings'
import { useDownloadQualityContext } from '../../../providers/Settings'
import { useJellifyContext } from '../../../providers'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import useStreamedMediaInfo from '../../../api/queries/media'
import { useAllDownloadedTracks, useDownloadedTrack } from '../../../api/queries/download'
export interface TrackProps {
track: BaseItemDto
@@ -56,14 +59,20 @@ export default function Track({
const { api } = useJellifyContext()
const streamingQuality = useStreamingQualityContext()
const deviceProfile = useStreamingDeviceProfile()
const downloadQuality = useDownloadQualityContext()
const { data: nowPlaying } = useNowPlaying()
const { data: playQueue } = useQueue()
const { mutate: loadNewQueue } = useLoadNewQueue()
const { downloadedTracks, networkStatus } = useNetworkContext()
const { networkStatus } = useNetworkContext()
const { data: mediaInfo } = useStreamedMediaInfo(track.Id)
const { data: downloadedTracks } = useAllDownloadedTracks()
const offlineAudio = useDownloadedTrack(track.Id)
useItemContext(track)
@@ -73,13 +82,6 @@ export default function Track({
[nowPlaying?.item.Id, track.Id],
)
const offlineAudio = useMemo(
() => downloadedTracks?.find((t) => t.item.Id === track.Id),
[downloadedTracks, track.Id],
)
const isDownloaded = useMemo(() => offlineAudio?.item?.Id, [offlineAudio])
const isOffline = useMemo(
() => networkStatus === networkStatusTypes.DISCONNECTED,
[networkStatus],
@@ -99,7 +101,7 @@ export default function Track({
loadNewQueue({
api,
downloadedTracks,
streamingQuality,
deviceProfile,
downloadQuality,
networkStatus,
track,
@@ -110,7 +112,7 @@ export default function Track({
startPlayback: true,
})
}
}, [onPress, track, index, memoizedTracklist, queue, useLoadNewQueue])
}, [onPress, track, index, memoizedTracklist, queue, useLoadNewQueue, downloadedTracks])
const handleLongPress = useCallback(() => {
if (onLongPress) {
@@ -119,9 +121,13 @@ export default function Track({
navigationRef.navigate('Context', {
item: track,
navigation,
streamingMediaSourceInfo: mediaInfo?.MediaSources
? mediaInfo!.MediaSources![0]
: undefined,
downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
})
}
}, [onLongPress, track, isNested])
}, [onLongPress, track, isNested, offlineAudio])
const handleIconPress = useCallback(() => {
if (showRemove) {
@@ -129,16 +135,21 @@ export default function Track({
} else {
navigationRef.navigate('Context', {
item: track,
navigation,
streamingMediaSourceInfo: mediaInfo?.MediaSources
? mediaInfo!.MediaSources![0]
: undefined,
downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
})
}
}, [showRemove, onRemove, track, isNested])
}, [showRemove, onRemove, track, isNested, offlineAudio])
// Memoize text color to prevent recalculation
const textColor = useMemo(() => {
if (isPlaying) return theme.primary.val
if (isOffline) return isDownloaded ? theme.color : theme.neutral.val
if (isOffline) return offlineAudio ? theme.color : theme.neutral.val
return theme.color
}, [isPlaying, isOffline, isDownloaded, theme.primary.val, theme.color, theme.neutral.val])
}, [isPlaying, isOffline, offlineAudio, theme.primary.val, theme.color, theme.neutral.val])
// Memoize artists text
const artistsText = useMemo(() => track.Artists?.join(', ') ?? '', [track.Artists])

View File

@@ -12,15 +12,19 @@ import HomeStackParamList from '../../../screens/Home/types'
import { useNavigation } from '@react-navigation/native'
import { RootStackParamList } from '../../../screens/types'
import { useJellifyContext } from '../../../providers'
import { useDownloadQualityContext, useStreamingQualityContext } from '../../../providers/Settings'
import { useDownloadQualityContext } from '../../../providers/Settings'
import { useNetworkContext } from '../../../providers/Network'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import { useAllDownloadedTracks } from '../../../api/queries/download'
export default function FrequentlyPlayedTracks(): React.JSX.Element {
const { api } = useJellifyContext()
const { networkStatus, downloadedTracks } = useNetworkContext()
const { networkStatus } = useNetworkContext()
const streamingQuality = useStreamingQualityContext()
const { data: downloadedTracks } = useAllDownloadedTracks()
const deviceProfile = useStreamingDeviceProfile()
const downloadQuality = useDownloadQualityContext()
@@ -71,7 +75,7 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
onPress={() => {
loadNewQueue({
api,
streamingQuality,
deviceProfile,
downloadQuality,
downloadedTracks,
networkStatus,

View File

@@ -15,14 +15,18 @@ import HomeStackParamList from '../../../screens/Home/types'
import { useNowPlaying } from '../../../providers/Player/hooks/queries'
import { useJellifyContext } from '../../../providers'
import { useNetworkContext } from '../../../providers/Network'
import { useDownloadQualityContext, useStreamingQualityContext } from '../../../providers/Settings'
import { useDownloadQualityContext } from '../../../providers/Settings'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import { useAllDownloadedTracks } from '../../../api/queries/download'
export default function RecentlyPlayed(): React.JSX.Element {
const { api } = useJellifyContext()
const { downloadedTracks, networkStatus } = useNetworkContext()
const { networkStatus } = useNetworkContext()
const streamingQuality = useStreamingQualityContext()
const { data: downloadedTracks } = useAllDownloadedTracks()
const deviceProfile = useStreamingDeviceProfile()
const downloadQuality = useDownloadQualityContext()
@@ -73,7 +77,7 @@ export default function RecentlyPlayed(): React.JSX.Element {
loadNewQueue({
api,
downloadedTracks,
streamingQuality,
deviceProfile,
networkStatus,
downloadQuality,
track: recentlyPlayedTrack,

View File

@@ -1,23 +0,0 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { QueryKeys } from '../../enums/query-keys'
import { Queue } from '../../player/types/queue-item'
interface CategoryRoute {
/* eslint-disable @typescript-eslint/no-explicit-any */
name: any // ¯\_(ツ)_/¯
iconName: string
params?: {
queue?: Queue
tracks?: BaseItemDto[]
artists?: BaseItemDto[]
}
}
const Categories: CategoryRoute[] = [
{ name: 'Artists', iconName: 'microphone-variant', params: {} },
{ name: 'Albums', iconName: 'music-box-multiple', params: {} },
{ name: 'Tracks', iconName: 'music-note', params: { queue: 'Favorite Tracks' } },
{ name: 'Playlists', iconName: 'playlist-music' },
]
export default Categories

View File

@@ -15,7 +15,7 @@ import { useNowPlaying } from '../../../providers/Player/hooks/queries'
import { useActiveTrack } from 'react-native-track-player'
import { useJellifyContext } from '../../../providers'
import { useEffect } from 'react'
import usePlayerEngineStore, { PlayerEngine } from '../../../zustand/engineStore'
import usePlayerEngineStore, { PlayerEngine } from '../../../stores/player-engine'
export default function Footer(): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<PlayerParamList>>()

View File

@@ -38,17 +38,17 @@ export default function PlayerHeader(): React.JSX.Element {
color={theme.color.val}
name={Platform.OS === 'android' ? 'chevron-left' : 'chevron-down'}
size={22}
style={{ flex: 1, margin: 'auto' }}
style={{ marginVertical: 'auto', width: 22 }}
/>
<YStack alignItems='center' flex={1}>
<YStack alignItems='center' flexGrow={1}>
<Text>Playing from</Text>
<Text bold numberOfLines={1} lineBreakStrategyIOS='standard'>
{playingFrom}
</Text>
</YStack>
<Spacer flex={1} />
<Spacer width={22} />
</XStack>
<YStack
@@ -57,7 +57,6 @@ export default function PlayerHeader(): React.JSX.Element {
paddingHorizontal={'$2'}
maxHeight={'70%'}
marginVertical={'auto'}
paddingVertical={Platform.OS === 'android' ? '$4' : '$2'}
>
<Animated.View
entering={FadeIn}

View File

@@ -0,0 +1,62 @@
import { Square } from 'tamagui'
import { Text } from '../../Global/helpers/text'
import navigationRef from '../../../../navigation'
import { parseBitrateFromTranscodingUrl } from '../../../utils/url-parsers'
import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client'
import { SourceType } from '../../../types/JellifyTrack'
interface QualityBadgeProps {
item: BaseItemDto
mediaSourceInfo: MediaSourceInfo
sourceType: SourceType
}
export default function QualityBadge({
item,
mediaSourceInfo,
sourceType,
}: QualityBadgeProps): React.JSX.Element {
const container = mediaSourceInfo.TranscodingContainer || mediaSourceInfo.Container
const transcodingUrl = mediaSourceInfo.TranscodingUrl
const bitrate = transcodingUrl
? parseBitrateFromTranscodingUrl(transcodingUrl)
: mediaSourceInfo.Bitrate
return bitrate && container ? (
<Square
animation={'bouncy'}
justifyContent='center'
borderWidth={'$1'}
backgroundColor={'$primary'}
paddingHorizontal={'$1'}
borderRadius={'$2'}
pressStyle={{ scale: 0.875 }}
onPress={() => {
navigationRef.navigate('AudioSpecs', {
item,
streamingMediaSourceInfo: sourceType === 'stream' ? mediaSourceInfo : undefined,
downloadedMediaSourceInfo:
sourceType === 'download' ? mediaSourceInfo : undefined,
})
}}
>
<Text bold color={'$background'} textAlign='center' fontVariant={['tabular-nums']}>
{`${Math.floor(bitrate / 1000)}kbps ${formatContainerName(bitrate, container)}`}
</Text>
</Square>
) : (
<></>
)
}
function formatContainerName(bitrate: number, container: string): string {
let formattedContainer = container.toUpperCase()
if (formattedContainer.includes('MOV')) {
if (bitrate > 256) formattedContainer = 'ALAC'
else formattedContainer = 'AAC'
}
return formattedContainer
}

View File

@@ -10,6 +10,8 @@ import { UPDATE_INTERVAL } from '../../../player/config'
import { ProgressMultiplier } from '../component.config'
import { useReducedHapticsContext } from '../../../providers/Settings'
import { useNowPlaying, useProgress } from '../../../providers/Player/hooks/queries'
import QualityBadge from './quality-badge'
import { useDisplayAudioQualityBadge } from '../../../stores/player-settings'
// Create a simple pan gesture
const scrubGesture = Gesture.Pan().runOnJS(true)
@@ -21,7 +23,12 @@ export default function Scrubber(): React.JSX.Element {
const reducedHaptics = useReducedHapticsContext()
// Get progress from the track player with the specified update interval
const { position, duration } = useProgress(UPDATE_INTERVAL)
// We *don't* use the duration from this hook because it will have a value of "0"
// in the event we are transcoding a track...
const { position } = useProgress(UPDATE_INTERVAL)
// ...instead we use the duration on the track object
const { duration } = nowPlaying!
// Single source of truth for the current position
const [displayPosition, setDisplayPosition] = useState<number>(0)
@@ -32,6 +39,8 @@ export default function Scrubber(): React.JSX.Element {
const currentTrackIdRef = useRef<string | null>(null)
const lastPositionRef = useRef<number>(0)
const [displayAudioQualityBadge] = useDisplayAudioQualityBadge()
// Memoize expensive calculations
const maxDuration = useMemo(() => {
return Math.round(duration * ProgressMultiplier)
@@ -147,16 +156,22 @@ export default function Scrubber(): React.JSX.Element {
props={sliderProps}
/>
<XStack paddingTop={'$2'}>
<YStack alignItems='flex-start' flex={2}>
<XStack alignItems='center' paddingTop={'$2'}>
<YStack alignItems='flex-start' flexShrink={1}>
<RunTimeSeconds alignment='left'>{currentSeconds}</RunTimeSeconds>
</YStack>
<YStack alignItems='center' flex={1}>
{/** Track metadata can go here */}
<YStack alignItems='center' flexGrow={1}>
{nowPlaying?.mediaSourceInfo && displayAudioQualityBadge && (
<QualityBadge
item={nowPlaying.item}
sourceType={nowPlaying.sourceType}
mediaSourceInfo={nowPlaying.mediaSourceInfo}
/>
)}
</YStack>
<YStack alignItems='flex-end' flex={2}>
<YStack alignItems='flex-end' flexShrink={1}>
<RunTimeSeconds alignment='right'>{totalSeconds}</RunTimeSeconds>
</YStack>
</XStack>

View File

@@ -81,7 +81,19 @@ export default function SongInfo(): React.JSX.Element {
<XStack justifyContent='flex-end' alignItems='center' flexShrink={1} gap={'$3'}>
<Icon
name='dots-horizontal-circle-outline'
onPress={() => navigationRef.navigate('Context', { item: nowPlaying!.item })}
onPress={() =>
navigationRef.navigate('Context', {
item: nowPlaying!.item,
streamingMediaSourceInfo:
nowPlaying!.sourceType === 'stream'
? nowPlaying!.mediaSourceInfo
: undefined,
downloadedMediaSourceInfo:
nowPlaying!.sourceType === 'download'
? nowPlaying!.mediaSourceInfo
: undefined,
})
}
/>
<FavoriteButton item={nowPlaying!.item} />

View File

@@ -67,7 +67,7 @@ export default function PlayerScreen(): React.JSX.Element {
{/* flexGrow 1 */}
<PlayerHeader />
<YStack justifyContent='flex-start' gap={'$4'} flexShrink={1}>
<YStack justifyContent='flex-start' gap={'$5'} flexShrink={1}>
<SongInfo />
<Scrubber />

View File

@@ -166,8 +166,9 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
})
function MiniPlayerRuntime(): React.JSX.Element {
const progress = useProgress(UPDATE_INTERVAL)
const { position } = useProgress(UPDATE_INTERVAL)
const { data: nowPlaying } = useNowPlaying()
const { duration } = nowPlaying!
return (
<Animated.View
@@ -178,7 +179,7 @@ function MiniPlayerRuntime(): React.JSX.Element {
<XStack gap={'$1'} justifyContent='flex-start' height={'$1'}>
<YStack justifyContent='center' marginRight={'$2'} paddingRight={'auto'}>
<RunTimeSeconds alignment='left'>
{Math.max(0, Math.floor(progress?.position ?? 0))}
{Math.max(0, Math.floor(position))}
</RunTimeSeconds>
</YStack>
@@ -188,7 +189,7 @@ function MiniPlayerRuntime(): React.JSX.Element {
<YStack justifyContent='center' marginLeft={'$2'}>
<RunTimeSeconds color={'$neutral'} alignment='right'>
{Math.max(0, Math.floor(progress?.duration ?? 0))}
{Math.max(0, Math.floor(duration))}
</RunTimeSeconds>
</YStack>
</XStack>

View File

@@ -15,10 +15,14 @@ import { useNetworkContext } from '../../../../src/providers/Network'
import { ActivityIndicator } from 'react-native'
import { mapDtoToTrack } from '../../../utils/mappings'
import { QueuingType } from '../../../enums/queuing-type'
import { useDownloadQualityContext, useStreamingQualityContext } from '../../../providers/Settings'
import { useDownloadQualityContext } from '../../../providers/Settings'
import { useNavigation } from '@react-navigation/native'
import LibraryStackParamList from '@/src/screens/Library/types'
import { useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
import useStreamingDeviceProfile, {
useDownloadingDeviceProfile,
} from '../../../stores/device-profile'
import { useAllDownloadedTracks } from '../../../api/queries/download'
export default function PlayliistTracklistHeader(
playlist: BaseItemDto,
@@ -149,21 +153,24 @@ function PlaylistHeaderControls({
}): React.JSX.Element {
const { useDownloadMultiple, pendingDownloads } = useNetworkContext()
const downloadQuality = useDownloadQualityContext()
const streamingQuality = useStreamingQualityContext()
const streamingDeviceProfile = useStreamingDeviceProfile()
const downloadingDeviceProfile = useDownloadingDeviceProfile()
const { mutate: loadNewQueue } = useLoadNewQueue()
const isDownloading = pendingDownloads.length != 0
const { api } = useJellifyContext()
const { networkStatus, downloadedTracks } = useNetworkContext()
const { networkStatus } = useNetworkContext()
const { data: downloadedTracks } = useAllDownloadedTracks()
const navigation = useNavigation<NativeStackNavigationProp<LibraryStackParamList>>()
const downloadPlaylist = () => {
if (!api) return
const jellifyTracks = playlistTracks.map((item) =>
mapDtoToTrack(api, item, [], undefined, downloadQuality, streamingQuality),
mapDtoToTrack(api, item, [], downloadingDeviceProfile),
)
useDownloadMultiple.mutate(jellifyTracks)
useDownloadMultiple(jellifyTracks)
}
const playPlaylist = (shuffled: boolean = false) => {
@@ -174,7 +181,7 @@ function PlaylistHeaderControls({
downloadQuality,
networkStatus,
downloadedTracks,
streamingQuality,
deviceProfile: streamingDeviceProfile,
track: playlistTracks[0],
index: 0,
tracklist: playlistTracks,

View File

@@ -10,7 +10,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useRef } from 'react'
import { warmItemContext } from '../../hooks/use-item-context'
import { useJellifyContext } from '../../providers'
import { useStreamingQualityContext } from '../../providers/Settings'
import useStreamingDeviceProfile from '../../stores/device-profile'
export interface PlaylistsProps {
canEdit?: boolean | undefined
@@ -34,12 +34,12 @@ export default function Playlists({
const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext()
const deviceProfile = useStreamingDeviceProfile()
const onViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: ViewToken<BaseItemDto>[] }) => {
viewableItems.forEach(({ isViewable, item }) => {
if (isViewable) warmItemContext(api, user, item, streamingQuality)
if (isViewable) warmItemContext(api, user, item, deviceProfile)
})
},
)

View File

@@ -2,25 +2,30 @@ import SettingsListGroup from './settings-list-group'
import { RadioGroup, YStack } from 'tamagui'
import { RadioGroupItemWithLabel } from '../../Global/helpers/radio-group-item-with-label'
import { Text } from '../../Global/helpers/text'
import { getQualityLabel, getBandwidthEstimate } from '../utils/quality'
import {
StreamingQuality,
useSetStreamingQualityContext,
useStreamingQualityContext,
} from '../../../providers/Settings'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import { useDisplayAudioQualityBadge } from '../../../stores/player-settings'
import { SwitchWithLabel } from '../../Global/helpers/switch-with-label'
export default function PlaybackTab(): React.JSX.Element {
const deviceProfile = useStreamingDeviceProfile()
const streamingQuality = useStreamingQualityContext()
const setStreamingQuality = useSetStreamingQualityContext()
const [displayAudioQualityBadge, setDisplayAudioQualityBadge] = useDisplayAudioQualityBadge()
return (
<SettingsListGroup
settingsList={[
{
title: 'Streaming Quality',
subTitle: `Current: ${getQualityLabel(streamingQuality)}${getBandwidthEstimate(streamingQuality)}`,
iconName: 'sine-wave',
iconColor: getStreamingQualityIconColor(streamingQuality),
subTitle: `${deviceProfile?.Name ?? 'Not set'}`,
iconName: 'radio-tower',
iconColor: '$borderColor',
children: (
<YStack gap='$2' paddingVertical='$2'>
<Text fontSize='$3' marginBottom='$2'>
@@ -56,6 +61,20 @@ export default function PlaybackTab(): React.JSX.Element {
</YStack>
),
},
{
title: 'Show Audio Quality Badge',
subTitle: 'Displays audio quality in the player',
iconName: 'sine-wave',
iconColor: '$borderColor',
children: (
<SwitchWithLabel
onCheckedChange={setDisplayAudioQualityBadge}
size={'$2'}
checked={displayAudioQualityBadge}
label={displayAudioQualityBadge ? 'Enabled' : 'Disabled'}
/>
),
},
]}
/>
)

View File

@@ -12,13 +12,14 @@ import { useNetworkContext } from '../../../providers/Network'
import { RadioGroup, YStack } from 'tamagui'
import { Text } from '../../Global/helpers/text'
import { getQualityLabel } from '../utils/quality'
import { useAllDownloadedTracks } from '../../../api/queries/download'
export default function StorageTab(): React.JSX.Element {
const autoDownload = useAutoDownloadContext()
const setAutoDownload = useSetAutoDownloadContext()
const downloadQuality = useDownloadQualityContext()
const setDownloadQuality = useSetDownloadQualityContext()
const { downloadedTracks } = useNetworkContext()
const { data: downloadedTracks } = useAllDownloadedTracks()
return (
<SettingsListGroup

View File

@@ -2,11 +2,12 @@ import React, { useEffect, useState } from 'react'
import { StyleSheet, Pressable, Alert, FlatList } from 'react-native'
import RNFS from 'react-native-fs'
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'
import { deleteAudioCache } from '../../components/Network/offlineModeUtils'
import { deleteAudioCache } from '../../api/mutations/download/offlineModeUtils'
import { useNetworkContext } from '../../providers/Network'
import Icon from '../Global/components/icon'
import { getToken, View } from 'tamagui'
import { Text } from '../Global/helpers/text'
import { useAllDownloadedTracks } from '../../api/queries/download'
// 🔹 Single Download Item with animated progress bar
function DownloadItem({
@@ -43,7 +44,9 @@ export default function StorageBar(): React.JSX.Element {
const [used, setUsed] = useState(0)
const [total, setTotal] = useState(1)
const { downloadedTracks, activeDownloads: activeDownloadsArray } = useNetworkContext()
const { activeDownloads: activeDownloadsArray } = useNetworkContext()
const { data: downloadedTracks } = useAllDownloadedTracks()
const usageShared = useSharedValue(0)
const percentUsed = used / total

View File

@@ -8,10 +8,11 @@ import { queryClient } from '../../constants/query-client'
import { QueryKeys } from '../../enums/query-keys'
import { FlashList, ViewToken } from '@shopify/flash-list'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { BaseStackParamList } from '@/src/screens/types'
import { BaseStackParamList } from '../../screens/types'
import { warmItemContext } from '../../hooks/use-item-context'
import { useJellifyContext } from '../../providers'
import { useStreamingQualityContext } from '../../providers/Settings'
import useStreamingDeviceProfile from '../../stores/device-profile'
import { useAllDownloadedTracks } from '../../api/queries/download'
interface TracksProps {
tracks: (string | number | BaseItemDto)[] | undefined
@@ -34,8 +35,8 @@ export default function Tracks({
}: TracksProps): React.JSX.Element {
const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext()
const { downloadedTracks } = useNetworkContext()
const deviceProfile = useStreamingDeviceProfile()
const { data: downloadedTracks } = useAllDownloadedTracks()
// Memoize the expensive tracks processing to prevent memory leaks
const tracksToDisplay = React.useMemo(() => {
@@ -82,7 +83,7 @@ export default function Tracks({
const onViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: ViewToken<BaseItemDto>[] }) => {
viewableItems.forEach(({ isViewable, item }) => {
if (isViewable) warmItemContext(api, user, item, streamingQuality)
if (isViewable) warmItemContext(api, user, item, deviceProfile)
})
},
)

View File

@@ -20,7 +20,7 @@ import Toast from 'react-native-toast-message'
import JellifyToastConfig from '../constants/toast.config'
import { useColorScheme } from 'react-native'
import { CarPlayProvider } from '../providers/CarPlay'
import { useSelectPlayerEngine } from '../zustand/engineStore'
import { useSelectPlayerEngine } from '../stores/player-engine'
/**
* The main component for the Jellify app. Children are wrapped in the {@link JellifyProvider}
* @returns The {@link Jellify} component

View File

@@ -1,21 +1,22 @@
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'
import { BaseItemDto, BaseItemKind, DeviceProfile } from '@jellyfin/sdk/lib/generated-client/models'
import { JellifyUser } from '../types/JellifyUser'
import { Api } from '@jellyfin/sdk'
import { queryClient } from '../constants/query-client'
import { QueryKeys } from '../enums/query-keys'
import { getQualityParams } from '../utils/mappings'
import { fetchMediaInfo } from '../api/queries/media'
import { StreamingQuality, useStreamingQualityContext } from '../providers/Settings'
import { fetchMediaInfo } from '../api/queries/media/utils'
import { fetchAlbumDiscs, fetchItem } from '../api/queries/item'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { fetchUserData } from '../api/queries/favorites'
import { useJellifyContext } from '../providers'
import { useEffect, useRef } from 'react'
import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../stores/device-profile'
export default function useItemContext(item: BaseItemDto): void {
const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext()
const streamingDeviceProfile = useStreamingDeviceProfile()
const downloadingDeviceProfile = useDownloadingDeviceProfile()
const prefetchedContext = useRef<Set<string>>(new Set())
@@ -28,15 +29,16 @@ export default function useItemContext(item: BaseItemDto): void {
// Mark this item's context as warmed, preventing reruns
prefetchedContext.current.add(effectSig)
warmItemContext(api, user, item, streamingQuality)
}, [api, user, streamingQuality])
warmItemContext(api, user, item, streamingDeviceProfile, downloadingDeviceProfile)
}, [api, user, streamingDeviceProfile])
}
export function warmItemContext(
api: Api | undefined,
user: JellifyUser | undefined,
item: BaseItemDto,
streamingQuality: StreamingQuality,
streamingDeviceProfile: DeviceProfile | undefined,
downloadingDeviceProfile?: DeviceProfile | undefined,
): void {
const { Id, Type, AlbumId, UserData } = item
@@ -45,7 +47,8 @@ export function warmItemContext(
console.debug(`Warming context query cache for item ${Id}`)
if (Type === BaseItemKind.Audio) warmTrackContext(api, user, item, streamingQuality)
if (Type === BaseItemKind.Audio)
warmTrackContext(api, user, item, streamingDeviceProfile, downloadingDeviceProfile)
if (Type === BaseItemKind.MusicArtist)
queryClient.setQueryData([QueryKeys.ArtistById, Id], item)
@@ -117,16 +120,29 @@ function warmTrackContext(
api: Api | undefined,
user: JellifyUser | undefined,
track: BaseItemDto,
streamingQuality: StreamingQuality,
streamingDeviceProfile: DeviceProfile | undefined,
downloadingDeviceProfile: DeviceProfile | undefined,
): void {
const { Id, AlbumId, ArtistItems } = track
const mediaSourcesQueryKey = [QueryKeys.MediaSources, streamingQuality, Id]
const streamingMediaSourceQueryKey = [QueryKeys.MediaSources, streamingDeviceProfile?.Name, Id]
if (queryClient.getQueryState(mediaSourcesQueryKey)?.status !== 'success')
if (queryClient.getQueryState(streamingMediaSourceQueryKey)?.status !== 'success')
queryClient.ensureQueryData({
queryKey: mediaSourcesQueryKey,
queryFn: () => fetchMediaInfo(api, user, getQualityParams(streamingQuality), Id!),
queryKey: streamingMediaSourceQueryKey,
queryFn: () => fetchMediaInfo(api, user, streamingDeviceProfile, Id!),
})
const downloadedMediaSourceQueryKey = [
QueryKeys.MediaSources,
downloadingDeviceProfile?.Name,
Id,
]
if (queryClient.getQueryState(downloadedMediaSourceQueryKey)?.status !== 'success')
queryClient.ensureQueryData({
queryKey: downloadedMediaSourceQueryKey,
queryFn: () => fetchMediaInfo(api, user, downloadingDeviceProfile, track.Id),
})
const albumQueryKey = [QueryKeys.Album, AlbumId]

View File

@@ -5,7 +5,9 @@ import { CarPlay } from 'react-native-carplay'
import { useJellifyContext } from '../index'
import { useLoadNewQueue } from '../Player/hooks/mutations'
import { useNetworkContext } from '../Network'
import { useDownloadQualityContext, useStreamingQualityContext } from '../Settings'
import { useDownloadQualityContext } from '../Settings'
import useStreamingDeviceProfile from '../../stores/device-profile'
import { useAllDownloadedTracks } from '../../api/queries/download'
interface CarPlayContext {
carplayConnected: boolean
@@ -15,9 +17,11 @@ const CarPlayContextInitializer = () => {
const { api, library } = useJellifyContext()
const [carplayConnected, setCarPlayConnected] = useState(CarPlay ? CarPlay.connected : false)
const { networkStatus, downloadedTracks } = useNetworkContext()
const { networkStatus } = useNetworkContext()
const streamingQuality = useStreamingQualityContext()
const { data: downloadedTracks } = useAllDownloadedTracks()
const deviceProfile = useStreamingDeviceProfile()
const downloadQuality = useDownloadQualityContext()
const { mutate: loadNewQueue } = useLoadNewQueue()
@@ -34,7 +38,7 @@ const CarPlayContextInitializer = () => {
api,
downloadedTracks,
networkStatus,
streamingQuality,
deviceProfile,
downloadQuality,
),
)

View File

@@ -3,7 +3,7 @@ import { BaseItemDto, ItemSortBy, SortOrder } from '@jellyfin/sdk/lib/generated-
import { useJellifyContext } from '..'
import { fetchArtists } from '../../api/queries/artist'
import { RefObject, useMemo, useRef } from 'react'
import QueryConfig from '../../api/queries/query.config'
import QueryConfig, { ApiLimits } from '../../api/queries/query.config'
import { fetchTracks } from '../../api/queries/tracks'
import { fetchAlbums } from '../../api/queries/album'
import { useLibrarySortAndFilterContext } from './sorting-filtering'
@@ -101,7 +101,7 @@ const LibraryContextInitializer = () => {
select: selectArtists,
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
return lastPage.length === QueryConfig.limits.library ? lastPageParam + 1 : undefined
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
},
getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => {
return firstPageParam === 0 ? null : firstPageParam - 1

View File

@@ -1,40 +1,24 @@
import React, { createContext, ReactNode, useContext, useEffect, useState, useMemo } from 'react'
import { JellifyDownload, JellifyDownloadProgress } from '../../types/JellifyDownload'
import { useMutation, UseMutationResult, useQuery } from '@tanstack/react-query'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { mapDtoToTrack } from '../../utils/mappings'
import { deleteAudio, getAudioCache, saveAudio } from '../../components/Network/offlineModeUtils'
import { QueryKeys } from '../../enums/query-keys'
import { JellifyDownloadProgress } from '../../types/JellifyDownload'
import { UseMutateFunction, useMutation } from '@tanstack/react-query'
import { saveAudio } from '../../api/mutations/download/offlineModeUtils'
import { networkStatusTypes } from '../../components/Network/internetConnectionWatcher'
import { useJellifyContext } from '..'
import { useDownloadQualityContext, useStreamingQualityContext } from '../Settings'
import { isUndefined } from 'lodash'
import RNFS from 'react-native-fs'
import { JellifyStorage } from './types'
import JellifyTrack from '../../types/JellifyTrack'
import { useAllDownloadedTracks } from '../../api/queries/download'
interface NetworkContext {
useDownload: UseMutationResult<boolean | void, Error, BaseItemDto, unknown>
useRemoveDownload: UseMutationResult<void, Error, BaseItemDto, unknown>
storageUsage: JellifyStorage | undefined
downloadedTracks: JellifyDownload[] | undefined
useDownloadMultiple: UseMutateFunction<boolean, Error, JellifyTrack[], unknown>
activeDownloads: JellifyDownloadProgress | undefined
networkStatus: networkStatusTypes | null
setNetworkStatus: (status: networkStatusTypes | null) => void
useDownloadMultiple: UseMutationResult<boolean, Error, JellifyTrack[], unknown>
pendingDownloads: JellifyTrack[]
downloadingDownloads: JellifyTrack[]
completedDownloads: JellifyTrack[]
failedDownloads: JellifyTrack[]
clearDownloads: () => void
}
const MAX_CONCURRENT_DOWNLOADS = 1
const NetworkContextInitializer = () => {
const { api, sessionId } = useJellifyContext()
const downloadQuality = useDownloadQualityContext()
const streamingQuality = useStreamingQualityContext()
const [downloadProgress, setDownloadProgress] = useState<JellifyDownloadProgress>({})
const [networkStatus, setNetworkStatus] = useState<networkStatusTypes | null>(null)
@@ -44,21 +28,7 @@ const NetworkContextInitializer = () => {
const [completed, setCompleted] = useState<JellifyTrack[]>([])
const [failed, setFailed] = useState<JellifyTrack[]>([])
const fetchStorageInUse: () => Promise<JellifyStorage> = async () => {
const totalStorage = await RNFS.getFSInfo()
const storageInUse = await RNFS.stat(RNFS.DocumentDirectoryPath)
return {
totalStorage: totalStorage.totalSpace,
freeSpace: totalStorage.freeSpace,
storageInUseByJellify: storageInUse.size,
}
}
const { data: downloadedTracks, refetch: refetchDownloadedTracks } = useQuery({
queryKey: [QueryKeys.AudioCache],
queryFn: getAudioCache,
staleTime: Infinity, // Never stale, we will manually refetch when downloads are completed
})
const { data: downloadedTracks, refetch: refetchDownloadedTracks } = useAllDownloadedTracks()
useEffect(() => {
if (pending.length > 0 && downloading.length < MAX_CONCURRENT_DOWNLOADS) {
@@ -89,59 +59,12 @@ const NetworkContextInitializer = () => {
}
}, [pending, downloading])
const useDownload = useMutation({
mutationFn: (trackItem: BaseItemDto) => {
if (isUndefined(api)) throw new Error('API client not initialized')
const track = mapDtoToTrack(
api,
trackItem,
[],
undefined,
downloadQuality,
streamingQuality,
)
return saveAudio(track, setDownloadProgress, false)
},
onSuccess: (data, variables) => {
console.debug(`Downloaded ${variables.Id} successfully`)
refetchDownloadedTracks()
return data
},
})
const { data: storageUsage } = useQuery({
queryKey: [QueryKeys.StorageInUse],
queryFn: () => fetchStorageInUse(),
})
const { mutate: clearDownloads } = useMutation({
mutationFn: async () => {
return downloadedTracks?.forEach((track) => {
deleteAudio(track.item)
})
},
onSuccess: () => {
refetchDownloadedTracks()
},
})
const useRemoveDownload = useMutation({
mutationFn: (trackItem: BaseItemDto) => deleteAudio(trackItem),
onSuccess: (data, { Id }) => {
console.debug(`Removed ${Id} from storage`)
refetchDownloadedTracks()
},
})
const addToQueue = async (items: JellifyTrack[]) => {
setPending((prev) => [...prev, ...items])
return true
}
const useDownloadMultiple = useMutation({
const { mutate: useDownloadMultiple } = useMutation({
mutationFn: (tracks: JellifyTrack[]) => {
return addToQueue(tracks)
},
@@ -151,89 +74,27 @@ const NetworkContextInitializer = () => {
})
return {
useDownload,
useRemoveDownload,
activeDownloads: downloadProgress,
downloadedTracks,
networkStatus,
setNetworkStatus,
storageUsage,
useDownloadMultiple,
pendingDownloads: pending,
downloadingDownloads: downloading,
completedDownloads: completed,
failedDownloads: failed,
clearDownloads,
}
}
const NetworkContext = createContext<NetworkContext>({
useDownload: {
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,
},
useRemoveDownload: {
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,
},
downloadedTracks: [],
activeDownloads: {},
networkStatus: networkStatusTypes.ONLINE,
setNetworkStatus: () => {},
storageUsage: undefined,
useDownloadMultiple: {
mutate: () => {},
mutateAsync: async () => {
return true
},
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,
},
useDownloadMultiple: () => {},
pendingDownloads: [],
downloadingDownloads: [],
completedDownloads: [],
failedDownloads: [],
clearDownloads: () => {},
})
export const NetworkContextProvider: ({
@@ -249,7 +110,6 @@ export const NetworkContextProvider: ({
[
context.downloadedTracks?.length,
context.networkStatus,
context.storageUsage,
context.pendingDownloads.length,
context.downloadingDownloads.length,
context.completedDownloads.length,

View File

@@ -1,5 +0,0 @@
export type JellifyStorage = {
totalStorage: number
freeSpace: number
storageInUseByJellify: number
}

View File

@@ -5,7 +5,6 @@ import { AddToQueueMutation, QueueMutation } from '../interfaces'
import { QueuingType } from '../../../enums/queuing-type'
import { shuffleJellifyTracks } from '../utils/shuffle'
import TrackPlayer from 'react-native-track-player'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import Toast from 'react-native-toast-message'
import { findPlayQueueIndexStart } from '../utils'
import JellifyTrack from '@/src/types/JellifyTrack'
@@ -13,13 +12,11 @@ import { setPlayQueue, setQueueRef, setShuffled, setUnshuffledQueue } from '.'
export async function loadQueue({
index,
track,
tracklist,
queue: queueRef,
shuffled = false,
api,
downloadQuality,
streamingQuality,
deviceProfile,
networkStatus = networkStatusTypes.ONLINE,
downloadedTracks,
}: QueueMutation) {
@@ -43,9 +40,8 @@ export async function loadQueue({
api!,
item,
downloadedTracks ?? [],
deviceProfile!,
QueuingType.FromSelection,
downloadQuality,
streamingQuality,
),
)
@@ -111,21 +107,13 @@ export async function loadQueue({
export const playNextInQueue = async ({
api,
downloadedTracks,
downloadQuality,
streamingQuality,
deviceProfile,
tracks,
}: AddToQueueMutation) => {
console.debug(`Playing item next in queue`)
const tracksToPlayNext = tracks.map((item) =>
mapDtoToTrack(
api!,
item,
downloadedTracks ?? [],
QueuingType.PlayingNext,
downloadQuality,
streamingQuality,
),
mapDtoToTrack(api!, item, downloadedTracks ?? [], deviceProfile!, QueuingType.PlayingNext),
)
const currentIndex = await TrackPlayer.getActiveTrackIndex()
@@ -149,8 +137,7 @@ export const playNextInQueue = async ({
export const playInQueue = async ({
api,
downloadQuality,
streamingQuality,
deviceProfile,
downloadedTracks,
tracks,
}: AddToQueueMutation) => {
@@ -169,9 +156,8 @@ export const playInQueue = async ({
api!,
item,
downloadedTracks ?? [],
deviceProfile!,
QueuingType.DirectlyQueued,
downloadQuality,
streamingQuality,
),
)

View File

@@ -19,7 +19,7 @@ import { handleDeshuffle, handleShuffle } from '../functions/shuffle'
import JellifyTrack from '@/src/types/JellifyTrack'
import calculateTrackVolume from '../utils/normalization'
import { useNowPlaying, usePlaybackState } from './queries'
import usePlayerEngineStore, { PlayerEngine } from '../../../zustand/engineStore'
import usePlayerEngineStore, { PlayerEngine } from '../../../stores/player-engine'
import { useRemoteMediaClient } from 'react-native-google-cast'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { RootStackParamList } from '../../../screens/types'

View File

@@ -15,8 +15,8 @@ import {
QUEUE_QUERY,
REPEAT_MODE_QUERY,
} from '../constants/queries'
import usePlayerEngineStore from '../../../zustand/engineStore'
import { PlayerEngine } from '../../../zustand/engineStore'
import usePlayerEngineStore from '../../../stores/player-engine'
import { PlayerEngine } from '../../../stores/player-engine'
import {
MediaPlayerState,
useMediaStatus,

View File

@@ -1,22 +1,20 @@
import { createContext } from 'use-context-selector'
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
import { Event, useTrackPlayerEvents } from 'react-native-track-player'
import { Event, State, useTrackPlayerEvents } from 'react-native-track-player'
import { refetchNowPlaying } from './functions/queries'
import { useEffect, useRef } from 'react'
import { useEffect } from 'react'
import { useAudioNormalization, useInitialization } from './hooks/mutations'
import { useCurrentIndex, useNowPlaying, useQueue } from './hooks/queries'
import {
cacheTrackIfConfigured,
handlePlaybackProgress,
handlePlaybackState,
} from './utils/handlers'
import { useJellifyContext } from '..'
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api'
import { handleActiveTrackChanged } from './functions'
import { useAutoDownloadContext, useStreamingQualityContext } from '../Settings'
import { useNetworkContext } from '../Network'
import JellifyTrack from '@/src/types/JellifyTrack'
import { useAutoDownloadContext } from '../Settings'
import JellifyTrack from '../../types/JellifyTrack'
import { useIsRestoring } from '@tanstack/react-query'
import {
useReportPlaybackProgress,
useReportPlaybackStarted,
useReportPlaybackStopped,
} from '../../api/mutations/playback'
import { useDownloadAudioItem } from '../../api/mutations/download'
const PLAYER_EVENTS: Event[] = [
Event.PlaybackActiveTrackChanged,
@@ -29,14 +27,7 @@ interface PlayerContext {}
export const PlayerContext = createContext<PlayerContext>({})
export const PlayerProvider: () => React.JSX.Element = () => {
const { api } = useJellifyContext()
const playStateApi = api ? getPlaystateApi(api) : undefined
const autoDownload = useAutoDownloadContext()
const streamingQuality = useStreamingQualityContext()
const { downloadedTracks, networkStatus } = useNetworkContext()
usePerformanceMonitor('PlayerProvider', 3)
@@ -50,38 +41,58 @@ export const PlayerProvider: () => React.JSX.Element = () => {
const { mutate: normalizeAudioVolume } = useAudioNormalization()
const isRestoring = useIsRestoring()
const { mutate: reportPlaybackStarted } = useReportPlaybackStarted()
const { mutate: reportPlaybackProgress } = useReportPlaybackProgress()
const { mutate: reportPlaybackStopped } = useReportPlaybackStopped()
const prefetchedTrackIds = useRef<Set<string>>(new Set())
const [downloadProgress, downloadAudioItem] = useDownloadAudioItem()
const isRestoring = useIsRestoring()
useTrackPlayerEvents(PLAYER_EVENTS, (event) => {
switch (event.type) {
case Event.PlaybackActiveTrackChanged:
if (event.track) normalizeAudioVolume(event.track as JellifyTrack)
handleActiveTrackChanged()
refetchNowPlaying()
if (event.lastTrack)
reportPlaybackStopped({
track: event.lastTrack as JellifyTrack,
lastPosition: event.lastPosition,
duration: (event.lastTrack as JellifyTrack).duration,
})
break
case Event.PlaybackProgressUpdated:
handlePlaybackProgress(
playStateApi,
streamingQuality,
event.duration,
event.position,
)
cacheTrackIfConfigured(
autoDownload,
currentIndex,
nowPlaying,
playQueue,
downloadedTracks,
prefetchedTrackIds.current,
networkStatus,
event.position,
event.duration,
)
console.debug(`Completion percentage: ${event.position / event.duration}`)
if (nowPlaying)
reportPlaybackProgress({
track: nowPlaying,
position: event.position,
})
if (event.position / event.duration > 0.3 && autoDownload && nowPlaying)
downloadAudioItem({ item: nowPlaying.item, autoCached: true })
break
case Event.PlaybackState:
handlePlaybackState(playStateApi, streamingQuality, event.state)
switch (event.state) {
case State.Playing:
if (nowPlaying)
reportPlaybackStarted({
track: nowPlaying,
})
break
case State.Paused:
case State.Stopped:
case State.Ended:
if (nowPlaying)
reportPlaybackStopped({
track: nowPlaying,
lastPosition: 0,
duration: nowPlaying.duration,
})
}
break
}
})

View File

@@ -1,5 +1,5 @@
import { QueuingType } from '../../enums/queuing-type'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { BaseItemDto, DeviceProfile } from '@jellyfin/sdk/lib/generated-client/models'
import { Queue } from '../../player/types/queue-item'
import { Api } from '@jellyfin/sdk'
import { networkStatusTypes } from '../../components/Network/internetConnectionWatcher'
@@ -25,7 +25,7 @@ export interface QueueMutation {
downloadQuality: DownloadQuality
streamingQuality: StreamingQuality
deviceProfile: DeviceProfile | undefined
/**
* The track that will be played first in the queue.
@@ -80,7 +80,7 @@ export interface AddToQueueMutation {
downloadQuality: DownloadQuality
streamingQuality: StreamingQuality
deviceProfile: DeviceProfile | undefined
/**
* The tracks to add to the queue.

View File

@@ -1,196 +0,0 @@
import { Progress, State } from 'react-native-track-player'
import JellifyTrack from '../../../types/JellifyTrack'
import { PlaystateApi } from '@jellyfin/sdk/lib/generated-client/api/playstate-api'
import { convertSecondsToRunTimeTicks } from '../../../utils/runtimeticks'
import { PROGRESS_UPDATE_EVENT_INTERVAL } from '../../../player/config'
import { getCurrentTrack } from '../functions'
import { queryClient } from '../../../constants/query-client'
import { QueryKeys } from '../../../enums/query-keys'
import { StreamingQuality } from '../../Settings'
import { PlaybackInfoResponse } from '@jellyfin/sdk/lib/generated-client/models'
import { JellifyDownload } from '../../../types/JellifyDownload'
import { networkStatusTypes } from '../../../components/Network/internetConnectionWatcher'
import {
getTracksToPreload,
optimizePlayerQueue,
shouldStartPrefetching,
} from '../../../player/helpers/gapless'
import {
PREFETCH_THRESHOLD_SECONDS,
QUEUE_PREPARATION_THRESHOLD_SECONDS,
} from '../../../player/gapless-config'
import { saveAudio } from '../../../components/Network/offlineModeUtils'
export async function handlePlaybackState(
playstateApi: PlaystateApi | undefined,
streamingQuality: StreamingQuality,
state: State,
) {
const track = getCurrentTrack()
if (playstateApi && track) {
const mediaInfo = queryClient.getQueryData([
QueryKeys.MediaSources,
streamingQuality,
track.item.Id,
]) as PlaybackInfoResponse | undefined
switch (state) {
case State.Playing: {
console.debug('Report playback started')
await playstateApi.reportPlaybackStart({
playbackStartInfo: {
SessionId: mediaInfo?.PlaySessionId,
ItemId: track.item.Id,
},
})
break
}
case State.Ended:
case State.Paused:
case State.Stopped: {
console.debug('Report playback stopped')
await playstateApi.reportPlaybackStopped({
playbackStopInfo: {
SessionId: mediaInfo?.PlaySessionId,
ItemId: track.item.Id,
},
})
break
}
default: {
return
}
}
}
}
export async function handlePlaybackProgress(
playstateApi: PlaystateApi | undefined,
streamingQuality: StreamingQuality,
duration: number,
position: number,
) {
const track = getCurrentTrack()
const mediaInfo = queryClient.getQueryData([
QueryKeys.MediaSources,
streamingQuality,
track?.item.Id,
]) as PlaybackInfoResponse | undefined
if (playstateApi && track) {
console.debug('Playback progress updated')
if (shouldMarkPlaybackFinished(duration, position)) {
console.debug(`Track finished. ${playstateApi ? 'scrobbling...' : ''}`)
await playstateApi.reportPlaybackStopped({
playbackStopInfo: {
SessionId: mediaInfo?.PlaySessionId,
ItemId: track.item.Id,
PositionTicks: convertSecondsToRunTimeTicks(track.duration!),
},
})
} else {
console.debug('Reporting playback position')
await playstateApi.reportPlaybackProgress({
playbackProgressInfo: {
SessionId: mediaInfo?.PlaySessionId,
ItemId: track.ItemId,
PositionTicks: convertSecondsToRunTimeTicks(position),
},
})
}
}
}
export function shouldMarkPlaybackFinished(duration: number, position: number): boolean {
return Math.floor(duration) - Math.floor(position) < PROGRESS_UPDATE_EVENT_INTERVAL
}
export async function cacheTrackIfConfigured(
autoDownload: boolean,
currentIndex: number | undefined,
nowPlaying: JellifyTrack | undefined,
playQueue: JellifyTrack[] | undefined,
downloadedTracks: JellifyDownload[] | undefined,
prefetchedTrackIds: Set<string>,
networkStatus: networkStatusTypes | null,
position: number,
duration: number,
): Promise<void> {
// Cache playing track at the first event emitted if it's not already downloaded
if (
nowPlaying &&
Math.floor(position) === PROGRESS_UPDATE_EVENT_INTERVAL &&
trackNotDownloaded(downloadedTracks, nowPlaying) &&
// Only download if we are online or *optimistically* if the network status is unknown
[networkStatusTypes.ONLINE, undefined, null].includes(networkStatus) &&
// Only download if auto-download is enabled
autoDownload
)
saveAudio(nowPlaying, () => {}, true)
// --- ENHANCED GAPLESS PLAYBACK LOGIC ---
if (nowPlaying && playQueue && typeof currentIndex === 'number' && autoDownload) {
const positionFloor = Math.floor(position)
const durationFloor = Math.floor(duration)
const timeRemaining = duration - position
// Check if we should start prefetching tracks
if (shouldStartPrefetching(positionFloor, durationFloor, PREFETCH_THRESHOLD_SECONDS)) {
const tracksToPreload = getTracksToPreload(playQueue, currentIndex, prefetchedTrackIds)
if (tracksToPreload.length > 0) {
console.debug(
`Gapless: Found ${tracksToPreload.length} tracks to preload (${timeRemaining}s remaining)`,
)
// Filter tracks that aren't already downloaded
const tracksToDownload = tracksToPreload.filter(
(track) =>
downloadedTracks?.filter((download) => download.item.Id === track.item.Id)
.length === 0,
)
if (
tracksToDownload.length > 0 &&
[networkStatusTypes.ONLINE, undefined, null].includes(
networkStatus as networkStatusTypes,
)
) {
console.debug(`Gapless: Starting download of ${tracksToDownload.length} tracks`)
tracksToDownload.forEach((track) => saveAudio(track, () => {}, true))
// Mark tracks as prefetched
tracksToDownload.forEach((track) => {
if (track.item.Id) {
prefetchedTrackIds.add(track.item.Id)
}
})
}
}
}
// Optimize the TrackPlayer queue for smooth transitions
if (timeRemaining <= QUEUE_PREPARATION_THRESHOLD_SECONDS) {
console.debug(`Gapless: Optimizing player queue (${timeRemaining}s remaining)`)
optimizePlayerQueue(playQueue, currentIndex).catch((error) =>
console.warn('Failed to optimize player queue:', error),
)
}
}
}
function trackNotDownloaded(
downloadedTracks: JellifyDownload[] | undefined,
track: JellifyTrack,
): boolean {
const notDownloaded =
downloadedTracks?.filter((download) => download.item.Id === track?.item.Id).length === 0
console.debug(`Currently playing track is currently ${notDownloaded && 'not'} downloaded`)
return notDownloaded
}

View File

@@ -23,6 +23,11 @@ const MIN_REDUCTION_DB = -10
*
* @param track - The track to calculate the normalization gain for.
* @returns The normalization gain for the track.
*
* Audio Normalization in Jellify would not be possible without the help
* of Chaphasilor - present maintainer and designer of Finamp.
*
* @see https://github.com/Chaphasilor
*/
export default function calculateTrackVolume(track: JellifyTrack): number {
const { NormalizationGain } = track.item

View File

@@ -3,6 +3,11 @@ import { storage } from '../../constants/storage'
import { MMKVStorageKeys } from '../../enums/mmkv-storage-keys'
import { useEffect, useState, useMemo } from 'react'
import { createContext, useContextSelector } from 'use-context-selector'
import {
useDownloadingDeviceProfileStore,
useStreamingDeviceProfileStore,
} from '../../stores/device-profile'
import { getDeviceProfile } from './utils'
export type DownloadQuality = 'original' | 'high' | 'medium' | 'low'
export type StreamingQuality = 'original' | 'high' | 'medium' | 'low'
@@ -61,11 +66,11 @@ const SettingsContextInitializer = () => {
const [devTools, setDevTools] = useState(false)
const [downloadQuality, setDownloadQuality] = useState<DownloadQuality>(
downloadQualityInit ?? 'medium',
downloadQualityInit ?? 'original',
)
const [streamingQuality, setStreamingQuality] = useState<StreamingQuality>(
streamingQualityInit ?? 'high',
streamingQualityInit ?? 'original',
)
const [reducedHaptics, setReducedHaptics] = useState(
@@ -74,6 +79,13 @@ const SettingsContextInitializer = () => {
const [theme, setTheme] = useState<Theme>(themeInit ?? 'system')
const setStreamingDeviceProfile = useStreamingDeviceProfileStore(
(state) => state.setDeviceProfile,
)
const setDownloadingDeviceProfile = useDownloadingDeviceProfileStore(
(state) => state.setDeviceProfile,
)
useEffect(() => {
storage.set(MMKVStorageKeys.SendMetrics, sendMetrics)
}, [sendMetrics])
@@ -84,10 +96,14 @@ const SettingsContextInitializer = () => {
useEffect(() => {
storage.set(MMKVStorageKeys.DownloadQuality, downloadQuality)
setDownloadingDeviceProfile(getDeviceProfile(downloadQuality, 'download'))
}, [downloadQuality])
useEffect(() => {
storage.set(MMKVStorageKeys.StreamingQuality, streamingQuality)
setStreamingDeviceProfile(getDeviceProfile(streamingQuality, 'stream'))
}, [streamingQuality])
useEffect(() => {

View File

@@ -0,0 +1,260 @@
/**
* This file incorporates code from Jellyfin iOS
*
* Original Source: https://github.com/jellyfin/jellyfin-ios/blob/042a48248fc23d3749d5d5991a2e1c63c0b10e7d/utils/profiles/base.ts
* Copyright (c) 2025 Jellyfin Contributors - licensed under the Mozilla Public License 2.0
*
* Modifications by Jellify Contributors
* - Refactored to account for differing platforms using React Native's Platform API
* - Configurable given a user definable Streaming Quality
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import {
DeviceProfile,
DlnaProfileType,
EncodingContext,
MediaStreamProtocol,
} from '@jellyfin/sdk/lib/generated-client'
import { StreamingQuality } from '..'
import { Platform } from 'react-native'
import { getQualityParams } from '../../../utils/mappings'
import { capitalize } from 'lodash'
import { SourceType } from '../../../types/JellifyTrack'
/**
* A constant that defines the options for the {@link useDeviceProfile} hook - building the
* {@link DeviceProfile}
*
* @param streamingQuality The {@link StreamingQuality} defined by the user in the settings
* @returns the query options
*
* Huge thank you to Bill on the Jellyfin Team for helping us with this! 💜
*/
export function getDeviceProfile(
streamingQuality: StreamingQuality,
type: SourceType,
): DeviceProfile {
const isApple = Platform.OS === 'ios' || Platform.OS === 'macos'
const platformProfile = isApple ? APPLE_PLATFORM_PROFILE : DEFAULT_PLATFORM_PROFILE
return {
Name: `${capitalize(streamingQuality)} Quality Audio ${capitalize(type)}`,
MaxStaticBitrate:
streamingQuality === 'original'
? 100_000_000
: getQualityParams(streamingQuality)?.AudioBitRate,
MaxStreamingBitrate:
streamingQuality === 'original'
? 120_000_000
: getQualityParams(streamingQuality)?.AudioBitRate,
MusicStreamingTranscodingBitrate: getQualityParams(streamingQuality)?.AudioBitRate,
ContainerProfiles: [],
...platformProfile,
} as DeviceProfile
}
/**
*
* @param streamingQuality
* @returns
*/
const APPLE_PLATFORM_PROFILE: DeviceProfile = {
DirectPlayProfiles: [
{
Container: 'mp3',
Type: DlnaProfileType.Audio,
},
{
Container: 'aac',
Type: DlnaProfileType.Audio,
},
{
AudioCodec: 'aac',
Container: 'm4a',
Type: DlnaProfileType.Audio,
},
{
AudioCodec: 'aac',
Container: 'm4b',
Type: DlnaProfileType.Audio,
},
{
Container: 'flac',
Type: DlnaProfileType.Audio,
},
{
Container: 'alac',
Type: DlnaProfileType.Audio,
},
{
AudioCodec: 'alac',
Container: 'm4a',
Type: DlnaProfileType.Audio,
},
{
AudioCodec: 'alac',
Container: 'm4b',
Type: DlnaProfileType.Audio,
},
{
Container: 'wav',
Type: DlnaProfileType.Audio,
},
],
TranscodingProfiles: [
{
AudioCodec: 'aac',
BreakOnNonKeyFrames: true,
Container: 'aac',
Context: EncodingContext.Streaming,
MaxAudioChannels: '6',
MinSegments: 2,
Protocol: MediaStreamProtocol.Hls,
Type: DlnaProfileType.Audio,
},
{
AudioCodec: 'aac',
Container: 'aac',
Context: EncodingContext.Streaming,
MaxAudioChannels: '6',
Protocol: MediaStreamProtocol.Http,
Type: DlnaProfileType.Audio,
},
{
AudioCodec: 'mp3',
Container: 'mp3',
Context: EncodingContext.Streaming,
MaxAudioChannels: '6',
Protocol: MediaStreamProtocol.Http,
Type: DlnaProfileType.Audio,
},
{
AudioCodec: 'wav',
Container: 'wav',
Context: EncodingContext.Streaming,
MaxAudioChannels: '6',
Protocol: MediaStreamProtocol.Http,
Type: DlnaProfileType.Audio,
},
{
AudioCodec: 'mp3',
Container: 'mp3',
Context: EncodingContext.Static,
MaxAudioChannels: '6',
Protocol: MediaStreamProtocol.Http,
Type: DlnaProfileType.Audio,
},
{
AudioCodec: 'aac',
Container: 'aac',
Context: EncodingContext.Static,
MaxAudioChannels: '6',
Protocol: MediaStreamProtocol.Http,
Type: DlnaProfileType.Audio,
},
{
AudioCodec: 'wav',
Container: 'wav',
Context: EncodingContext.Static,
MaxAudioChannels: '6',
Protocol: MediaStreamProtocol.Http,
Type: DlnaProfileType.Audio,
},
],
}
const DEFAULT_PLATFORM_PROFILE: DeviceProfile = {
DirectPlayProfiles: [
{
Container: 'mp3',
Type: DlnaProfileType.Audio,
},
// {
// Container: 'aac',
// Type: DlnaProfileType.Audio,
// },
// {
// AudioCodec: 'aac',
// Container: 'm4a',
// Type: DlnaProfileType.Audio,
// },
// {
// AudioCodec: 'aac',
// Container: 'm4b',
// Type: DlnaProfileType.Audio,
// },
{
Container: 'flac',
Type: DlnaProfileType.Audio,
},
{
Container: 'wav',
Type: DlnaProfileType.Audio,
},
],
TranscodingProfiles: [
// {
// AudioCodec: 'aac',
// BreakOnNonKeyFrames: true,
// Container: 'aac',
// Context: EncodingContext.Streaming,
// MaxAudioChannels: '6',
// MinSegments: 2,
// Protocol: MediaStreamProtocol.Hls,
// Type: DlnaProfileType.Audio,
// },
// {
// AudioCodec: 'aac',
// Container: 'aac',
// Context: EncodingContext.Streaming,
// MaxAudioChannels: '6',
// Protocol: MediaStreamProtocol.Http,
// Type: DlnaProfileType.Audio,
// },
{
AudioCodec: 'mp3',
Container: 'mp3',
Context: EncodingContext.Streaming,
MaxAudioChannels: '6',
Protocol: MediaStreamProtocol.Http,
Type: DlnaProfileType.Audio,
},
{
AudioCodec: 'wav',
Container: 'wav',
Context: EncodingContext.Streaming,
MaxAudioChannels: '6',
Protocol: MediaStreamProtocol.Http,
Type: DlnaProfileType.Audio,
},
{
AudioCodec: 'mp3',
Container: 'mp3',
Context: EncodingContext.Static,
MaxAudioChannels: '6',
Protocol: MediaStreamProtocol.Http,
Type: DlnaProfileType.Audio,
},
// {
// AudioCodec: 'aac',
// Container: 'aac',
// Context: EncodingContext.Static,
// MaxAudioChannels: '6',
// Protocol: MediaStreamProtocol.Http,
// Type: DlnaProfileType.Audio,
// },
{
AudioCodec: 'wav',
Container: 'wav',
Context: EncodingContext.Static,
MaxAudioChannels: '6',
Protocol: MediaStreamProtocol.Http,
Type: DlnaProfileType.Audio,
},
],
}

View File

@@ -15,9 +15,7 @@ import { storage } from '../constants/storage'
import { MMKVStorageKeys } from '../enums/mmkv-storage-keys'
import { Api } from '@jellyfin/sdk/lib/api'
import { JellyfinInfo } from '../api/info'
import uuid from 'react-native-uuid'
import { queryClient } from '../constants/query-client'
import { MediaInfoApi } from '@jellyfin/sdk/lib/generated-client/api'
/**
* The context for the Jellify provider.
@@ -48,11 +46,6 @@ interface JellifyContext {
*/
library: JellifyLibrary | undefined
/**
* The ID for the current session.
*/
sessionId: string
/**
* The function to set the context {@link JellifyServer}.
*/
@@ -81,14 +74,6 @@ const JellifyContextInitializer = () => {
const libraryJson = storage.getString(MMKVStorageKeys.Library)
const apiJson = storage.getString(MMKVStorageKeys.Api)
/**
* TODO: This is not the correct way to generate a session ID.
*
* Per Niels, we should be using the {@link MediaInfoApi} to retrieve a
* a server-side session ID stored on the {@link PlaybackInfoResponse}.
*/
const sessionId = uuid.v4()
const [api, setApi] = useState<Api | undefined>(apiJson ? JSON.parse(apiJson) : undefined)
const [server, setServer] = useState<JellifyServer | undefined>(
serverJson ? JSON.parse(serverJson) : undefined,
@@ -147,7 +132,6 @@ const JellifyContextInitializer = () => {
server,
user,
library,
sessionId,
setServer,
setUser,
setLibrary,
@@ -161,7 +145,6 @@ const JellifyContext = createContext<JellifyContext>({
server: undefined,
user: undefined,
library: undefined,
sessionId: '',
setServer: () => {},
setUser: () => {},
setLibrary: () => {},
@@ -190,7 +173,6 @@ export const JellifyProvider: ({ children }: { children: ReactNode }) => React.J
context.server?.url,
context.user?.id,
context.library?.musicLibraryId,
context.sessionId,
],
)

View File

@@ -4,10 +4,12 @@ import { ContextProps } from '../types'
export default function ItemContextScreen({ route, navigation }: ContextProps): React.JSX.Element {
return (
<ItemContext
navigation={navigation}
item={route.params.item}
stackNavigation={route.params.navigation}
navigation={navigation}
navigationCallback={route.params.navigationCallback}
streamingMediaSourceInfo={route.params.streamingMediaSourceInfo}
downloadedMediaSourceInfo={route.params.downloadedMediaSourceInfo}
/>
)
}

View File

@@ -4,6 +4,7 @@ import Queue from '../../components/Player/queue'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import MultipleArtistsSheet from '../Context/multiple-artists'
import { PlayerParamList } from './types'
import AudioSpecsSheet from '../Stats'
export const PlayerStack = createNativeStackNavigator<PlayerParamList>()

View File

@@ -7,12 +7,13 @@ import { useJellifyContext } from '../../providers'
import { useNetworkContext } from '../../providers/Network'
import { useResetQueue } from '../../providers/Player/hooks/mutations'
import navigationRef from '../../../navigation'
import { useClearAllDownloads } from '../../api/mutations/download'
export default function SignOutModal({ navigation }: SignOutModalProps): React.JSX.Element {
const { server } = useJellifyContext()
const { mutate: resetQueue } = useResetQueue()
const { clearDownloads } = useNetworkContext()
const clearDownloads = useClearAllDownloads()
return (
<YStack margin={'$6'}>

View File

@@ -0,0 +1,6 @@
import AudioSpecs from '../../components/AudioSpecs'
import { AudioSpecsProps } from '../types'
export default function AudioSpecsSheet({ route, navigation }: AudioSpecsProps): React.JSX.Element {
return <AudioSpecs navigation={navigation} {...route.params} />
}

View File

@@ -1,4 +1,4 @@
import Player from './Player'
import Player, { PlayerStack } from './Player'
import Tabs from './Tabs'
import { RootStackParamList } from './types'
import { getToken, useTheme, YStack } from 'tamagui'
@@ -13,6 +13,7 @@ import TextTicker from 'react-native-text-ticker'
import { TextTickerConfig } from '../components/Player/component.config'
import { Text } from '../components/Global/helpers/text'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import AudioSpecsSheet from './Stats'
const RootStack = createNativeStackNavigator<RootStackParamList>()
@@ -77,6 +78,17 @@ export default function Root(): React.JSX.Element {
sheetGrabberVisible: true,
}}
/>
<RootStack.Screen
name='AudioSpecs'
component={AudioSpecsSheet}
options={({ route }) => ({
header: () => ContextSheetHeader(route.params.item),
presentation: 'formSheet',
sheetAllowedDetents: 'fitToContents',
sheetGrabberVisible: true,
})}
/>
</RootStack.Navigator>
)
}

View File

@@ -1,5 +1,5 @@
import { QueryKeys } from '../enums/query-keys'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client/models'
import { NativeStackNavigationProp, NativeStackScreenProps } from '@react-navigation/native-stack'
import { Queue } from '../player/types/queue-item'
import { MaterialTopTabBarProps } from '@react-navigation/material-top-tabs'
@@ -59,6 +59,8 @@ export type RootStackParamList = {
Context: {
item: BaseItemDto
streamingMediaSourceInfo?: MediaSourceInfo
downloadedMediaSourceInfo?: MediaSourceInfo
navigation?: Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
navigationCallback?: (screen: 'Album' | 'Artist', item: BaseItemDto) => void
}
@@ -66,6 +68,12 @@ export type RootStackParamList = {
AddToPlaylist: {
track: BaseItemDto
}
AudioSpecs: {
item: BaseItemDto
streamingMediaSourceInfo?: MediaSourceInfo
downloadedMediaSourceInfo?: MediaSourceInfo
}
}
export type LoginProps = NativeStackNavigationProp<RootStackParamList, 'Login'>
@@ -73,6 +81,7 @@ export type TabProps = NativeStackScreenProps<RootStackParamList, 'Tabs'>
export type PlayerProps = NativeStackScreenProps<RootStackParamList, 'PlayerRoot'>
export type ContextProps = NativeStackScreenProps<RootStackParamList, 'Context'>
export type AddToPlaylistProps = NativeStackScreenProps<RootStackParamList, 'AddToPlaylist'>
export type AudioSpecsProps = NativeStackScreenProps<RootStackParamList, 'AudioSpecs'>
export type ArtistsProps = {
artistsInfiniteQuery: UseInfiniteQueryResult<

View File

@@ -0,0 +1,46 @@
import { DeviceProfile } from '@jellyfin/sdk/lib/generated-client'
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
type DeviceProfileStore = {
deviceProfile: DeviceProfile
setDeviceProfile: (data: DeviceProfile) => void
}
export const useStreamingDeviceProfileStore = create<DeviceProfileStore>()(
devtools(
persist(
(set) => ({
deviceProfile: {},
setDeviceProfile: (data: DeviceProfile) => set({ deviceProfile: data }),
}),
{
name: 'streaming-device-profile-storage',
},
),
),
)
const useStreamingDeviceProfile = () => {
return useStreamingDeviceProfileStore((state) => state.deviceProfile)
}
export default useStreamingDeviceProfile
export const useDownloadingDeviceProfileStore = create<DeviceProfileStore>()(
devtools(
persist(
(set) => ({
deviceProfile: {},
setDeviceProfile: (data: DeviceProfile) => set({ deviceProfile: data }),
}),
{
name: 'downloading-device-profile-storage',
},
),
),
)
export const useDownloadingDeviceProfile = () => {
return useDownloadingDeviceProfileStore((state) => state.deviceProfile)
}

View File

@@ -0,0 +1,39 @@
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
type PlayerSettingsStore = {
displayAudioQualityBadge: boolean
setDisplayAudioQualityBadge: (displayAudioQualityBadge: boolean) => void
}
export const usePlayerSettingsStore = create<PlayerSettingsStore>()(
devtools(
persist(
(set) => ({
displayAudioQualityBadge: false,
setDisplayAudioQualityBadge: (displayAudioQualityBadge) =>
set({ displayAudioQualityBadge }),
}),
{
name: 'player-settings-storage',
},
),
),
)
export const useDisplayAudioQualityBadge: () => [
boolean,
(displayAudioQualityBadge: boolean) => void,
] = () => {
const displayAudioQualityBadge = usePlayerSettingsStore(
(state) => state.displayAudioQualityBadge,
)
const setDisplayAudioQualityBadge = usePlayerSettingsStore(
(state) => state.setDisplayAudioQualityBadge,
)
return [displayAudioQualityBadge, setDisplayAudioQualityBadge]
}
export const useSetDisplayAudioQualityBadge = () =>
usePlayerSettingsStore((state) => state.setDisplayAudioQualityBadge)

View File

View File

@@ -1,21 +1,14 @@
import { PitchAlgorithm, RatingType, Track, TrackType } from 'react-native-track-player'
import { RatingType, Track } from 'react-native-track-player'
import { QueuingType } from '../enums/queuing-type'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client/models'
export type SourceType = 'stream' | 'download'
interface JellifyTrack extends Track {
url: string
type?: TrackType | undefined
userAgent?: string | undefined
contentType?: string | undefined
pitchAlgorithm?: PitchAlgorithm | undefined
/* eslint-disable @typescript-eslint/no-explicit-any */
headers?: { [key: string]: any } | undefined
title?: string | undefined
album?: string | undefined
artist?: string | undefined
duration?: number | undefined
duration: number
artwork?: string | undefined
description?: string | undefined
genre?: string | undefined
@@ -23,7 +16,10 @@ interface JellifyTrack extends Track {
rating?: RatingType | undefined
isLiveStream?: boolean | undefined
sourceType: SourceType
item: BaseItemDto
sessionId: string | null | undefined
mediaSourceInfo?: MediaSourceInfo
/**
* Represents the type of queuing for this song, be it that it was

View File

@@ -1,13 +1,15 @@
import {
BaseItemDto,
DeviceProfile,
ImageType,
MediaSourceInfo,
PlaybackInfoResponse,
} from '@jellyfin/sdk/lib/generated-client/models'
import JellifyTrack from '../types/JellifyTrack'
import TrackPlayer, { Track, TrackType } from 'react-native-track-player'
import { QueuingType } from '../enums/queuing-type'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import { AudioApi, UniversalAudioApi } from '@jellyfin/sdk/lib/generated-client/api'
import { AudioApi } from '@jellyfin/sdk/lib/generated-client/api'
import { JellifyDownload } from '../types/JellifyDownload'
import { Api } from '@jellyfin/sdk/lib/api'
import RNFS from 'react-native-fs'
@@ -17,21 +19,7 @@ import { queryClient } from '../constants/query-client'
import { QueryKeys } from '../enums/query-keys'
import { isUndefined } from 'lodash'
import uuid from 'react-native-uuid'
/**
* The container that the Jellyfin server will attempt to transcode to
*
* This is set to `ts` (MPEG-TS), as that is what HLS relies upon
*
* Finamp and Jellyfin Web also have this set to `ts`
* @see https://jmshrv.com/posts/jellyfin-api/#playback-in-the-case-of-music
*/
const transcodingContainer = 'ts'
/*
* The type of track to use for the player
*/
const type = TrackType.Default
import { convertRunTimeTicksToSeconds } from './runtimeticks'
/**
* Gets quality-specific parameters for transcoding
@@ -68,6 +56,11 @@ export function getQualityParams(
}
}
type TrackMediaInfo = Pick<
JellifyTrack,
'url' | 'image' | 'duration' | 'item' | 'mediaSourceInfo' | 'sessionId' | 'sourceType' | 'type'
>
/**
* A mapper function that can be used to get a RNTP {@link Track} compliant object
* from a Jellyfin server {@link BaseItemDto}. Applies a queuing type to the track
@@ -84,41 +77,99 @@ export function mapDtoToTrack(
api: Api,
item: BaseItemDto,
downloadedTracks: JellifyDownload[],
deviceProfile: DeviceProfile,
queuingType?: QueuingType,
downloadQuality: DownloadQuality = 'medium',
streamingQuality?: StreamingQuality | undefined,
): JellifyTrack {
const downloads = downloadedTracks.filter((download) => download.item.Id === item.Id)
let url: string
let image: string | undefined
const mediaInfo = queryClient.getQueryData([
QueryKeys.MediaSources,
deviceProfile?.Name,
item.Id,
]) as PlaybackInfoResponse | undefined
if (downloads.length > 0 && downloads[0].path) {
url = `file://${RNFS.DocumentDirectoryPath}/${downloads[0].path.split('/').pop()}`
image = `file://${RNFS.DocumentDirectoryPath}/${downloads[0].artwork?.split('/').pop()}`
} else {
url = buildAudioApiUrl(api, item, streamingQuality, downloadQuality)
image = item.AlbumId
? getImageApi(api).getItemImageUrlById(item.AlbumId, ImageType.Primary)
: undefined
}
let trackMediaInfo: TrackMediaInfo
// Prioritize downloads over streaming to save bandwidth
if (downloads.length > 0 && downloads[0].path)
trackMediaInfo = buildDownloadedTrack(downloads[0])
/**
* Prioritize transcoding over direct play
* so that unsupported codecs playback properly
*
* (i.e. ALAC audio on Android)
*/ else if (mediaInfo?.MediaSources && mediaInfo.MediaSources[0].TranscodingUrl) {
trackMediaInfo = buildTranscodedTrack(
api,
item,
mediaInfo!.MediaSources![0],
mediaInfo?.PlaySessionId,
)
} else
trackMediaInfo = {
url: buildAudioApiUrl(api, item, deviceProfile),
image: item.AlbumId
? getImageApi(api).getItemImageUrlById(item.AlbumId, ImageType.Primary)
: undefined,
duration: convertRunTimeTicksToSeconds(item.RunTimeTicks!),
item,
sessionId: mediaInfo?.PlaySessionId,
mediaSourceInfo:
mediaInfo && mediaInfo.MediaSources ? mediaInfo.MediaSources[0] : undefined,
sourceType: 'stream',
type: TrackType.Default,
}
return {
url,
type,
headers: {
'X-Emby-Token': api.accessToken,
},
...trackMediaInfo,
title: item.Name,
album: item.Album,
artist: item.Artists?.join(' • '),
duration: item.RunTimeTicks,
artwork: image,
item,
artwork: trackMediaInfo.image,
QueuingType: queuingType ?? QueuingType.DirectlyQueued,
} as JellifyTrack
}
function buildDownloadedTrack(downloadedTrack: JellifyDownload): TrackMediaInfo {
return {
type: TrackType.Default,
url: `file://${RNFS.DocumentDirectoryPath}/${downloadedTrack.path!.split('/').pop()}`,
image: `file://${RNFS.DocumentDirectoryPath}/${downloadedTrack.artwork!.split('/').pop()}`,
duration: convertRunTimeTicksToSeconds(
downloadedTrack.mediaSourceInfo?.RunTimeTicks || downloadedTrack.item.RunTimeTicks || 0,
),
item: downloadedTrack.item,
mediaSourceInfo: downloadedTrack.mediaSourceInfo,
sessionId: downloadedTrack.sessionId,
sourceType: 'download',
}
}
function buildTranscodedTrack(
api: Api,
item: BaseItemDto,
mediaSourceInfo: MediaSourceInfo,
sessionId: string | null | undefined,
): TrackMediaInfo {
const { AlbumId, RunTimeTicks } = item
return {
type: TrackType.HLS,
url: `${api.basePath}${mediaSourceInfo.TranscodingUrl}`,
image: AlbumId
? getImageApi(api).getItemImageUrlById(AlbumId, ImageType.Primary)
: undefined,
duration: convertRunTimeTicksToSeconds(RunTimeTicks ?? 0),
mediaSourceInfo,
item,
sessionId,
sourceType: 'stream',
}
}
/**
* Builds a URL targeting the {@link AudioApi}, using data contained in the
* {@link PlaybackInfoResponse}
@@ -131,19 +182,14 @@ export function mapDtoToTrack(
function buildAudioApiUrl(
api: Api,
item: BaseItemDto,
streamingQuality: StreamingQuality | undefined,
downloadQuality: DownloadQuality,
deviceProfile: DeviceProfile | undefined,
): string {
// Use streamingQuality for URL generation, fallback to downloadQuality for backward compatibility
const qualityForStreaming = streamingQuality || downloadQuality
const qualityParams = getQualityParams(qualityForStreaming)
console.debug(
`Mapping BaseItemDTO to Track object with streaming quality: ${qualityForStreaming}`,
`Mapping BaseItemDTO to Track object with streaming quality: ${deviceProfile?.Name}`,
)
const mediaInfo = queryClient.getQueryData([
QueryKeys.MediaSources,
streamingQuality,
deviceProfile?.Name,
item.Id,
]) as PlaybackInfoResponse | undefined
@@ -157,7 +203,6 @@ function buildAudioApiUrl(
playSessionId: mediaInfo?.PlaySessionId ?? uuid.v4(),
startTimeTicks: '0',
static: 'true',
...qualityParams,
}
if (mediaSource.Container! !== 'mpeg') container = mediaSource.Container!
@@ -166,45 +211,12 @@ function buildAudioApiUrl(
playSessionId: uuid.v4(),
StartTimeTicks: '0',
static: 'true',
...qualityParams,
}
if (item.Container! !== 'mpeg') container = item.Container!
}
return `${api.basePath}/Audio/${item.Id!}/stream.${container}?${new URLSearchParams(urlParams)}`
}
/**
* @deprecated Per Niels we should not be using the {@link UniversalAudioApi},
* but rather the {@link AudioApi}.
*
* Builds a URL targeting the {@link UniversalAudioApi}, used as a fallback
* when there is no {@link PlaybackInfoResponse} available
*
* @param api The API instance
* @param item The item to build the URL for
* @param sessionId The session ID
* @param qualityParams The quality parameters
* @returns The URL for the universal audio API
*/
function buildUniversalAudioApiUrl(
api: Api,
item: BaseItemDto,
sessionId: string,
qualityParams: Record<string, string>,
): string {
const urlParams = {
Container: item.Container!,
TranscodingContainer: transcodingContainer,
EnableRemoteMedia: 'true',
EnableRedirection: 'true',
api_key: api.accessToken,
StartTimeTicks: '0',
PlaySessionId: sessionId,
...qualityParams,
}
return `${api.basePath}/Audio/${item.Id!}/universal?${new URLSearchParams(urlParams)}`
return `${api.basePath}/Audio/${item.Id!}/stream?${new URLSearchParams(urlParams)}`
}
function mediaSourceExists(mediaInfo: PlaybackInfoResponse | undefined): boolean {

8
src/utils/url-parsers.ts Normal file
View File

@@ -0,0 +1,8 @@
export function parseBitrateFromTranscodingUrl(transcodingUrl: string): number {
return parseInt(
transcodingUrl
.split('&')
.find((part) => part.includes('AudioBitrate'))!
.split('=')[1],
)
}