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> <SafeAreaProvider>
<OTAUpdateScreen /> <OTAUpdateScreen />
<ErrorBoundary reloader={reloader} onRetry={handleRetry}> <ErrorBoundary reloader={reloader} onRetry={handleRetry}>
<SettingsProvider> <PersistQueryClientProvider
<Container playerIsReady={playerIsReady} /> client={queryClient}
</SettingsProvider> persistOptions={{
persister: clientPersister,
/**
* Maximum query data age of one day
*/
maxAge: Infinity,
}}
>
<SettingsProvider>
<Container playerIsReady={playerIsReady} />
</SettingsProvider>
</PersistQueryClientProvider>
</ErrorBoundary> </ErrorBoundary>
</SafeAreaProvider> </SafeAreaProvider>
</React.StrictMode> </React.StrictMode>
@@ -104,23 +116,11 @@ function Container({ playerIsReady }: { playerIsReady: boolean }): React.JSX.Ele
: JellifyLightTheme : JellifyLightTheme
} }
> >
<PersistQueryClientProvider <GestureHandlerRootView>
client={queryClient} <TamaguiProvider config={jellifyConfig}>
persistOptions={{ {playerIsReady && <Jellify />}
persister: clientPersister, </TamaguiProvider>
</GestureHandlerRootView>
/**
* Maximum query data age of one day
*/
maxAge: Infinity,
}}
>
<GestureHandlerRootView>
<TamaguiProvider config={jellifyConfig}>
{playerIsReady && <Jellify />}
</TamaguiProvider>
</GestureHandlerRootView>
</PersistQueryClientProvider>
</NavigationContainer> </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 MMKV](https://github.com/mrousavy/react-native-mmkv)\
[React Native OTA Hot Update](https://github.com/vantuan88291/react-native-ota-hot-update)\ [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 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 ### 👩‍💻 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. - 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) - 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](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 - 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 - 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 - [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 - 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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { PlayerProvider } from '../../src/providers/Player' import { PlayerProvider } from '../../src/providers/Player'
import { View } from 'react-native'
import { JellifyProvider } from '../../src/providers' import { JellifyProvider } from '../../src/providers'
const queryClient = new QueryClient() const queryClient = new QueryClient()

View File

@@ -1,12 +1,16 @@
import JellifyTrack from '../../src/types/JellifyTrack'
import calculateTrackVolume from '../../src/providers/Player/utils/normalization' import calculateTrackVolume from '../../src/providers/Player/utils/normalization'
describe('Normalization Module', () => { describe('Normalization Module', () => {
it('should calculate the volume for a track with a normalization gain of 6', () => { 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', url: 'https://example.com/track.mp3',
item: { item: {
NormalizationGain: 6, // 6 Gain means the track is quieter than the target volume 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) 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', () => { 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', url: 'https://example.com/track.mp3',
item: { item: {
NormalizationGain: 0, // 0 Gain means the track is at the target volume NormalizationGain: 0, // 0 Gain means the track is at the target volume
}, },
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
} }
const volume = calculateTrackVolume(track) 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', () => { 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', url: 'https://example.com/track.mp3',
item: { item: {
NormalizationGain: -10, // -10 Gain means the track is louder than the target volume 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) 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 () => { it('should return the index of the active track + 1', async () => {
const result = await findPlayNextIndexStart([ 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) 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 () => { it('should return 0 if the active track is not in the queue', async () => {
const result = await findPlayNextIndexStart([ 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) expect(result).toBe(0)
@@ -40,6 +56,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com', url: 'https://example.com',
item: { Id: '1' }, item: { Id: '1' },
QueuingType: QueuingType.FromSelection, QueuingType: QueuingType.FromSelection,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
}, },
{ {
id: '2', id: '2',
@@ -47,6 +66,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com', url: 'https://example.com',
item: { Id: '2' }, item: { Id: '2' },
QueuingType: QueuingType.PlayingNext, QueuingType: QueuingType.PlayingNext,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
}, },
{ {
id: '3', id: '3',
@@ -54,6 +76,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com', url: 'https://example.com',
item: { Id: '3' }, item: { Id: '3' },
QueuingType: QueuingType.DirectlyQueued, QueuingType: QueuingType.DirectlyQueued,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
}, },
], ],
0, 0,
@@ -71,6 +96,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com', url: 'https://example.com',
item: { Id: '1' }, item: { Id: '1' },
QueuingType: QueuingType.FromSelection, QueuingType: QueuingType.FromSelection,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
}, },
{ {
id: '2', id: '2',
@@ -78,6 +106,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com', url: 'https://example.com',
item: { Id: '2' }, item: { Id: '2' },
QueuingType: QueuingType.PlayingNext, QueuingType: QueuingType.PlayingNext,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
}, },
{ {
id: '3', id: '3',
@@ -85,6 +116,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com', url: 'https://example.com',
item: { Id: '3' }, item: { Id: '3' },
QueuingType: QueuingType.DirectlyQueued, QueuingType: QueuingType.DirectlyQueued,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
}, },
{ {
id: '4', id: '4',
@@ -92,6 +126,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com', url: 'https://example.com',
item: { Id: '4' }, item: { Id: '4' },
QueuingType: QueuingType.FromSelection, QueuingType: QueuingType.FromSelection,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
}, },
{ {
id: '5', id: '5',
@@ -99,6 +136,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com', url: 'https://example.com',
item: { Id: '5' }, item: { Id: '5' },
QueuingType: QueuingType.FromSelection, QueuingType: QueuingType.FromSelection,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
}, },
], ],
0, 0,
@@ -116,6 +156,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com', url: 'https://example.com',
item: { Id: '2' }, item: { Id: '2' },
QueuingType: QueuingType.FromSelection, QueuingType: QueuingType.FromSelection,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
}, },
{ {
id: '1', id: '1',
@@ -123,6 +166,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com', url: 'https://example.com',
item: { Id: '1' }, item: { Id: '1' },
QueuingType: QueuingType.PlayingNext, QueuingType: QueuingType.PlayingNext,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
}, },
{ {
id: '3', id: '3',
@@ -130,6 +176,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com', url: 'https://example.com',
item: { Id: '3' }, item: { Id: '3' },
QueuingType: QueuingType.DirectlyQueued, QueuingType: QueuingType.DirectlyQueued,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
}, },
{ {
id: '5', id: '5',
@@ -137,6 +186,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com', url: 'https://example.com',
item: { Id: '5' }, item: { Id: '5' },
QueuingType: QueuingType.FromSelection, QueuingType: QueuingType.FromSelection,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
}, },
{ {
id: '4', id: '4',
@@ -144,6 +196,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com', url: 'https://example.com',
item: { Id: '4' }, item: { Id: '4' },
QueuingType: QueuingType.FromSelection, QueuingType: QueuingType.FromSelection,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
}, },
{ {
id: '6', id: '6',
@@ -151,6 +206,9 @@ describe('Queue Index Util', () => {
url: 'https://example.com', url: 'https://example.com',
item: { Id: '6' }, item: { Id: '6' },
QueuingType: QueuingType.FromSelection, QueuingType: QueuingType.FromSelection,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
}, },
], ],
0, 0,

View File

@@ -12,6 +12,9 @@ describe('Shuffle Utility Function', () => {
id: `track-${i + 1}`, id: `track-${i + 1}`,
title: `Track ${i + 1}`, title: `Track ${i + 1}`,
artist: `Artist ${i + 1}`, artist: `Artist ${i + 1}`,
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
item: { item: {
Id: `${i + 1}`, Id: `${i + 1}`,
Name: `Track ${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 { Progress } from 'react-native-track-player'
import { shouldMarkPlaybackFinished } from '../../src/providers/Player/utils/handlers'
describe('Playback Event Handlers', () => { describe('Playback Event Handlers', () => {
it('should determine that the track has finished', () => { it('should determine that the track has finished', () => {
@@ -9,7 +9,9 @@ describe('Playback Event Handlers', () => {
buffered: 98.2345568679345, buffered: 98.2345568679345,
} }
const playbackFinished = shouldMarkPlaybackFinished(progress.duration, progress.position) const { position, duration } = progress
const playbackFinished = isPlaybackFinished(position, duration)
expect(playbackFinished).toBeTruthy() expect(playbackFinished).toBeTruthy()
}) })
@@ -21,7 +23,9 @@ describe('Playback Event Handlers', () => {
buffered: 98.2345568679345, buffered: 98.2345568679345,
} }
const playbackFinished = shouldMarkPlaybackFinished(progress.duration, progress.position) const { position, duration } = progress
const playbackFinished = isPlaybackFinished(position, duration)
expect(playbackFinished).toBeFalsy() 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 { MMKV } from 'react-native-mmkv'
import RNFS from 'react-native-fs' import RNFS from 'react-native-fs'
import JellifyTrack from '../../types/JellifyTrack' import JellifyTrack from '../../../types/JellifyTrack'
import axios from 'axios' import axios from 'axios'
import { import {
JellifyDownload, JellifyDownload,
JellifyDownloadProgress, JellifyDownloadProgress,
JellifyDownloadProgressState, JellifyDownloadProgressState,
} from '../../types/JellifyDownload' } from '../../../types/JellifyDownload'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { queryClient } from '../../constants/query-client' import { queryClient } from '../../../constants/query-client'
import { QueryKeys } from '../../enums/query-keys' import { QueryKeys } from '../../../enums/query-keys'
export async function downloadJellyfinFile( export async function downloadJellyfinFile(
url: string, url: string,
@@ -162,10 +162,10 @@ export const saveAudio = async (
return true return true
} }
export const deleteAudio = async (trackItem: BaseItemDto) => { export const deleteAudio = async (itemId: string | undefined | null) => {
const downloads = getAudioCache() 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) { if (download.length === 1) {
RNFS.unlink(`${RNFS.DocumentDirectoryPath}/${download[0].item.Id}`) 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' } from '@jellyfin/sdk/lib/generated-client/models'
import { getArtistsApi, getItemsApi } from '@jellyfin/sdk/lib/utils/api' import { getArtistsApi, getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { JellifyUser } from '../../types/JellifyUser' import { JellifyUser } from '../../types/JellifyUser'
import QueryConfig from './query.config' import { ApiLimits } from './query.config'
export function fetchArtists( export function fetchArtists(
api: Api | undefined, api: Api | undefined,
@@ -31,13 +31,13 @@ export function fetchArtists(
.getAlbumArtists({ .getAlbumArtists({
parentId: library.musicLibraryId, parentId: library.musicLibraryId,
userId: user.id, userId: user.id,
enableUserData: true, enableUserData: false, // This data is fetched lazily on component render
sortBy: sortBy, sortBy: sortBy,
sortOrder: sortOrder, sortOrder: sortOrder,
startIndex: page * QueryConfig.limits.library, startIndex: page * ApiLimits.Library,
limit: QueryConfig.limits.library, limit: ApiLimits.Library,
isFavorite: isFavorite, isFavorite: isFavorite,
fields: [ItemFields.SortName, ItemFields.ChildCount], fields: [ItemFields.SortName],
}) })
.then((response) => { .then((response) => {
console.debug('Artists Response received') 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 { 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 { getMediaInfoApi } from '@jellyfin/sdk/lib/utils/api'
import { isUndefined } from 'lodash' import { isUndefined } from 'lodash'
import { JellifyUser } from '../../types/JellifyUser' import { JellifyUser } from '../../../../types/JellifyUser'
import { AudioQuality } from '../../types/AudioQuality'
export async function fetchMediaInfo( export async function fetchMediaInfo(
api: Api | undefined, api: Api | undefined,
user: JellifyUser | undefined, user: JellifyUser | undefined,
bitrate: AudioQuality | undefined, deviceProfile: DeviceProfile | undefined,
itemId: string, itemId: string | null | undefined,
): Promise<PlaybackInfoResponse> { ): 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) => { return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set') if (isUndefined(api)) return reject('Client instance not set')
@@ -22,12 +21,7 @@ export async function fetchMediaInfo(
itemId: itemId!, itemId: itemId!,
userId: user.id, userId: user.id,
playbackInfoDto: { playbackInfoDto: {
MaxAudioChannels: bitrate?.MaxAudioBitDepth DeviceProfile: deviceProfile,
? parseInt(bitrate.MaxAudioBitDepth)
: undefined,
MaxStreamingBitrate: bitrate?.AudioBitRate
? parseInt(bitrate.AudioBitRate)
: undefined,
}, },
}) })
.then(({ data }) => { .then(({ data }) => {

View File

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

View File

@@ -11,7 +11,7 @@ import LibraryStackParamList from '../../screens/Library/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { warmItemContext } from '../../hooks/use-item-context' import { warmItemContext } from '../../hooks/use-item-context'
import { useJellifyContext } from '../../providers' import { useJellifyContext } from '../../providers'
import { useStreamingQualityContext } from '../../providers/Settings' import useStreamingDeviceProfile from '../../stores/device-profile'
interface AlbumsProps { interface AlbumsProps {
albums: (string | number | BaseItemDto)[] | undefined albums: (string | number | BaseItemDto)[] | undefined
@@ -33,13 +33,13 @@ export default function Albums({
const { api, user } = useJellifyContext() const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext() const deviceProfile = useStreamingDeviceProfile()
const onViewableItemsChangedRef = useRef( const onViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: ViewToken<string | number | BaseItemDto>[] }) => { ({ viewableItems }: { viewableItems: ViewToken<string | number | BaseItemDto>[] }) => {
viewableItems.forEach(({ isViewable, item }) => { viewableItems.forEach(({ isViewable, item }) => {
if (isViewable && typeof item === 'object') 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 { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import { warmItemContext } from '../../hooks/use-item-context' import { warmItemContext } from '../../hooks/use-item-context'
import { useJellifyContext } from '../../providers' import { useJellifyContext } from '../../providers'
import { useStreamingQualityContext } from '../../providers/Settings' import useStreamingDeviceProfile from '../../stores/device-profile'
export default function Albums({ export default function Albums({
route, route,
navigation, navigation,
}: ArtistAlbumsProps | ArtistEpsProps | ArtistFeaturedOnProps): React.JSX.Element { }: ArtistAlbumsProps | ArtistEpsProps | ArtistFeaturedOnProps): React.JSX.Element {
const { api, user } = useJellifyContext() const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext() const deviceProfile = useStreamingDeviceProfile()
const { width } = useSafeAreaFrame() const { width } = useSafeAreaFrame()
const { albums, fetchingAlbums, featuredOn, scroll } = useArtistContext() const { albums, fetchingAlbums, featuredOn, scroll } = useArtistContext()
@@ -33,7 +33,7 @@ export default function Albums({
const onViewableItemsChangedRef = useRef( const onViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: ViewToken<BaseItemDto>[] }) => { ({ viewableItems }: { viewableItems: ViewToken<BaseItemDto>[] }) => {
viewableItems.forEach(({ isViewable, item }) => { 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 { QueuingType } from '../../enums/queuing-type'
import { fetchAlbumDiscs } from '../../api/queries/item' import { fetchAlbumDiscs } from '../../api/queries/item'
import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { BaseStackParamList } from '@/src/screens/types' import { BaseStackParamList } from '../../screens/types'
import { useDownloadQualityContext, useStreamingQualityContext } from '../../providers/Settings' import { useDownloadQualityContext } from '../../providers/Settings'
import { useNetworkContext } from '../../providers/Network' import { useNetworkContext } from '../../providers/Network'
import useStreamingDeviceProfile from '../../stores/device-profile'
import { useAllDownloadedTracks } from '../../api/queries/download'
export default function ArtistTabBar({ export default function ArtistTabBar({
stackNavigation, stackNavigation,
@@ -31,11 +33,13 @@ export default function ArtistTabBar({
const { artist, scroll, albums } = useArtistContext() const { artist, scroll, albums } = useArtistContext()
const { mutate: loadNewQueue } = useLoadNewQueue() const { mutate: loadNewQueue } = useLoadNewQueue()
const streamingQuality = useStreamingQualityContext() const deviceProfile = useStreamingDeviceProfile()
const downloadQuality = useDownloadQualityContext() const downloadQuality = useDownloadQualityContext()
const { downloadedTracks, networkStatus } = useNetworkContext() const { networkStatus } = useNetworkContext()
const { data: downloadedTracks } = useAllDownloadedTracks()
const { width } = useSafeAreaFrame() const { width } = useSafeAreaFrame()
@@ -60,7 +64,7 @@ export default function ArtistTabBar({
api, api,
downloadedTracks, downloadedTracks,
networkStatus, networkStatus,
streamingQuality, deviceProfile,
downloadQuality, downloadQuality,
track: allTracks[0], track: allTracks[0],
index: 0, index: 0,

View File

@@ -15,7 +15,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import LibraryStackParamList from '../../screens/Library/types' import LibraryStackParamList from '../../screens/Library/types'
import { warmItemContext } from '../../hooks/use-item-context' import { warmItemContext } from '../../hooks/use-item-context'
import { useJellifyContext } from '../../providers' import { useJellifyContext } from '../../providers'
import { useStreamingQualityContext } from '../../providers/Settings' import useStreamingDeviceProfile from '../../stores/device-profile'
/** /**
* @param artistsInfiniteQuery - The infinite query for artists * @param artistsInfiniteQuery - The infinite query for artists
@@ -33,7 +33,7 @@ export default function Artists({
const { api, user } = useJellifyContext() const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext() const deviceProfile = useStreamingDeviceProfile()
const { isFavorites } = useLibrarySortAndFilterContext() const { isFavorites } = useLibrarySortAndFilterContext()
@@ -48,7 +48,7 @@ export default function Artists({
({ viewableItems }: { viewableItems: ViewToken<string | number | BaseItemDto>[] }) => { ({ viewableItems }: { viewableItems: ViewToken<string | number | BaseItemDto>[] }) => {
viewableItems.forEach(({ isViewable, item }) => { viewableItems.forEach(({ isViewable, item }) => {
if (isViewable && typeof item === 'object') 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) if (artistsInfiniteQuery.hasNextPage && !artistsInfiniteQuery.isFetching)
artistsInfiniteQuery.fetchNextPage() artistsInfiniteQuery.fetchNextPage()
}} }}
// onEndReachedThreshold default is 0.5
removeClippedSubviews removeClippedSubviews
onViewableItemsChanged={onViewableItemsChangedRef.current} 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 { QueryKeys } from '../../enums/query-keys'
import { CarPlay, ListTemplate } from 'react-native-carplay' import { CarPlay, ListTemplate } from 'react-native-carplay'
import { queryClient } from '../../constants/query-client' 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 TracksTemplate from './Tracks'
import ArtistsTemplate from './Artists' import ArtistsTemplate from './Artists'
import uuid from 'react-native-uuid' import uuid from 'react-native-uuid'
@@ -11,7 +11,7 @@ import { JellifyLibrary } from '../../types/JellifyLibrary'
import { Api } from '@jellyfin/sdk' import { Api } from '@jellyfin/sdk'
import { JellifyDownload } from '../../types/JellifyDownload' import { JellifyDownload } from '../../types/JellifyDownload'
import { networkStatusTypes } from '../Network/internetConnectionWatcher' import { networkStatusTypes } from '../Network/internetConnectionWatcher'
import { DownloadQuality, StreamingQuality } from '../../providers/Settings' import { DownloadQuality } from '../../providers/Settings'
const CarPlayHome = ( const CarPlayHome = (
library: JellifyLibrary, library: JellifyLibrary,
@@ -19,7 +19,7 @@ const CarPlayHome = (
api: Api | undefined, api: Api | undefined,
downloadedTracks: JellifyDownload[] | undefined, downloadedTracks: JellifyDownload[] | undefined,
networkStatus: networkStatusTypes | null, networkStatus: networkStatusTypes | null,
streamingQuality: StreamingQuality, deviceProfile: DeviceProfile | undefined,
downloadQuality: DownloadQuality, downloadQuality: DownloadQuality,
) => ) =>
new ListTemplate({ new ListTemplate({
@@ -71,7 +71,7 @@ const CarPlayHome = (
api, api,
downloadedTracks, downloadedTracks,
networkStatus, networkStatus,
streamingQuality, deviceProfile,
downloadQuality, downloadQuality,
), ),
) )
@@ -102,7 +102,7 @@ const CarPlayHome = (
api, api,
downloadedTracks, downloadedTracks,
networkStatus, networkStatus,
streamingQuality, deviceProfile,
downloadQuality, downloadQuality,
), ),
) )

View File

@@ -7,7 +7,8 @@ import { JellifyLibrary } from '../../types/JellifyLibrary'
import { Api } from '@jellyfin/sdk' import { Api } from '@jellyfin/sdk'
import { JellifyDownload } from '@/src/types/JellifyDownload' import { JellifyDownload } from '@/src/types/JellifyDownload'
import { networkStatusTypes } from '../Network/internetConnectionWatcher' 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 = ( const CarPlayNavigation = (
library: JellifyLibrary, library: JellifyLibrary,
@@ -15,7 +16,7 @@ const CarPlayNavigation = (
api: Api | undefined, api: Api | undefined,
downloadedTracks: JellifyDownload[] | undefined, downloadedTracks: JellifyDownload[] | undefined,
networkStatus: networkStatusTypes | null, networkStatus: networkStatusTypes | null,
streamingQuality: StreamingQuality, deviceProfile: DeviceProfile | undefined,
downloadQuality: DownloadQuality, downloadQuality: DownloadQuality,
) => ) =>
new TabBarTemplate({ new TabBarTemplate({
@@ -28,7 +29,7 @@ const CarPlayNavigation = (
api, api,
downloadedTracks, downloadedTracks,
networkStatus, networkStatus,
streamingQuality, deviceProfile,
downloadQuality, downloadQuality,
), ),
CarPlayDiscover, 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 { CarPlay, ListTemplate } from 'react-native-carplay'
import uuid from 'react-native-uuid' import uuid from 'react-native-uuid'
import CarPlayNowPlaying from './NowPlaying' import CarPlayNowPlaying from './NowPlaying'
@@ -8,7 +8,7 @@ import { QueuingType } from '../../enums/queuing-type'
import { Api } from '@jellyfin/sdk' import { Api } from '@jellyfin/sdk'
import { JellifyDownload } from '../../types/JellifyDownload' import { JellifyDownload } from '../../types/JellifyDownload'
import { networkStatusTypes } from '../Network/internetConnectionWatcher' import { networkStatusTypes } from '../Network/internetConnectionWatcher'
import { DownloadQuality, StreamingQuality } from '../../providers/Settings' import { DownloadQuality } from '../../providers/Settings'
const TracksTemplate = ( const TracksTemplate = (
items: BaseItemDto[], items: BaseItemDto[],
@@ -17,7 +17,7 @@ const TracksTemplate = (
api: Api | undefined, api: Api | undefined,
downloadedTracks: JellifyDownload[] | undefined, downloadedTracks: JellifyDownload[] | undefined,
networkStatus: networkStatusTypes | null, networkStatus: networkStatusTypes | null,
streamingQuality: StreamingQuality, deviceProfile: DeviceProfile | undefined,
downloadQuality: DownloadQuality, downloadQuality: DownloadQuality,
) => ) =>
new ListTemplate({ new ListTemplate({
@@ -37,7 +37,7 @@ const TracksTemplate = (
loadQueue({ loadQueue({
api, api,
networkStatus, networkStatus,
streamingQuality, deviceProfile,
downloadQuality, downloadQuality,
downloadedTracks, downloadedTracks,
queuingType: QueuingType.FromSelection, queuingType: QueuingType.FromSelection,

View File

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

View File

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

View File

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

View File

@@ -39,7 +39,7 @@ export default function FavoriteContextMenuRow({ item }: { item: BaseItemDto }):
exiting={FadeOut} exiting={FadeOut}
key={`${item.Id}-remove-favorite-row`} 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'} /> <Icon name={'heart'} small color={'$primary'} />
<Text bold>Remove from favorites</Text> <Text bold>Remove from favorites</Text>
@@ -51,7 +51,6 @@ export default function FavoriteContextMenuRow({ item }: { item: BaseItemDto }):
animation={'quick'} animation={'quick'}
backgroundColor={'transparent'} backgroundColor={'transparent'}
justifyContent='flex-start' justifyContent='flex-start'
gap={'$2'}
onPress={() => { onPress={() => {
toggleFavorite(!!isFavorite, { toggleFavorite(!!isFavorite, {
item, item,
@@ -61,7 +60,7 @@ export default function FavoriteContextMenuRow({ item }: { item: BaseItemDto }):
pressStyle={{ opacity: 0.5 }} pressStyle={{ opacity: 0.5 }}
> >
<Animated.View entering={FadeIn} exiting={FadeOut} key={`${item.Id}-favorite-row`}> <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'} /> <Icon small name={'heart-outline'} color={'$primary'} />
<Text bold>Add to favorites</Text> <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 { warmItemContext } from '../../../hooks/use-item-context'
import { useJellifyContext } from '../../../providers' import { useJellifyContext } from '../../../providers'
import { useStreamingQualityContext } from '../../../providers/Settings'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import { FlashList, FlashListProps, ViewToken } from '@shopify/flash-list' import { FlashList, FlashListProps, ViewToken } from '@shopify/flash-list'
import React, { useRef } from 'react' import React, { useRef } from 'react'
@@ -18,15 +18,13 @@ export default function HorizontalCardList({
}: HorizontalCardListProps): React.JSX.Element { }: HorizontalCardListProps): React.JSX.Element {
const { api, user } = useJellifyContext() const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext() const deviceProfile = useStreamingDeviceProfile()
const onViewableItemsChangedRef = useRef( const onViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: ViewToken<BaseItemDto>[] }) => { ({ viewableItems }: { viewableItems: ViewToken<BaseItemDto>[] }) => {
viewableItems viewableItems
.filter(({ isViewable }) => isViewable) .filter(({ isViewable }) => isViewable)
.forEach(({ isViewable, item }) => { .forEach(({ item }) => warmItemContext(api, user, item, deviceProfile))
if (isViewable) warmItemContext(api, user, item, streamingQuality)
})
}, },
) )

View File

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

View File

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

View File

@@ -15,14 +15,18 @@ import HomeStackParamList from '../../../screens/Home/types'
import { useNowPlaying } from '../../../providers/Player/hooks/queries' import { useNowPlaying } from '../../../providers/Player/hooks/queries'
import { useJellifyContext } from '../../../providers' import { useJellifyContext } from '../../../providers'
import { useNetworkContext } from '../../../providers/Network' 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 { export default function RecentlyPlayed(): React.JSX.Element {
const { api } = useJellifyContext() const { api } = useJellifyContext()
const { downloadedTracks, networkStatus } = useNetworkContext() const { networkStatus } = useNetworkContext()
const streamingQuality = useStreamingQualityContext() const { data: downloadedTracks } = useAllDownloadedTracks()
const deviceProfile = useStreamingDeviceProfile()
const downloadQuality = useDownloadQualityContext() const downloadQuality = useDownloadQualityContext()
@@ -73,7 +77,7 @@ export default function RecentlyPlayed(): React.JSX.Element {
loadNewQueue({ loadNewQueue({
api, api,
downloadedTracks, downloadedTracks,
streamingQuality, deviceProfile,
networkStatus, networkStatus,
downloadQuality, downloadQuality,
track: recentlyPlayedTrack, 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 { useActiveTrack } from 'react-native-track-player'
import { useJellifyContext } from '../../../providers' import { useJellifyContext } from '../../../providers'
import { useEffect } from 'react' import { useEffect } from 'react'
import usePlayerEngineStore, { PlayerEngine } from '../../../zustand/engineStore' import usePlayerEngineStore, { PlayerEngine } from '../../../stores/player-engine'
export default function Footer(): React.JSX.Element { export default function Footer(): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<PlayerParamList>>() const navigation = useNavigation<NativeStackNavigationProp<PlayerParamList>>()

View File

@@ -38,17 +38,17 @@ export default function PlayerHeader(): React.JSX.Element {
color={theme.color.val} color={theme.color.val}
name={Platform.OS === 'android' ? 'chevron-left' : 'chevron-down'} name={Platform.OS === 'android' ? 'chevron-left' : 'chevron-down'}
size={22} 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>Playing from</Text>
<Text bold numberOfLines={1} lineBreakStrategyIOS='standard'> <Text bold numberOfLines={1} lineBreakStrategyIOS='standard'>
{playingFrom} {playingFrom}
</Text> </Text>
</YStack> </YStack>
<Spacer flex={1} /> <Spacer width={22} />
</XStack> </XStack>
<YStack <YStack
@@ -57,7 +57,6 @@ export default function PlayerHeader(): React.JSX.Element {
paddingHorizontal={'$2'} paddingHorizontal={'$2'}
maxHeight={'70%'} maxHeight={'70%'}
marginVertical={'auto'} marginVertical={'auto'}
paddingVertical={Platform.OS === 'android' ? '$4' : '$2'}
> >
<Animated.View <Animated.View
entering={FadeIn} 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 { ProgressMultiplier } from '../component.config'
import { useReducedHapticsContext } from '../../../providers/Settings' import { useReducedHapticsContext } from '../../../providers/Settings'
import { useNowPlaying, useProgress } from '../../../providers/Player/hooks/queries' import { useNowPlaying, useProgress } from '../../../providers/Player/hooks/queries'
import QualityBadge from './quality-badge'
import { useDisplayAudioQualityBadge } from '../../../stores/player-settings'
// Create a simple pan gesture // Create a simple pan gesture
const scrubGesture = Gesture.Pan().runOnJS(true) const scrubGesture = Gesture.Pan().runOnJS(true)
@@ -21,7 +23,12 @@ export default function Scrubber(): React.JSX.Element {
const reducedHaptics = useReducedHapticsContext() const reducedHaptics = useReducedHapticsContext()
// Get progress from the track player with the specified update interval // 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 // Single source of truth for the current position
const [displayPosition, setDisplayPosition] = useState<number>(0) const [displayPosition, setDisplayPosition] = useState<number>(0)
@@ -32,6 +39,8 @@ export default function Scrubber(): React.JSX.Element {
const currentTrackIdRef = useRef<string | null>(null) const currentTrackIdRef = useRef<string | null>(null)
const lastPositionRef = useRef<number>(0) const lastPositionRef = useRef<number>(0)
const [displayAudioQualityBadge] = useDisplayAudioQualityBadge()
// Memoize expensive calculations // Memoize expensive calculations
const maxDuration = useMemo(() => { const maxDuration = useMemo(() => {
return Math.round(duration * ProgressMultiplier) return Math.round(duration * ProgressMultiplier)
@@ -147,16 +156,22 @@ export default function Scrubber(): React.JSX.Element {
props={sliderProps} props={sliderProps}
/> />
<XStack paddingTop={'$2'}> <XStack alignItems='center' paddingTop={'$2'}>
<YStack alignItems='flex-start' flex={2}> <YStack alignItems='flex-start' flexShrink={1}>
<RunTimeSeconds alignment='left'>{currentSeconds}</RunTimeSeconds> <RunTimeSeconds alignment='left'>{currentSeconds}</RunTimeSeconds>
</YStack> </YStack>
<YStack alignItems='center' flex={1}> <YStack alignItems='center' flexGrow={1}>
{/** Track metadata can go here */} {nowPlaying?.mediaSourceInfo && displayAudioQualityBadge && (
<QualityBadge
item={nowPlaying.item}
sourceType={nowPlaying.sourceType}
mediaSourceInfo={nowPlaying.mediaSourceInfo}
/>
)}
</YStack> </YStack>
<YStack alignItems='flex-end' flex={2}> <YStack alignItems='flex-end' flexShrink={1}>
<RunTimeSeconds alignment='right'>{totalSeconds}</RunTimeSeconds> <RunTimeSeconds alignment='right'>{totalSeconds}</RunTimeSeconds>
</YStack> </YStack>
</XStack> </XStack>

View File

@@ -81,7 +81,19 @@ export default function SongInfo(): React.JSX.Element {
<XStack justifyContent='flex-end' alignItems='center' flexShrink={1} gap={'$3'}> <XStack justifyContent='flex-end' alignItems='center' flexShrink={1} gap={'$3'}>
<Icon <Icon
name='dots-horizontal-circle-outline' 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} /> <FavoriteButton item={nowPlaying!.item} />

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useRef } from 'react' import { useRef } from 'react'
import { warmItemContext } from '../../hooks/use-item-context' import { warmItemContext } from '../../hooks/use-item-context'
import { useJellifyContext } from '../../providers' import { useJellifyContext } from '../../providers'
import { useStreamingQualityContext } from '../../providers/Settings' import useStreamingDeviceProfile from '../../stores/device-profile'
export interface PlaylistsProps { export interface PlaylistsProps {
canEdit?: boolean | undefined canEdit?: boolean | undefined
@@ -34,12 +34,12 @@ export default function Playlists({
const { api, user } = useJellifyContext() const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext() const deviceProfile = useStreamingDeviceProfile()
const onViewableItemsChangedRef = useRef( const onViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: ViewToken<BaseItemDto>[] }) => { ({ viewableItems }: { viewableItems: ViewToken<BaseItemDto>[] }) => {
viewableItems.forEach(({ isViewable, item }) => { 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 { RadioGroup, YStack } from 'tamagui'
import { RadioGroupItemWithLabel } from '../../Global/helpers/radio-group-item-with-label' import { RadioGroupItemWithLabel } from '../../Global/helpers/radio-group-item-with-label'
import { Text } from '../../Global/helpers/text' import { Text } from '../../Global/helpers/text'
import { getQualityLabel, getBandwidthEstimate } from '../utils/quality'
import { import {
StreamingQuality, StreamingQuality,
useSetStreamingQualityContext, useSetStreamingQualityContext,
useStreamingQualityContext, useStreamingQualityContext,
} from '../../../providers/Settings' } 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 { export default function PlaybackTab(): React.JSX.Element {
const deviceProfile = useStreamingDeviceProfile()
const streamingQuality = useStreamingQualityContext() const streamingQuality = useStreamingQualityContext()
const setStreamingQuality = useSetStreamingQualityContext() const setStreamingQuality = useSetStreamingQualityContext()
const [displayAudioQualityBadge, setDisplayAudioQualityBadge] = useDisplayAudioQualityBadge()
return ( return (
<SettingsListGroup <SettingsListGroup
settingsList={[ settingsList={[
{ {
title: 'Streaming Quality', title: 'Streaming Quality',
subTitle: `Current: ${getQualityLabel(streamingQuality)}${getBandwidthEstimate(streamingQuality)}`, subTitle: `${deviceProfile?.Name ?? 'Not set'}`,
iconName: 'sine-wave', iconName: 'radio-tower',
iconColor: getStreamingQualityIconColor(streamingQuality), iconColor: '$borderColor',
children: ( children: (
<YStack gap='$2' paddingVertical='$2'> <YStack gap='$2' paddingVertical='$2'>
<Text fontSize='$3' marginBottom='$2'> <Text fontSize='$3' marginBottom='$2'>
@@ -56,6 +61,20 @@ export default function PlaybackTab(): React.JSX.Element {
</YStack> </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 { RadioGroup, YStack } from 'tamagui'
import { Text } from '../../Global/helpers/text' import { Text } from '../../Global/helpers/text'
import { getQualityLabel } from '../utils/quality' import { getQualityLabel } from '../utils/quality'
import { useAllDownloadedTracks } from '../../../api/queries/download'
export default function StorageTab(): React.JSX.Element { export default function StorageTab(): React.JSX.Element {
const autoDownload = useAutoDownloadContext() const autoDownload = useAutoDownloadContext()
const setAutoDownload = useSetAutoDownloadContext() const setAutoDownload = useSetAutoDownloadContext()
const downloadQuality = useDownloadQualityContext() const downloadQuality = useDownloadQualityContext()
const setDownloadQuality = useSetDownloadQualityContext() const setDownloadQuality = useSetDownloadQualityContext()
const { downloadedTracks } = useNetworkContext() const { data: downloadedTracks } = useAllDownloadedTracks()
return ( return (
<SettingsListGroup <SettingsListGroup

View File

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

View File

@@ -8,10 +8,11 @@ import { queryClient } from '../../constants/query-client'
import { QueryKeys } from '../../enums/query-keys' import { QueryKeys } from '../../enums/query-keys'
import { FlashList, ViewToken } from '@shopify/flash-list' import { FlashList, ViewToken } from '@shopify/flash-list'
import { NativeStackNavigationProp } from '@react-navigation/native-stack' 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 { warmItemContext } from '../../hooks/use-item-context'
import { useJellifyContext } from '../../providers' import { useJellifyContext } from '../../providers'
import { useStreamingQualityContext } from '../../providers/Settings' import useStreamingDeviceProfile from '../../stores/device-profile'
import { useAllDownloadedTracks } from '../../api/queries/download'
interface TracksProps { interface TracksProps {
tracks: (string | number | BaseItemDto)[] | undefined tracks: (string | number | BaseItemDto)[] | undefined
@@ -34,8 +35,8 @@ export default function Tracks({
}: TracksProps): React.JSX.Element { }: TracksProps): React.JSX.Element {
const { api, user } = useJellifyContext() const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext() const deviceProfile = useStreamingDeviceProfile()
const { downloadedTracks } = useNetworkContext() const { data: downloadedTracks } = useAllDownloadedTracks()
// Memoize the expensive tracks processing to prevent memory leaks // Memoize the expensive tracks processing to prevent memory leaks
const tracksToDisplay = React.useMemo(() => { const tracksToDisplay = React.useMemo(() => {
@@ -82,7 +83,7 @@ export default function Tracks({
const onViewableItemsChangedRef = useRef( const onViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: ViewToken<BaseItemDto>[] }) => { ({ viewableItems }: { viewableItems: ViewToken<BaseItemDto>[] }) => {
viewableItems.forEach(({ isViewable, item }) => { 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 JellifyToastConfig from '../constants/toast.config'
import { useColorScheme } from 'react-native' import { useColorScheme } from 'react-native'
import { CarPlayProvider } from '../providers/CarPlay' 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} * The main component for the Jellify app. Children are wrapped in the {@link JellifyProvider}
* @returns The {@link Jellify} component * @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 { JellifyUser } from '../types/JellifyUser'
import { Api } from '@jellyfin/sdk' import { Api } from '@jellyfin/sdk'
import { queryClient } from '../constants/query-client' import { queryClient } from '../constants/query-client'
import { QueryKeys } from '../enums/query-keys' import { QueryKeys } from '../enums/query-keys'
import { getQualityParams } from '../utils/mappings' import { fetchMediaInfo } from '../api/queries/media/utils'
import { fetchMediaInfo } from '../api/queries/media'
import { StreamingQuality, useStreamingQualityContext } from '../providers/Settings'
import { fetchAlbumDiscs, fetchItem } from '../api/queries/item' import { fetchAlbumDiscs, fetchItem } from '../api/queries/item'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api' import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { fetchUserData } from '../api/queries/favorites' import { fetchUserData } from '../api/queries/favorites'
import { useJellifyContext } from '../providers' import { useJellifyContext } from '../providers'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../stores/device-profile'
export default function useItemContext(item: BaseItemDto): void { export default function useItemContext(item: BaseItemDto): void {
const { api, user } = useJellifyContext() const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext() const streamingDeviceProfile = useStreamingDeviceProfile()
const downloadingDeviceProfile = useDownloadingDeviceProfile()
const prefetchedContext = useRef<Set<string>>(new Set()) 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 // Mark this item's context as warmed, preventing reruns
prefetchedContext.current.add(effectSig) prefetchedContext.current.add(effectSig)
warmItemContext(api, user, item, streamingQuality) warmItemContext(api, user, item, streamingDeviceProfile, downloadingDeviceProfile)
}, [api, user, streamingQuality]) }, [api, user, streamingDeviceProfile])
} }
export function warmItemContext( export function warmItemContext(
api: Api | undefined, api: Api | undefined,
user: JellifyUser | undefined, user: JellifyUser | undefined,
item: BaseItemDto, item: BaseItemDto,
streamingQuality: StreamingQuality, streamingDeviceProfile: DeviceProfile | undefined,
downloadingDeviceProfile?: DeviceProfile | undefined,
): void { ): void {
const { Id, Type, AlbumId, UserData } = item const { Id, Type, AlbumId, UserData } = item
@@ -45,7 +47,8 @@ export function warmItemContext(
console.debug(`Warming context query cache for item ${Id}`) 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) if (Type === BaseItemKind.MusicArtist)
queryClient.setQueryData([QueryKeys.ArtistById, Id], item) queryClient.setQueryData([QueryKeys.ArtistById, Id], item)
@@ -117,16 +120,29 @@ function warmTrackContext(
api: Api | undefined, api: Api | undefined,
user: JellifyUser | undefined, user: JellifyUser | undefined,
track: BaseItemDto, track: BaseItemDto,
streamingQuality: StreamingQuality, streamingDeviceProfile: DeviceProfile | undefined,
downloadingDeviceProfile: DeviceProfile | undefined,
): void { ): void {
const { Id, AlbumId, ArtistItems } = track 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({ queryClient.ensureQueryData({
queryKey: mediaSourcesQueryKey, queryKey: streamingMediaSourceQueryKey,
queryFn: () => fetchMediaInfo(api, user, getQualityParams(streamingQuality), Id!), 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] const albumQueryKey = [QueryKeys.Album, AlbumId]

View File

@@ -5,7 +5,9 @@ import { CarPlay } from 'react-native-carplay'
import { useJellifyContext } from '../index' import { useJellifyContext } from '../index'
import { useLoadNewQueue } from '../Player/hooks/mutations' import { useLoadNewQueue } from '../Player/hooks/mutations'
import { useNetworkContext } from '../Network' 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 { interface CarPlayContext {
carplayConnected: boolean carplayConnected: boolean
@@ -15,9 +17,11 @@ const CarPlayContextInitializer = () => {
const { api, library } = useJellifyContext() const { api, library } = useJellifyContext()
const [carplayConnected, setCarPlayConnected] = useState(CarPlay ? CarPlay.connected : false) 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 downloadQuality = useDownloadQualityContext()
const { mutate: loadNewQueue } = useLoadNewQueue() const { mutate: loadNewQueue } = useLoadNewQueue()
@@ -34,7 +38,7 @@ const CarPlayContextInitializer = () => {
api, api,
downloadedTracks, downloadedTracks,
networkStatus, networkStatus,
streamingQuality, deviceProfile,
downloadQuality, downloadQuality,
), ),
) )

View File

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

View File

@@ -1,40 +1,24 @@
import React, { createContext, ReactNode, useContext, useEffect, useState, useMemo } from 'react' import React, { createContext, ReactNode, useContext, useEffect, useState, useMemo } from 'react'
import { JellifyDownload, JellifyDownloadProgress } from '../../types/JellifyDownload' import { JellifyDownloadProgress } from '../../types/JellifyDownload'
import { useMutation, UseMutationResult, useQuery } from '@tanstack/react-query' import { UseMutateFunction, useMutation } from '@tanstack/react-query'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { saveAudio } from '../../api/mutations/download/offlineModeUtils'
import { mapDtoToTrack } from '../../utils/mappings'
import { deleteAudio, getAudioCache, saveAudio } from '../../components/Network/offlineModeUtils'
import { QueryKeys } from '../../enums/query-keys'
import { networkStatusTypes } from '../../components/Network/internetConnectionWatcher' 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 JellifyTrack from '../../types/JellifyTrack'
import { useAllDownloadedTracks } from '../../api/queries/download'
interface NetworkContext { interface NetworkContext {
useDownload: UseMutationResult<boolean | void, Error, BaseItemDto, unknown> useDownloadMultiple: UseMutateFunction<boolean, Error, JellifyTrack[], unknown>
useRemoveDownload: UseMutationResult<void, Error, BaseItemDto, unknown>
storageUsage: JellifyStorage | undefined
downloadedTracks: JellifyDownload[] | undefined
activeDownloads: JellifyDownloadProgress | undefined activeDownloads: JellifyDownloadProgress | undefined
networkStatus: networkStatusTypes | null networkStatus: networkStatusTypes | null
setNetworkStatus: (status: networkStatusTypes | null) => void setNetworkStatus: (status: networkStatusTypes | null) => void
useDownloadMultiple: UseMutationResult<boolean, Error, JellifyTrack[], unknown>
pendingDownloads: JellifyTrack[] pendingDownloads: JellifyTrack[]
downloadingDownloads: JellifyTrack[] downloadingDownloads: JellifyTrack[]
completedDownloads: JellifyTrack[] completedDownloads: JellifyTrack[]
failedDownloads: JellifyTrack[] failedDownloads: JellifyTrack[]
clearDownloads: () => void
} }
const MAX_CONCURRENT_DOWNLOADS = 1 const MAX_CONCURRENT_DOWNLOADS = 1
const NetworkContextInitializer = () => { const NetworkContextInitializer = () => {
const { api, sessionId } = useJellifyContext()
const downloadQuality = useDownloadQualityContext()
const streamingQuality = useStreamingQualityContext()
const [downloadProgress, setDownloadProgress] = useState<JellifyDownloadProgress>({}) const [downloadProgress, setDownloadProgress] = useState<JellifyDownloadProgress>({})
const [networkStatus, setNetworkStatus] = useState<networkStatusTypes | null>(null) const [networkStatus, setNetworkStatus] = useState<networkStatusTypes | null>(null)
@@ -44,21 +28,7 @@ const NetworkContextInitializer = () => {
const [completed, setCompleted] = useState<JellifyTrack[]>([]) const [completed, setCompleted] = useState<JellifyTrack[]>([])
const [failed, setFailed] = useState<JellifyTrack[]>([]) const [failed, setFailed] = useState<JellifyTrack[]>([])
const fetchStorageInUse: () => Promise<JellifyStorage> = async () => { const { data: downloadedTracks, refetch: refetchDownloadedTracks } = useAllDownloadedTracks()
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
})
useEffect(() => { useEffect(() => {
if (pending.length > 0 && downloading.length < MAX_CONCURRENT_DOWNLOADS) { if (pending.length > 0 && downloading.length < MAX_CONCURRENT_DOWNLOADS) {
@@ -89,59 +59,12 @@ const NetworkContextInitializer = () => {
} }
}, [pending, downloading]) }, [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[]) => { const addToQueue = async (items: JellifyTrack[]) => {
setPending((prev) => [...prev, ...items]) setPending((prev) => [...prev, ...items])
return true return true
} }
const useDownloadMultiple = useMutation({ const { mutate: useDownloadMultiple } = useMutation({
mutationFn: (tracks: JellifyTrack[]) => { mutationFn: (tracks: JellifyTrack[]) => {
return addToQueue(tracks) return addToQueue(tracks)
}, },
@@ -151,89 +74,27 @@ const NetworkContextInitializer = () => {
}) })
return { return {
useDownload,
useRemoveDownload,
activeDownloads: downloadProgress, activeDownloads: downloadProgress,
downloadedTracks, downloadedTracks,
networkStatus, networkStatus,
setNetworkStatus, setNetworkStatus,
storageUsage,
useDownloadMultiple, useDownloadMultiple,
pendingDownloads: pending, pendingDownloads: pending,
downloadingDownloads: downloading, downloadingDownloads: downloading,
completedDownloads: completed, completedDownloads: completed,
failedDownloads: failed, failedDownloads: failed,
clearDownloads,
} }
} }
const NetworkContext = createContext<NetworkContext>({ 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: {}, activeDownloads: {},
networkStatus: networkStatusTypes.ONLINE, networkStatus: networkStatusTypes.ONLINE,
setNetworkStatus: () => {}, setNetworkStatus: () => {},
storageUsage: undefined, useDownloadMultiple: () => {},
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,
},
pendingDownloads: [], pendingDownloads: [],
downloadingDownloads: [], downloadingDownloads: [],
completedDownloads: [], completedDownloads: [],
failedDownloads: [], failedDownloads: [],
clearDownloads: () => {},
}) })
export const NetworkContextProvider: ({ export const NetworkContextProvider: ({
@@ -249,7 +110,6 @@ export const NetworkContextProvider: ({
[ [
context.downloadedTracks?.length, context.downloadedTracks?.length,
context.networkStatus, context.networkStatus,
context.storageUsage,
context.pendingDownloads.length, context.pendingDownloads.length,
context.downloadingDownloads.length, context.downloadingDownloads.length,
context.completedDownloads.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 { QueuingType } from '../../../enums/queuing-type'
import { shuffleJellifyTracks } from '../utils/shuffle' import { shuffleJellifyTracks } from '../utils/shuffle'
import TrackPlayer from 'react-native-track-player' import TrackPlayer from 'react-native-track-player'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import Toast from 'react-native-toast-message' import Toast from 'react-native-toast-message'
import { findPlayQueueIndexStart } from '../utils' import { findPlayQueueIndexStart } from '../utils'
import JellifyTrack from '@/src/types/JellifyTrack' import JellifyTrack from '@/src/types/JellifyTrack'
@@ -13,13 +12,11 @@ import { setPlayQueue, setQueueRef, setShuffled, setUnshuffledQueue } from '.'
export async function loadQueue({ export async function loadQueue({
index, index,
track,
tracklist, tracklist,
queue: queueRef, queue: queueRef,
shuffled = false, shuffled = false,
api, api,
downloadQuality, deviceProfile,
streamingQuality,
networkStatus = networkStatusTypes.ONLINE, networkStatus = networkStatusTypes.ONLINE,
downloadedTracks, downloadedTracks,
}: QueueMutation) { }: QueueMutation) {
@@ -43,9 +40,8 @@ export async function loadQueue({
api!, api!,
item, item,
downloadedTracks ?? [], downloadedTracks ?? [],
deviceProfile!,
QueuingType.FromSelection, QueuingType.FromSelection,
downloadQuality,
streamingQuality,
), ),
) )
@@ -111,21 +107,13 @@ export async function loadQueue({
export const playNextInQueue = async ({ export const playNextInQueue = async ({
api, api,
downloadedTracks, downloadedTracks,
downloadQuality, deviceProfile,
streamingQuality,
tracks, tracks,
}: AddToQueueMutation) => { }: AddToQueueMutation) => {
console.debug(`Playing item next in queue`) console.debug(`Playing item next in queue`)
const tracksToPlayNext = tracks.map((item) => const tracksToPlayNext = tracks.map((item) =>
mapDtoToTrack( mapDtoToTrack(api!, item, downloadedTracks ?? [], deviceProfile!, QueuingType.PlayingNext),
api!,
item,
downloadedTracks ?? [],
QueuingType.PlayingNext,
downloadQuality,
streamingQuality,
),
) )
const currentIndex = await TrackPlayer.getActiveTrackIndex() const currentIndex = await TrackPlayer.getActiveTrackIndex()
@@ -149,8 +137,7 @@ export const playNextInQueue = async ({
export const playInQueue = async ({ export const playInQueue = async ({
api, api,
downloadQuality, deviceProfile,
streamingQuality,
downloadedTracks, downloadedTracks,
tracks, tracks,
}: AddToQueueMutation) => { }: AddToQueueMutation) => {
@@ -169,9 +156,8 @@ export const playInQueue = async ({
api!, api!,
item, item,
downloadedTracks ?? [], downloadedTracks ?? [],
deviceProfile!,
QueuingType.DirectlyQueued, QueuingType.DirectlyQueued,
downloadQuality,
streamingQuality,
), ),
) )

View File

@@ -19,7 +19,7 @@ import { handleDeshuffle, handleShuffle } from '../functions/shuffle'
import JellifyTrack from '@/src/types/JellifyTrack' import JellifyTrack from '@/src/types/JellifyTrack'
import calculateTrackVolume from '../utils/normalization' import calculateTrackVolume from '../utils/normalization'
import { useNowPlaying, usePlaybackState } from './queries' 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 { useRemoteMediaClient } from 'react-native-google-cast'
import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { RootStackParamList } from '../../../screens/types' import { RootStackParamList } from '../../../screens/types'

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { QueuingType } from '../../enums/queuing-type' 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 { Queue } from '../../player/types/queue-item'
import { Api } from '@jellyfin/sdk' import { Api } from '@jellyfin/sdk'
import { networkStatusTypes } from '../../components/Network/internetConnectionWatcher' import { networkStatusTypes } from '../../components/Network/internetConnectionWatcher'
@@ -25,7 +25,7 @@ export interface QueueMutation {
downloadQuality: DownloadQuality downloadQuality: DownloadQuality
streamingQuality: StreamingQuality deviceProfile: DeviceProfile | undefined
/** /**
* The track that will be played first in the queue. * The track that will be played first in the queue.
@@ -80,7 +80,7 @@ export interface AddToQueueMutation {
downloadQuality: DownloadQuality downloadQuality: DownloadQuality
streamingQuality: StreamingQuality deviceProfile: DeviceProfile | undefined
/** /**
* The tracks to add to the queue. * 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. * @param track - The track to calculate the normalization gain for.
* @returns The normalization gain for the track. * @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 { export default function calculateTrackVolume(track: JellifyTrack): number {
const { NormalizationGain } = track.item const { NormalizationGain } = track.item

View File

@@ -3,6 +3,11 @@ import { storage } from '../../constants/storage'
import { MMKVStorageKeys } from '../../enums/mmkv-storage-keys' import { MMKVStorageKeys } from '../../enums/mmkv-storage-keys'
import { useEffect, useState, useMemo } from 'react' import { useEffect, useState, useMemo } from 'react'
import { createContext, useContextSelector } from 'use-context-selector' 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 DownloadQuality = 'original' | 'high' | 'medium' | 'low'
export type StreamingQuality = 'original' | 'high' | 'medium' | 'low' export type StreamingQuality = 'original' | 'high' | 'medium' | 'low'
@@ -61,11 +66,11 @@ const SettingsContextInitializer = () => {
const [devTools, setDevTools] = useState(false) const [devTools, setDevTools] = useState(false)
const [downloadQuality, setDownloadQuality] = useState<DownloadQuality>( const [downloadQuality, setDownloadQuality] = useState<DownloadQuality>(
downloadQualityInit ?? 'medium', downloadQualityInit ?? 'original',
) )
const [streamingQuality, setStreamingQuality] = useState<StreamingQuality>( const [streamingQuality, setStreamingQuality] = useState<StreamingQuality>(
streamingQualityInit ?? 'high', streamingQualityInit ?? 'original',
) )
const [reducedHaptics, setReducedHaptics] = useState( const [reducedHaptics, setReducedHaptics] = useState(
@@ -74,6 +79,13 @@ const SettingsContextInitializer = () => {
const [theme, setTheme] = useState<Theme>(themeInit ?? 'system') const [theme, setTheme] = useState<Theme>(themeInit ?? 'system')
const setStreamingDeviceProfile = useStreamingDeviceProfileStore(
(state) => state.setDeviceProfile,
)
const setDownloadingDeviceProfile = useDownloadingDeviceProfileStore(
(state) => state.setDeviceProfile,
)
useEffect(() => { useEffect(() => {
storage.set(MMKVStorageKeys.SendMetrics, sendMetrics) storage.set(MMKVStorageKeys.SendMetrics, sendMetrics)
}, [sendMetrics]) }, [sendMetrics])
@@ -84,10 +96,14 @@ const SettingsContextInitializer = () => {
useEffect(() => { useEffect(() => {
storage.set(MMKVStorageKeys.DownloadQuality, downloadQuality) storage.set(MMKVStorageKeys.DownloadQuality, downloadQuality)
setDownloadingDeviceProfile(getDeviceProfile(downloadQuality, 'download'))
}, [downloadQuality]) }, [downloadQuality])
useEffect(() => { useEffect(() => {
storage.set(MMKVStorageKeys.StreamingQuality, streamingQuality) storage.set(MMKVStorageKeys.StreamingQuality, streamingQuality)
setStreamingDeviceProfile(getDeviceProfile(streamingQuality, 'stream'))
}, [streamingQuality]) }, [streamingQuality])
useEffect(() => { 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 { MMKVStorageKeys } from '../enums/mmkv-storage-keys'
import { Api } from '@jellyfin/sdk/lib/api' import { Api } from '@jellyfin/sdk/lib/api'
import { JellyfinInfo } from '../api/info' import { JellyfinInfo } from '../api/info'
import uuid from 'react-native-uuid'
import { queryClient } from '../constants/query-client' import { queryClient } from '../constants/query-client'
import { MediaInfoApi } from '@jellyfin/sdk/lib/generated-client/api'
/** /**
* The context for the Jellify provider. * The context for the Jellify provider.
@@ -48,11 +46,6 @@ interface JellifyContext {
*/ */
library: JellifyLibrary | undefined library: JellifyLibrary | undefined
/**
* The ID for the current session.
*/
sessionId: string
/** /**
* The function to set the context {@link JellifyServer}. * The function to set the context {@link JellifyServer}.
*/ */
@@ -81,14 +74,6 @@ const JellifyContextInitializer = () => {
const libraryJson = storage.getString(MMKVStorageKeys.Library) const libraryJson = storage.getString(MMKVStorageKeys.Library)
const apiJson = storage.getString(MMKVStorageKeys.Api) 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 [api, setApi] = useState<Api | undefined>(apiJson ? JSON.parse(apiJson) : undefined)
const [server, setServer] = useState<JellifyServer | undefined>( const [server, setServer] = useState<JellifyServer | undefined>(
serverJson ? JSON.parse(serverJson) : undefined, serverJson ? JSON.parse(serverJson) : undefined,
@@ -147,7 +132,6 @@ const JellifyContextInitializer = () => {
server, server,
user, user,
library, library,
sessionId,
setServer, setServer,
setUser, setUser,
setLibrary, setLibrary,
@@ -161,7 +145,6 @@ const JellifyContext = createContext<JellifyContext>({
server: undefined, server: undefined,
user: undefined, user: undefined,
library: undefined, library: undefined,
sessionId: '',
setServer: () => {}, setServer: () => {},
setUser: () => {}, setUser: () => {},
setLibrary: () => {}, setLibrary: () => {},
@@ -190,7 +173,6 @@ export const JellifyProvider: ({ children }: { children: ReactNode }) => React.J
context.server?.url, context.server?.url,
context.user?.id, context.user?.id,
context.library?.musicLibraryId, 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 { export default function ItemContextScreen({ route, navigation }: ContextProps): React.JSX.Element {
return ( return (
<ItemContext <ItemContext
navigation={navigation}
item={route.params.item} item={route.params.item}
stackNavigation={route.params.navigation} stackNavigation={route.params.navigation}
navigation={navigation}
navigationCallback={route.params.navigationCallback} 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 { createNativeStackNavigator } from '@react-navigation/native-stack'
import MultipleArtistsSheet from '../Context/multiple-artists' import MultipleArtistsSheet from '../Context/multiple-artists'
import { PlayerParamList } from './types' import { PlayerParamList } from './types'
import AudioSpecsSheet from '../Stats'
export const PlayerStack = createNativeStackNavigator<PlayerParamList>() export const PlayerStack = createNativeStackNavigator<PlayerParamList>()

View File

@@ -7,12 +7,13 @@ import { useJellifyContext } from '../../providers'
import { useNetworkContext } from '../../providers/Network' import { useNetworkContext } from '../../providers/Network'
import { useResetQueue } from '../../providers/Player/hooks/mutations' import { useResetQueue } from '../../providers/Player/hooks/mutations'
import navigationRef from '../../../navigation' import navigationRef from '../../../navigation'
import { useClearAllDownloads } from '../../api/mutations/download'
export default function SignOutModal({ navigation }: SignOutModalProps): React.JSX.Element { export default function SignOutModal({ navigation }: SignOutModalProps): React.JSX.Element {
const { server } = useJellifyContext() const { server } = useJellifyContext()
const { mutate: resetQueue } = useResetQueue() const { mutate: resetQueue } = useResetQueue()
const { clearDownloads } = useNetworkContext() const clearDownloads = useClearAllDownloads()
return ( return (
<YStack margin={'$6'}> <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 Tabs from './Tabs'
import { RootStackParamList } from './types' import { RootStackParamList } from './types'
import { getToken, useTheme, YStack } from 'tamagui' 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 { TextTickerConfig } from '../components/Player/component.config'
import { Text } from '../components/Global/helpers/text' import { Text } from '../components/Global/helpers/text'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import AudioSpecsSheet from './Stats'
const RootStack = createNativeStackNavigator<RootStackParamList>() const RootStack = createNativeStackNavigator<RootStackParamList>()
@@ -77,6 +78,17 @@ export default function Root(): React.JSX.Element {
sheetGrabberVisible: true, sheetGrabberVisible: true,
}} }}
/> />
<RootStack.Screen
name='AudioSpecs'
component={AudioSpecsSheet}
options={({ route }) => ({
header: () => ContextSheetHeader(route.params.item),
presentation: 'formSheet',
sheetAllowedDetents: 'fitToContents',
sheetGrabberVisible: true,
})}
/>
</RootStack.Navigator> </RootStack.Navigator>
) )
} }

View File

@@ -1,5 +1,5 @@
import { QueryKeys } from '../enums/query-keys' 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 { NativeStackNavigationProp, NativeStackScreenProps } from '@react-navigation/native-stack'
import { Queue } from '../player/types/queue-item' import { Queue } from '../player/types/queue-item'
import { MaterialTopTabBarProps } from '@react-navigation/material-top-tabs' import { MaterialTopTabBarProps } from '@react-navigation/material-top-tabs'
@@ -59,6 +59,8 @@ export type RootStackParamList = {
Context: { Context: {
item: BaseItemDto item: BaseItemDto
streamingMediaSourceInfo?: MediaSourceInfo
downloadedMediaSourceInfo?: MediaSourceInfo
navigation?: Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'> navigation?: Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
navigationCallback?: (screen: 'Album' | 'Artist', item: BaseItemDto) => void navigationCallback?: (screen: 'Album' | 'Artist', item: BaseItemDto) => void
} }
@@ -66,6 +68,12 @@ export type RootStackParamList = {
AddToPlaylist: { AddToPlaylist: {
track: BaseItemDto track: BaseItemDto
} }
AudioSpecs: {
item: BaseItemDto
streamingMediaSourceInfo?: MediaSourceInfo
downloadedMediaSourceInfo?: MediaSourceInfo
}
} }
export type LoginProps = NativeStackNavigationProp<RootStackParamList, 'Login'> export type LoginProps = NativeStackNavigationProp<RootStackParamList, 'Login'>
@@ -73,6 +81,7 @@ export type TabProps = NativeStackScreenProps<RootStackParamList, 'Tabs'>
export type PlayerProps = NativeStackScreenProps<RootStackParamList, 'PlayerRoot'> export type PlayerProps = NativeStackScreenProps<RootStackParamList, 'PlayerRoot'>
export type ContextProps = NativeStackScreenProps<RootStackParamList, 'Context'> export type ContextProps = NativeStackScreenProps<RootStackParamList, 'Context'>
export type AddToPlaylistProps = NativeStackScreenProps<RootStackParamList, 'AddToPlaylist'> export type AddToPlaylistProps = NativeStackScreenProps<RootStackParamList, 'AddToPlaylist'>
export type AudioSpecsProps = NativeStackScreenProps<RootStackParamList, 'AudioSpecs'>
export type ArtistsProps = { export type ArtistsProps = {
artistsInfiniteQuery: UseInfiniteQueryResult< 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 { 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 { 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 title?: string | undefined
album?: string | undefined album?: string | undefined
artist?: string | undefined artist?: string | undefined
duration?: number | undefined duration: number
artwork?: string | undefined artwork?: string | undefined
description?: string | undefined description?: string | undefined
genre?: string | undefined genre?: string | undefined
@@ -23,7 +16,10 @@ interface JellifyTrack extends Track {
rating?: RatingType | undefined rating?: RatingType | undefined
isLiveStream?: boolean | undefined isLiveStream?: boolean | undefined
sourceType: SourceType
item: BaseItemDto item: BaseItemDto
sessionId: string | null | undefined
mediaSourceInfo?: MediaSourceInfo
/** /**
* Represents the type of queuing for this song, be it that it was * Represents the type of queuing for this song, be it that it was

View File

@@ -1,13 +1,15 @@
import { import {
BaseItemDto, BaseItemDto,
DeviceProfile,
ImageType, ImageType,
MediaSourceInfo,
PlaybackInfoResponse, PlaybackInfoResponse,
} from '@jellyfin/sdk/lib/generated-client/models' } from '@jellyfin/sdk/lib/generated-client/models'
import JellifyTrack from '../types/JellifyTrack' import JellifyTrack from '../types/JellifyTrack'
import TrackPlayer, { Track, TrackType } from 'react-native-track-player' import TrackPlayer, { Track, TrackType } from 'react-native-track-player'
import { QueuingType } from '../enums/queuing-type' import { QueuingType } from '../enums/queuing-type'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api' 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 { JellifyDownload } from '../types/JellifyDownload'
import { Api } from '@jellyfin/sdk/lib/api' import { Api } from '@jellyfin/sdk/lib/api'
import RNFS from 'react-native-fs' import RNFS from 'react-native-fs'
@@ -17,21 +19,7 @@ import { queryClient } from '../constants/query-client'
import { QueryKeys } from '../enums/query-keys' import { QueryKeys } from '../enums/query-keys'
import { isUndefined } from 'lodash' import { isUndefined } from 'lodash'
import uuid from 'react-native-uuid' import uuid from 'react-native-uuid'
import { convertRunTimeTicksToSeconds } from './runtimeticks'
/**
* 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
/** /**
* Gets quality-specific parameters for transcoding * 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 * 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 * from a Jellyfin server {@link BaseItemDto}. Applies a queuing type to the track
@@ -84,41 +77,99 @@ export function mapDtoToTrack(
api: Api, api: Api,
item: BaseItemDto, item: BaseItemDto,
downloadedTracks: JellifyDownload[], downloadedTracks: JellifyDownload[],
deviceProfile: DeviceProfile,
queuingType?: QueuingType, queuingType?: QueuingType,
downloadQuality: DownloadQuality = 'medium',
streamingQuality?: StreamingQuality | undefined,
): JellifyTrack { ): JellifyTrack {
const downloads = downloadedTracks.filter((download) => download.item.Id === item.Id) const downloads = downloadedTracks.filter((download) => download.item.Id === item.Id)
let url: string const mediaInfo = queryClient.getQueryData([
let image: string | undefined QueryKeys.MediaSources,
deviceProfile?.Name,
item.Id,
]) as PlaybackInfoResponse | undefined
if (downloads.length > 0 && downloads[0].path) { let trackMediaInfo: TrackMediaInfo
url = `file://${RNFS.DocumentDirectoryPath}/${downloads[0].path.split('/').pop()}`
image = `file://${RNFS.DocumentDirectoryPath}/${downloads[0].artwork?.split('/').pop()}` // Prioritize downloads over streaming to save bandwidth
} else { if (downloads.length > 0 && downloads[0].path)
url = buildAudioApiUrl(api, item, streamingQuality, downloadQuality) trackMediaInfo = buildDownloadedTrack(downloads[0])
image = item.AlbumId /**
? getImageApi(api).getItemImageUrlById(item.AlbumId, ImageType.Primary) * Prioritize transcoding over direct play
: undefined * 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 { return {
url,
type,
headers: { headers: {
'X-Emby-Token': api.accessToken, 'X-Emby-Token': api.accessToken,
}, },
...trackMediaInfo,
title: item.Name, title: item.Name,
album: item.Album, album: item.Album,
artist: item.Artists?.join(' • '), artist: item.Artists?.join(' • '),
duration: item.RunTimeTicks, artwork: trackMediaInfo.image,
artwork: image,
item,
QueuingType: queuingType ?? QueuingType.DirectlyQueued, QueuingType: queuingType ?? QueuingType.DirectlyQueued,
} as JellifyTrack } 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 * Builds a URL targeting the {@link AudioApi}, using data contained in the
* {@link PlaybackInfoResponse} * {@link PlaybackInfoResponse}
@@ -131,19 +182,14 @@ export function mapDtoToTrack(
function buildAudioApiUrl( function buildAudioApiUrl(
api: Api, api: Api,
item: BaseItemDto, item: BaseItemDto,
streamingQuality: StreamingQuality | undefined, deviceProfile: DeviceProfile | undefined,
downloadQuality: DownloadQuality,
): string { ): string {
// Use streamingQuality for URL generation, fallback to downloadQuality for backward compatibility
const qualityForStreaming = streamingQuality || downloadQuality
const qualityParams = getQualityParams(qualityForStreaming)
console.debug( 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([ const mediaInfo = queryClient.getQueryData([
QueryKeys.MediaSources, QueryKeys.MediaSources,
streamingQuality, deviceProfile?.Name,
item.Id, item.Id,
]) as PlaybackInfoResponse | undefined ]) as PlaybackInfoResponse | undefined
@@ -157,7 +203,6 @@ function buildAudioApiUrl(
playSessionId: mediaInfo?.PlaySessionId ?? uuid.v4(), playSessionId: mediaInfo?.PlaySessionId ?? uuid.v4(),
startTimeTicks: '0', startTimeTicks: '0',
static: 'true', static: 'true',
...qualityParams,
} }
if (mediaSource.Container! !== 'mpeg') container = mediaSource.Container! if (mediaSource.Container! !== 'mpeg') container = mediaSource.Container!
@@ -166,45 +211,12 @@ function buildAudioApiUrl(
playSessionId: uuid.v4(), playSessionId: uuid.v4(),
StartTimeTicks: '0', StartTimeTicks: '0',
static: 'true', static: 'true',
...qualityParams,
} }
if (item.Container! !== 'mpeg') container = item.Container! if (item.Container! !== 'mpeg') container = item.Container!
} }
return `${api.basePath}/Audio/${item.Id!}/stream.${container}?${new URLSearchParams(urlParams)}` return `${api.basePath}/Audio/${item.Id!}/stream?${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)}`
} }
function mediaSourceExists(mediaInfo: PlaybackInfoResponse | undefined): boolean { 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],
)
}