mirror of
https://github.com/anultravioletaurora/Jellify.git
synced 2025-12-16 18:55:44 -06:00
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:
40
App.tsx
40
App.tsx
@@ -77,9 +77,21 @@ export default function App(): React.JSX.Element {
|
||||
<SafeAreaProvider>
|
||||
<OTAUpdateScreen />
|
||||
<ErrorBoundary reloader={reloader} onRetry={handleRetry}>
|
||||
<SettingsProvider>
|
||||
<Container playerIsReady={playerIsReady} />
|
||||
</SettingsProvider>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister: clientPersister,
|
||||
|
||||
/**
|
||||
* Maximum query data age of one day
|
||||
*/
|
||||
maxAge: Infinity,
|
||||
}}
|
||||
>
|
||||
<SettingsProvider>
|
||||
<Container playerIsReady={playerIsReady} />
|
||||
</SettingsProvider>
|
||||
</PersistQueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</SafeAreaProvider>
|
||||
</React.StrictMode>
|
||||
@@ -104,23 +116,11 @@ function Container({ playerIsReady }: { playerIsReady: boolean }): React.JSX.Ele
|
||||
: JellifyLightTheme
|
||||
}
|
||||
>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister: clientPersister,
|
||||
|
||||
/**
|
||||
* Maximum query data age of one day
|
||||
*/
|
||||
maxAge: Infinity,
|
||||
}}
|
||||
>
|
||||
<GestureHandlerRootView>
|
||||
<TamaguiProvider config={jellifyConfig}>
|
||||
{playerIsReady && <Jellify />}
|
||||
</TamaguiProvider>
|
||||
</GestureHandlerRootView>
|
||||
</PersistQueryClientProvider>
|
||||
<GestureHandlerRootView>
|
||||
<TamaguiProvider config={jellifyConfig}>
|
||||
{playerIsReady && <Jellify />}
|
||||
</TamaguiProvider>
|
||||
</GestureHandlerRootView>
|
||||
</NavigationContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -225,7 +225,8 @@ Install via [Altstore](https://altstore.io) or your favorite sideloading utility
|
||||
[React Native MMKV](https://github.com/mrousavy/react-native-mmkv)\
|
||||
[React Native OTA Hot Update](https://github.com/vantuan88291/react-native-ota-hot-update)\
|
||||
[React Native Track Player](https://github.com/doublesymmetry/react-native-track-player)\
|
||||
[React Native URL Polyfill](https://github.com/charpeni/react-native-url-polyfill)
|
||||
[React Native URL Polyfill](https://github.com/charpeni/react-native-url-polyfill)\
|
||||
[Zustand](https://github.com/pmndrs/zustand)
|
||||
|
||||
### 👩💻 Opt-In Monitoring
|
||||
|
||||
@@ -253,9 +254,10 @@ This allows me to prioritize specific features, acquire additional hardware for
|
||||
|
||||
- The [Jellyfin Team](https://jellyfin.org/) for making this possible with their software, SDKs, and unequivocal helpfulness.
|
||||
- Extra thanks to [Niels](https://github.com/nielsvanvelzen) and [Bill](https://github.com/thornbill)
|
||||
- They taught me the ways of the AudioAPI and how to do audio transcoding with Jellyfin
|
||||
- [James](https://github.com/jmshrv), [Chaphasilor](https://github.com/Chaphasilor) and all other contributors of [Finamp](https://github.com/jmshrv/finamp) - another music app for Jellyfin
|
||||
- James’ [API Blog Post](https://jmshrv.com/posts/jellyfin-api/) proved to be exceptionally valuable during development
|
||||
- Chaphasilor taught me everything they know about audio normalization and LUFS
|
||||
- Chaphasilor taught me everything they know about audio normalization and LUFS, and their math was referenced in _Jellify_'s audio normalization algorithm
|
||||
- Marc and the rest of the [Margelo Community](https://discord.com/invite/6CSHz2qAvA) for their amazing modules and support
|
||||
- [Nicolas Charpentier](https://github.com/charpeni) for his [React Native URL Polyfill](https://github.com/charpeni/react-native-url-polyfill) module and for his assistance with getting Jest working
|
||||
- The team behind [Podverse](https://github.com/podverse/podverse-rn) for their incredible open source project, of which was used as a reference extensively during development
|
||||
|
||||
@@ -4,7 +4,6 @@ import { render } from '@testing-library/react-native'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { PlayerProvider } from '../../src/providers/Player'
|
||||
import { View } from 'react-native'
|
||||
import { JellifyProvider } from '../../src/providers'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import JellifyTrack from '../../src/types/JellifyTrack'
|
||||
import calculateTrackVolume from '../../src/providers/Player/utils/normalization'
|
||||
|
||||
describe('Normalization Module', () => {
|
||||
it('should calculate the volume for a track with a normalization gain of 6', () => {
|
||||
const track = {
|
||||
const track: JellifyTrack = {
|
||||
url: 'https://example.com/track.mp3',
|
||||
item: {
|
||||
NormalizationGain: 6, // 6 Gain means the track is quieter than the target volume
|
||||
},
|
||||
duration: 420,
|
||||
sessionId: 'TEST_SESSION_ID',
|
||||
sourceType: 'stream',
|
||||
}
|
||||
|
||||
const volume = calculateTrackVolume(track)
|
||||
@@ -15,11 +19,14 @@ describe('Normalization Module', () => {
|
||||
})
|
||||
|
||||
it('should calculate the volume for a track with a normalization gain of 0', () => {
|
||||
const track = {
|
||||
const track: JellifyTrack = {
|
||||
url: 'https://example.com/track.mp3',
|
||||
item: {
|
||||
NormalizationGain: 0, // 0 Gain means the track is at the target volume
|
||||
},
|
||||
duration: 420,
|
||||
sessionId: 'TEST_SESSION_ID',
|
||||
sourceType: 'stream',
|
||||
}
|
||||
|
||||
const volume = calculateTrackVolume(track)
|
||||
@@ -28,11 +35,14 @@ describe('Normalization Module', () => {
|
||||
})
|
||||
|
||||
it('should calculate the volume for a track with a normalization gain of -10', () => {
|
||||
const track = {
|
||||
const track: JellifyTrack = {
|
||||
url: 'https://example.com/track.mp3',
|
||||
item: {
|
||||
NormalizationGain: -10, // -10 Gain means the track is louder than the target volume
|
||||
},
|
||||
duration: 420,
|
||||
sessionId: 'TEST_SESSION_ID',
|
||||
sourceType: 'stream',
|
||||
}
|
||||
|
||||
const volume = calculateTrackVolume(track)
|
||||
|
||||
@@ -15,7 +15,15 @@ describe('Queue Index Util', () => {
|
||||
|
||||
it('should return the index of the active track + 1', async () => {
|
||||
const result = await findPlayNextIndexStart([
|
||||
{ id: '1', index: 0, url: 'https://example.com', item: { Id: '1' } },
|
||||
{
|
||||
id: '1',
|
||||
index: 0,
|
||||
url: 'https://example.com',
|
||||
item: { Id: '1' },
|
||||
duration: 420,
|
||||
sessionId: 'TEST_SESSION_ID',
|
||||
sourceType: 'stream',
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toBe(1)
|
||||
@@ -23,7 +31,15 @@ describe('Queue Index Util', () => {
|
||||
|
||||
it('should return 0 if the active track is not in the queue', async () => {
|
||||
const result = await findPlayNextIndexStart([
|
||||
{ id: '1', index: 0, url: 'https://example.com', item: { Id: '2' } },
|
||||
{
|
||||
id: '1',
|
||||
index: 0,
|
||||
url: 'https://example.com',
|
||||
item: { Id: '2' },
|
||||
duration: 420,
|
||||
sessionId: 'TEST_SESSION_ID',
|
||||
sourceType: 'stream',
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toBe(0)
|
||||
@@ -40,6 +56,9 @@ describe('Queue Index Util', () => {
|
||||
url: 'https://example.com',
|
||||
item: { Id: '1' },
|
||||
QueuingType: QueuingType.FromSelection,
|
||||
duration: 420,
|
||||
sessionId: 'TEST_SESSION_ID',
|
||||
sourceType: 'stream',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
@@ -47,6 +66,9 @@ describe('Queue Index Util', () => {
|
||||
url: 'https://example.com',
|
||||
item: { Id: '2' },
|
||||
QueuingType: QueuingType.PlayingNext,
|
||||
duration: 420,
|
||||
sessionId: 'TEST_SESSION_ID',
|
||||
sourceType: 'stream',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
@@ -54,6 +76,9 @@ describe('Queue Index Util', () => {
|
||||
url: 'https://example.com',
|
||||
item: { Id: '3' },
|
||||
QueuingType: QueuingType.DirectlyQueued,
|
||||
duration: 420,
|
||||
sessionId: 'TEST_SESSION_ID',
|
||||
sourceType: 'stream',
|
||||
},
|
||||
],
|
||||
0,
|
||||
@@ -71,6 +96,9 @@ describe('Queue Index Util', () => {
|
||||
url: 'https://example.com',
|
||||
item: { Id: '1' },
|
||||
QueuingType: QueuingType.FromSelection,
|
||||
duration: 420,
|
||||
sessionId: 'TEST_SESSION_ID',
|
||||
sourceType: 'stream',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
@@ -78,6 +106,9 @@ describe('Queue Index Util', () => {
|
||||
url: 'https://example.com',
|
||||
item: { Id: '2' },
|
||||
QueuingType: QueuingType.PlayingNext,
|
||||
duration: 420,
|
||||
sessionId: 'TEST_SESSION_ID',
|
||||
sourceType: 'stream',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
@@ -85,6 +116,9 @@ describe('Queue Index Util', () => {
|
||||
url: 'https://example.com',
|
||||
item: { Id: '3' },
|
||||
QueuingType: QueuingType.DirectlyQueued,
|
||||
duration: 420,
|
||||
sessionId: 'TEST_SESSION_ID',
|
||||
sourceType: 'stream',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
@@ -92,6 +126,9 @@ describe('Queue Index Util', () => {
|
||||
url: 'https://example.com',
|
||||
item: { Id: '4' },
|
||||
QueuingType: QueuingType.FromSelection,
|
||||
duration: 420,
|
||||
sessionId: 'TEST_SESSION_ID',
|
||||
sourceType: 'stream',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
@@ -99,6 +136,9 @@ describe('Queue Index Util', () => {
|
||||
url: 'https://example.com',
|
||||
item: { Id: '5' },
|
||||
QueuingType: QueuingType.FromSelection,
|
||||
duration: 420,
|
||||
sessionId: 'TEST_SESSION_ID',
|
||||
sourceType: 'stream',
|
||||
},
|
||||
],
|
||||
0,
|
||||
@@ -116,6 +156,9 @@ describe('Queue Index Util', () => {
|
||||
url: 'https://example.com',
|
||||
item: { Id: '2' },
|
||||
QueuingType: QueuingType.FromSelection,
|
||||
duration: 420,
|
||||
sessionId: 'TEST_SESSION_ID',
|
||||
sourceType: 'stream',
|
||||
},
|
||||
{
|
||||
id: '1',
|
||||
@@ -123,6 +166,9 @@ describe('Queue Index Util', () => {
|
||||
url: 'https://example.com',
|
||||
item: { Id: '1' },
|
||||
QueuingType: QueuingType.PlayingNext,
|
||||
duration: 420,
|
||||
sessionId: 'TEST_SESSION_ID',
|
||||
sourceType: 'stream',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
@@ -130,6 +176,9 @@ describe('Queue Index Util', () => {
|
||||
url: 'https://example.com',
|
||||
item: { Id: '3' },
|
||||
QueuingType: QueuingType.DirectlyQueued,
|
||||
duration: 420,
|
||||
sessionId: 'TEST_SESSION_ID',
|
||||
sourceType: 'stream',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
@@ -137,6 +186,9 @@ describe('Queue Index Util', () => {
|
||||
url: 'https://example.com',
|
||||
item: { Id: '5' },
|
||||
QueuingType: QueuingType.FromSelection,
|
||||
duration: 420,
|
||||
sessionId: 'TEST_SESSION_ID',
|
||||
sourceType: 'stream',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
@@ -144,6 +196,9 @@ describe('Queue Index Util', () => {
|
||||
url: 'https://example.com',
|
||||
item: { Id: '4' },
|
||||
QueuingType: QueuingType.FromSelection,
|
||||
duration: 420,
|
||||
sessionId: 'TEST_SESSION_ID',
|
||||
sourceType: 'stream',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
@@ -151,6 +206,9 @@ describe('Queue Index Util', () => {
|
||||
url: 'https://example.com',
|
||||
item: { Id: '6' },
|
||||
QueuingType: QueuingType.FromSelection,
|
||||
duration: 420,
|
||||
sessionId: 'TEST_SESSION_ID',
|
||||
sourceType: 'stream',
|
||||
},
|
||||
],
|
||||
0,
|
||||
|
||||
@@ -12,6 +12,9 @@ describe('Shuffle Utility Function', () => {
|
||||
id: `track-${i + 1}`,
|
||||
title: `Track ${i + 1}`,
|
||||
artist: `Artist ${i + 1}`,
|
||||
duration: 420,
|
||||
sessionId: 'TEST_SESSION_ID',
|
||||
sourceType: 'stream',
|
||||
item: {
|
||||
Id: `${i + 1}`,
|
||||
Name: `Track ${i + 1}`,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import isPlaybackFinished from '../../src/api/mutations/playback/utils'
|
||||
import { Progress } from 'react-native-track-player'
|
||||
import { shouldMarkPlaybackFinished } from '../../src/providers/Player/utils/handlers'
|
||||
|
||||
describe('Playback Event Handlers', () => {
|
||||
it('should determine that the track has finished', () => {
|
||||
@@ -9,7 +9,9 @@ describe('Playback Event Handlers', () => {
|
||||
buffered: 98.2345568679345,
|
||||
}
|
||||
|
||||
const playbackFinished = shouldMarkPlaybackFinished(progress.duration, progress.position)
|
||||
const { position, duration } = progress
|
||||
|
||||
const playbackFinished = isPlaybackFinished(position, duration)
|
||||
|
||||
expect(playbackFinished).toBeTruthy()
|
||||
})
|
||||
@@ -21,7 +23,9 @@ describe('Playback Event Handlers', () => {
|
||||
buffered: 98.2345568679345,
|
||||
}
|
||||
|
||||
const playbackFinished = shouldMarkPlaybackFinished(progress.duration, progress.position)
|
||||
const { position, duration } = progress
|
||||
|
||||
const playbackFinished = isPlaybackFinished(position, duration)
|
||||
|
||||
expect(playbackFinished).toBeFalsy()
|
||||
})
|
||||
86
src/api/mutations/download/index.ts
Normal file
86
src/api/mutations/download/index.ts
Normal 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
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import { MMKV } from 'react-native-mmkv'
|
||||
|
||||
import RNFS from 'react-native-fs'
|
||||
import JellifyTrack from '../../types/JellifyTrack'
|
||||
import JellifyTrack from '../../../types/JellifyTrack'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
JellifyDownload,
|
||||
JellifyDownloadProgress,
|
||||
JellifyDownloadProgressState,
|
||||
} from '../../types/JellifyDownload'
|
||||
} from '../../../types/JellifyDownload'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { queryClient } from '../../constants/query-client'
|
||||
import { QueryKeys } from '../../enums/query-keys'
|
||||
import { queryClient } from '../../../constants/query-client'
|
||||
import { QueryKeys } from '../../../enums/query-keys'
|
||||
|
||||
export async function downloadJellyfinFile(
|
||||
url: string,
|
||||
@@ -162,10 +162,10 @@ export const saveAudio = async (
|
||||
return true
|
||||
}
|
||||
|
||||
export const deleteAudio = async (trackItem: BaseItemDto) => {
|
||||
export const deleteAudio = async (itemId: string | undefined | null) => {
|
||||
const downloads = getAudioCache()
|
||||
|
||||
const download = downloads.filter((download) => download.item.Id === trackItem.Id)
|
||||
const download = downloads.filter((download) => download.item.Id === itemId)
|
||||
|
||||
if (download.length === 1) {
|
||||
RNFS.unlink(`${RNFS.DocumentDirectoryPath}/${download[0].item.Id}`)
|
||||
21
src/api/mutations/playback/functions/playback-completed.ts
Normal file
21
src/api/mutations/playback/functions/playback-completed.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
23
src/api/mutations/playback/functions/playback-progress.ts
Normal file
23
src/api/mutations/playback/functions/playback-progress.ts
Normal 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),
|
||||
},
|
||||
})
|
||||
}
|
||||
16
src/api/mutations/playback/functions/playback-started.ts
Normal file
16
src/api/mutations/playback/functions/playback-started.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
20
src/api/mutations/playback/functions/playback-stopped.ts
Normal file
20
src/api/mutations/playback/functions/playback-stopped.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
69
src/api/mutations/playback/index.ts
Normal file
69
src/api/mutations/playback/index.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
10
src/api/mutations/playback/utils/index.ts
Normal file
10
src/api/mutations/playback/utils/index.ts
Normal 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
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { getArtistsApi, getItemsApi } from '@jellyfin/sdk/lib/utils/api'
|
||||
import { JellifyUser } from '../../types/JellifyUser'
|
||||
import QueryConfig from './query.config'
|
||||
import { ApiLimits } from './query.config'
|
||||
|
||||
export function fetchArtists(
|
||||
api: Api | undefined,
|
||||
@@ -31,13 +31,13 @@ export function fetchArtists(
|
||||
.getAlbumArtists({
|
||||
parentId: library.musicLibraryId,
|
||||
userId: user.id,
|
||||
enableUserData: true,
|
||||
enableUserData: false, // This data is fetched lazily on component render
|
||||
sortBy: sortBy,
|
||||
sortOrder: sortOrder,
|
||||
startIndex: page * QueryConfig.limits.library,
|
||||
limit: QueryConfig.limits.library,
|
||||
startIndex: page * ApiLimits.Library,
|
||||
limit: ApiLimits.Library,
|
||||
isFavorite: isFavorite,
|
||||
fields: [ItemFields.SortName, ItemFields.ChildCount],
|
||||
fields: [ItemFields.SortName],
|
||||
})
|
||||
.then((response) => {
|
||||
console.debug('Artists Response received')
|
||||
|
||||
27
src/api/queries/download/index.ts
Normal file
27
src/api/queries/download/index.ts
Normal 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
|
||||
6
src/api/queries/download/keys.ts
Normal file
6
src/api/queries/download/keys.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
enum DownloadQueryKeys {
|
||||
DownloadedTrack = 'DOWNLOADED_TRACK',
|
||||
DownloadedTracks = 'DownloadedTracks',
|
||||
}
|
||||
|
||||
export default DownloadQueryKeys
|
||||
20
src/api/queries/download/utils/storage-in-use.ts
Normal file
20
src/api/queries/download/utils/storage-in-use.ts
Normal 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
|
||||
76
src/api/queries/media/index.ts
Normal file
76
src/api/queries/media/index.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import { Api } from '@jellyfin/sdk'
|
||||
import { PlaybackInfoResponse } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { DeviceProfile, PlaybackInfoResponse } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { getMediaInfoApi } from '@jellyfin/sdk/lib/utils/api'
|
||||
import { isUndefined } from 'lodash'
|
||||
import { JellifyUser } from '../../types/JellifyUser'
|
||||
import { AudioQuality } from '../../types/AudioQuality'
|
||||
import { JellifyUser } from '../../../../types/JellifyUser'
|
||||
|
||||
export async function fetchMediaInfo(
|
||||
api: Api | undefined,
|
||||
user: JellifyUser | undefined,
|
||||
bitrate: AudioQuality | undefined,
|
||||
itemId: string,
|
||||
deviceProfile: DeviceProfile | undefined,
|
||||
itemId: string | null | undefined,
|
||||
): Promise<PlaybackInfoResponse> {
|
||||
console.debug(`Fetching media info of quality ${JSON.stringify(bitrate)}`)
|
||||
console.debug(`Fetching media info of with ${deviceProfile?.Name} profile`)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isUndefined(api)) return reject('Client instance not set')
|
||||
@@ -22,12 +21,7 @@ export async function fetchMediaInfo(
|
||||
itemId: itemId!,
|
||||
userId: user.id,
|
||||
playbackInfoDto: {
|
||||
MaxAudioChannels: bitrate?.MaxAudioBitDepth
|
||||
? parseInt(bitrate.MaxAudioBitDepth)
|
||||
: undefined,
|
||||
MaxStreamingBitrate: bitrate?.AudioBitRate
|
||||
? parseInt(bitrate.AudioBitRate)
|
||||
: undefined,
|
||||
DeviceProfile: deviceProfile,
|
||||
},
|
||||
})
|
||||
.then(({ data }) => {
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ImageFormat } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
|
||||
export enum ApiLimits {
|
||||
Library = 100,
|
||||
}
|
||||
|
||||
const QueryConfig = {
|
||||
/**
|
||||
* Defines the limits for the number of items returned by a query
|
||||
|
||||
@@ -9,21 +9,23 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import InstantMixButton from '../Global/components/instant-mix-button'
|
||||
import ItemImage from '../Global/components/image'
|
||||
import React, { useCallback, useEffect, useMemo } from 'react'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { useJellifyContext } from '../../providers'
|
||||
import { useSafeAreaFrame } from 'react-native-safe-area-context'
|
||||
import Icon from '../Global/components/icon'
|
||||
import { mapDtoToTrack } from '../../utils/mappings'
|
||||
import { useNetworkContext } from '../../providers/Network'
|
||||
import { useDownloadQualityContext, useStreamingQualityContext } from '../../providers/Settings'
|
||||
import { useDownloadQualityContext } from '../../providers/Settings'
|
||||
import { useLoadNewQueue } from '../../providers/Player/hooks/mutations'
|
||||
import { QueuingType } from '../../enums/queuing-type'
|
||||
import { useAlbumContext } from '../../providers/Album'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import HomeStackParamList from '@/src/screens/Home/types'
|
||||
import LibraryStackParamList from '@/src/screens/Library/types'
|
||||
import DiscoverStackParamList from '@/src/screens/Discover/types'
|
||||
import { BaseStackParamList } from '@/src/screens/types'
|
||||
import HomeStackParamList from '../../screens/Home/types'
|
||||
import LibraryStackParamList from '../../screens/Library/types'
|
||||
import DiscoverStackParamList from '../../screens/Discover/types'
|
||||
import { BaseStackParamList } from '../../screens/types'
|
||||
import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../../stores/device-profile'
|
||||
import { useAllDownloadedTracks } from '../../api/queries/download'
|
||||
|
||||
/**
|
||||
* The screen for an Album's track list
|
||||
@@ -38,19 +40,21 @@ export function Album(): React.JSX.Element {
|
||||
|
||||
const { album, discs, isPending } = useAlbumContext()
|
||||
|
||||
const { api, sessionId } = useJellifyContext()
|
||||
const { useDownloadMultiple, pendingDownloads, networkStatus, downloadedTracks } =
|
||||
useNetworkContext()
|
||||
const { api } = useJellifyContext()
|
||||
const { useDownloadMultiple, pendingDownloads, networkStatus } = useNetworkContext()
|
||||
const downloadQuality = useDownloadQualityContext()
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
const streamingDeviceProfile = useStreamingDeviceProfile()
|
||||
const downloadingDeviceProfile = useDownloadingDeviceProfile()
|
||||
const { mutate: loadNewQueue } = useLoadNewQueue()
|
||||
|
||||
const { data: downloadedTracks } = useAllDownloadedTracks()
|
||||
|
||||
const downloadAlbum = (item: BaseItemDto[]) => {
|
||||
if (!api || !sessionId) return
|
||||
if (!api) return
|
||||
const jellifyTracks = item.map((item) =>
|
||||
mapDtoToTrack(api, item, [], undefined, downloadQuality, streamingQuality),
|
||||
mapDtoToTrack(api, item, [], downloadingDeviceProfile),
|
||||
)
|
||||
useDownloadMultiple.mutate(jellifyTracks)
|
||||
useDownloadMultiple(jellifyTracks)
|
||||
}
|
||||
|
||||
const playAlbum = useCallback(
|
||||
@@ -64,7 +68,7 @@ export function Album(): React.JSX.Element {
|
||||
api,
|
||||
downloadedTracks,
|
||||
networkStatus,
|
||||
streamingQuality,
|
||||
deviceProfile: streamingDeviceProfile,
|
||||
downloadQuality,
|
||||
track: allTracks[0],
|
||||
index: 0,
|
||||
|
||||
@@ -11,7 +11,7 @@ import LibraryStackParamList from '../../screens/Library/types'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import { warmItemContext } from '../../hooks/use-item-context'
|
||||
import { useJellifyContext } from '../../providers'
|
||||
import { useStreamingQualityContext } from '../../providers/Settings'
|
||||
import useStreamingDeviceProfile from '../../stores/device-profile'
|
||||
|
||||
interface AlbumsProps {
|
||||
albums: (string | number | BaseItemDto)[] | undefined
|
||||
@@ -33,13 +33,13 @@ export default function Albums({
|
||||
|
||||
const { api, user } = useJellifyContext()
|
||||
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
const deviceProfile = useStreamingDeviceProfile()
|
||||
|
||||
const onViewableItemsChangedRef = useRef(
|
||||
({ viewableItems }: { viewableItems: ViewToken<string | number | BaseItemDto>[] }) => {
|
||||
viewableItems.forEach(({ isViewable, item }) => {
|
||||
if (isViewable && typeof item === 'object')
|
||||
warmItemContext(api, user, item, streamingQuality)
|
||||
warmItemContext(api, user, item, deviceProfile)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -12,14 +12,14 @@ import navigationRef from '../../../navigation'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
|
||||
import { warmItemContext } from '../../hooks/use-item-context'
|
||||
import { useJellifyContext } from '../../providers'
|
||||
import { useStreamingQualityContext } from '../../providers/Settings'
|
||||
import useStreamingDeviceProfile from '../../stores/device-profile'
|
||||
export default function Albums({
|
||||
route,
|
||||
navigation,
|
||||
}: ArtistAlbumsProps | ArtistEpsProps | ArtistFeaturedOnProps): React.JSX.Element {
|
||||
const { api, user } = useJellifyContext()
|
||||
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
const deviceProfile = useStreamingDeviceProfile()
|
||||
|
||||
const { width } = useSafeAreaFrame()
|
||||
const { albums, fetchingAlbums, featuredOn, scroll } = useArtistContext()
|
||||
@@ -33,7 +33,7 @@ export default function Albums({
|
||||
const onViewableItemsChangedRef = useRef(
|
||||
({ viewableItems }: { viewableItems: ViewToken<BaseItemDto>[] }) => {
|
||||
viewableItems.forEach(({ isViewable, item }) => {
|
||||
if (isViewable) warmItemContext(api, user, item, streamingQuality)
|
||||
if (isViewable) warmItemContext(api, user, item, deviceProfile)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -16,9 +16,11 @@ import { useLoadNewQueue } from '../../providers/Player/hooks/mutations'
|
||||
import { QueuingType } from '../../enums/queuing-type'
|
||||
import { fetchAlbumDiscs } from '../../api/queries/item'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import { BaseStackParamList } from '@/src/screens/types'
|
||||
import { useDownloadQualityContext, useStreamingQualityContext } from '../../providers/Settings'
|
||||
import { BaseStackParamList } from '../../screens/types'
|
||||
import { useDownloadQualityContext } from '../../providers/Settings'
|
||||
import { useNetworkContext } from '../../providers/Network'
|
||||
import useStreamingDeviceProfile from '../../stores/device-profile'
|
||||
import { useAllDownloadedTracks } from '../../api/queries/download'
|
||||
|
||||
export default function ArtistTabBar({
|
||||
stackNavigation,
|
||||
@@ -31,11 +33,13 @@ export default function ArtistTabBar({
|
||||
const { artist, scroll, albums } = useArtistContext()
|
||||
const { mutate: loadNewQueue } = useLoadNewQueue()
|
||||
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
const deviceProfile = useStreamingDeviceProfile()
|
||||
|
||||
const downloadQuality = useDownloadQualityContext()
|
||||
|
||||
const { downloadedTracks, networkStatus } = useNetworkContext()
|
||||
const { networkStatus } = useNetworkContext()
|
||||
|
||||
const { data: downloadedTracks } = useAllDownloadedTracks()
|
||||
|
||||
const { width } = useSafeAreaFrame()
|
||||
|
||||
@@ -60,7 +64,7 @@ export default function ArtistTabBar({
|
||||
api,
|
||||
downloadedTracks,
|
||||
networkStatus,
|
||||
streamingQuality,
|
||||
deviceProfile,
|
||||
downloadQuality,
|
||||
track: allTracks[0],
|
||||
index: 0,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import LibraryStackParamList from '../../screens/Library/types'
|
||||
import { warmItemContext } from '../../hooks/use-item-context'
|
||||
import { useJellifyContext } from '../../providers'
|
||||
import { useStreamingQualityContext } from '../../providers/Settings'
|
||||
import useStreamingDeviceProfile from '../../stores/device-profile'
|
||||
|
||||
/**
|
||||
* @param artistsInfiniteQuery - The infinite query for artists
|
||||
@@ -33,7 +33,7 @@ export default function Artists({
|
||||
|
||||
const { api, user } = useJellifyContext()
|
||||
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
const deviceProfile = useStreamingDeviceProfile()
|
||||
|
||||
const { isFavorites } = useLibrarySortAndFilterContext()
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function Artists({
|
||||
({ viewableItems }: { viewableItems: ViewToken<string | number | BaseItemDto>[] }) => {
|
||||
viewableItems.forEach(({ isViewable, item }) => {
|
||||
if (isViewable && typeof item === 'object')
|
||||
warmItemContext(api, user, item, streamingQuality)
|
||||
warmItemContext(api, user, item, deviceProfile)
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -185,6 +185,7 @@ export default function Artists({
|
||||
if (artistsInfiniteQuery.hasNextPage && !artistsInfiniteQuery.isFetching)
|
||||
artistsInfiniteQuery.fetchNextPage()
|
||||
}}
|
||||
// onEndReachedThreshold default is 0.5
|
||||
removeClippedSubviews
|
||||
onViewableItemsChanged={onViewableItemsChangedRef.current}
|
||||
/>
|
||||
|
||||
101
src/components/AudioSpecs/index.tsx
Normal file
101
src/components/AudioSpecs/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { QueryKeys } from '../../enums/query-keys'
|
||||
import { CarPlay, ListTemplate } from 'react-native-carplay'
|
||||
import { queryClient } from '../../constants/query-client'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { BaseItemDto, DeviceProfile } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import TracksTemplate from './Tracks'
|
||||
import ArtistsTemplate from './Artists'
|
||||
import uuid from 'react-native-uuid'
|
||||
@@ -11,7 +11,7 @@ import { JellifyLibrary } from '../../types/JellifyLibrary'
|
||||
import { Api } from '@jellyfin/sdk'
|
||||
import { JellifyDownload } from '../../types/JellifyDownload'
|
||||
import { networkStatusTypes } from '../Network/internetConnectionWatcher'
|
||||
import { DownloadQuality, StreamingQuality } from '../../providers/Settings'
|
||||
import { DownloadQuality } from '../../providers/Settings'
|
||||
|
||||
const CarPlayHome = (
|
||||
library: JellifyLibrary,
|
||||
@@ -19,7 +19,7 @@ const CarPlayHome = (
|
||||
api: Api | undefined,
|
||||
downloadedTracks: JellifyDownload[] | undefined,
|
||||
networkStatus: networkStatusTypes | null,
|
||||
streamingQuality: StreamingQuality,
|
||||
deviceProfile: DeviceProfile | undefined,
|
||||
downloadQuality: DownloadQuality,
|
||||
) =>
|
||||
new ListTemplate({
|
||||
@@ -71,7 +71,7 @@ const CarPlayHome = (
|
||||
api,
|
||||
downloadedTracks,
|
||||
networkStatus,
|
||||
streamingQuality,
|
||||
deviceProfile,
|
||||
downloadQuality,
|
||||
),
|
||||
)
|
||||
@@ -102,7 +102,7 @@ const CarPlayHome = (
|
||||
api,
|
||||
downloadedTracks,
|
||||
networkStatus,
|
||||
streamingQuality,
|
||||
deviceProfile,
|
||||
downloadQuality,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -7,7 +7,8 @@ import { JellifyLibrary } from '../../types/JellifyLibrary'
|
||||
import { Api } from '@jellyfin/sdk'
|
||||
import { JellifyDownload } from '@/src/types/JellifyDownload'
|
||||
import { networkStatusTypes } from '../Network/internetConnectionWatcher'
|
||||
import { DownloadQuality, StreamingQuality } from '../../providers/Settings'
|
||||
import { DownloadQuality } from '../../providers/Settings'
|
||||
import { DeviceProfile } from '@jellyfin/sdk/lib/generated-client'
|
||||
|
||||
const CarPlayNavigation = (
|
||||
library: JellifyLibrary,
|
||||
@@ -15,7 +16,7 @@ const CarPlayNavigation = (
|
||||
api: Api | undefined,
|
||||
downloadedTracks: JellifyDownload[] | undefined,
|
||||
networkStatus: networkStatusTypes | null,
|
||||
streamingQuality: StreamingQuality,
|
||||
deviceProfile: DeviceProfile | undefined,
|
||||
downloadQuality: DownloadQuality,
|
||||
) =>
|
||||
new TabBarTemplate({
|
||||
@@ -28,7 +29,7 @@ const CarPlayNavigation = (
|
||||
api,
|
||||
downloadedTracks,
|
||||
networkStatus,
|
||||
streamingQuality,
|
||||
deviceProfile,
|
||||
downloadQuality,
|
||||
),
|
||||
CarPlayDiscover,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { BaseItemDto, DeviceProfile } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { CarPlay, ListTemplate } from 'react-native-carplay'
|
||||
import uuid from 'react-native-uuid'
|
||||
import CarPlayNowPlaying from './NowPlaying'
|
||||
@@ -8,7 +8,7 @@ import { QueuingType } from '../../enums/queuing-type'
|
||||
import { Api } from '@jellyfin/sdk'
|
||||
import { JellifyDownload } from '../../types/JellifyDownload'
|
||||
import { networkStatusTypes } from '../Network/internetConnectionWatcher'
|
||||
import { DownloadQuality, StreamingQuality } from '../../providers/Settings'
|
||||
import { DownloadQuality } from '../../providers/Settings'
|
||||
|
||||
const TracksTemplate = (
|
||||
items: BaseItemDto[],
|
||||
@@ -17,7 +17,7 @@ const TracksTemplate = (
|
||||
api: Api | undefined,
|
||||
downloadedTracks: JellifyDownload[] | undefined,
|
||||
networkStatus: networkStatusTypes | null,
|
||||
streamingQuality: StreamingQuality,
|
||||
deviceProfile: DeviceProfile | undefined,
|
||||
downloadQuality: DownloadQuality,
|
||||
) =>
|
||||
new ListTemplate({
|
||||
@@ -37,7 +37,7 @@ const TracksTemplate = (
|
||||
loadQueue({
|
||||
api,
|
||||
networkStatus,
|
||||
streamingQuality,
|
||||
deviceProfile,
|
||||
downloadQuality,
|
||||
downloadedTracks,
|
||||
queuingType: QueuingType.FromSelection,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { getToken, ListItem, ScrollView, Spinner, View, XStack, YGroup } from 'tamagui'
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
MediaSourceInfo,
|
||||
} from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { getToken, ListItem, ScrollView, Spinner, View, YGroup } from 'tamagui'
|
||||
import { BaseStackParamList, RootStackParamList } from '../../screens/types'
|
||||
import { Text } from '../Global/helpers/text'
|
||||
import FavoriteContextMenuRow from '../Global/components/favorite-context-menu-row'
|
||||
import { useColorScheme } from 'react-native'
|
||||
import {
|
||||
useDownloadQualityContext,
|
||||
useStreamingQualityContext,
|
||||
useThemeSettingContext,
|
||||
} from '../../providers/Settings'
|
||||
import { useDownloadQualityContext, useThemeSettingContext } from '../../providers/Settings'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import Icon from '../Global/components/icon'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
@@ -32,17 +32,27 @@ import { trigger } from 'react-native-haptic-feedback'
|
||||
import { useAddToQueue } from '../../providers/Player/hooks/mutations'
|
||||
import { useNetworkContext } from '../../providers/Network'
|
||||
import { mapDtoToTrack } from '../../utils/mappings'
|
||||
import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../../stores/device-profile'
|
||||
import { useAllDownloadedTracks, useIsDownloaded } from '../../api/queries/download'
|
||||
import { useDeleteDownloads } from '../../api/mutations/download'
|
||||
|
||||
type StackNavigation = Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
|
||||
|
||||
interface ContextProps {
|
||||
item: BaseItemDto
|
||||
streamingMediaSourceInfo?: MediaSourceInfo
|
||||
downloadedMediaSourceInfo?: MediaSourceInfo
|
||||
stackNavigation?: StackNavigation
|
||||
navigation: NativeStackNavigationProp<RootStackParamList>
|
||||
navigationCallback?: (screen: 'Album' | 'Artist', item: BaseItemDto) => void
|
||||
}
|
||||
|
||||
export default function ItemContext({ item, stackNavigation }: ContextProps): React.JSX.Element {
|
||||
export default function ItemContext({
|
||||
item,
|
||||
streamingMediaSourceInfo,
|
||||
downloadedMediaSourceInfo,
|
||||
stackNavigation,
|
||||
}: ContextProps): React.JSX.Element {
|
||||
const { api } = useJellifyContext()
|
||||
|
||||
const { bottom } = useSafeAreaInsets()
|
||||
@@ -110,6 +120,14 @@ export default function ItemContext({ item, stackNavigation }: ContextProps): Re
|
||||
|
||||
{renderAddToPlaylistRow && <AddToPlaylistRow track={item} />}
|
||||
|
||||
{(streamingMediaSourceInfo || downloadedMediaSourceInfo) && (
|
||||
<StatsRow
|
||||
item={item}
|
||||
streamingMediaSourceInfo={streamingMediaSourceInfo}
|
||||
downloadedMediaSourceInfo={downloadedMediaSourceInfo}
|
||||
/>
|
||||
)}
|
||||
|
||||
{renderViewAlbumRow && (
|
||||
<ViewAlbumMenuRow
|
||||
album={isAlbum ? item : album!}
|
||||
@@ -131,7 +149,7 @@ function AddToPlaylistRow({ track }: { track: BaseItemDto }): React.JSX.Element
|
||||
animation={'quick'}
|
||||
backgroundColor={'transparent'}
|
||||
flex={1}
|
||||
gap={'$2'}
|
||||
gap={'$2.5'}
|
||||
justifyContent='flex-start'
|
||||
onPress={() => {
|
||||
navigationRef.goBack()
|
||||
@@ -149,11 +167,13 @@ function AddToPlaylistRow({ track }: { track: BaseItemDto }): React.JSX.Element
|
||||
function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Element {
|
||||
const { api } = useJellifyContext()
|
||||
|
||||
const { networkStatus, downloadedTracks } = useNetworkContext()
|
||||
const { networkStatus } = useNetworkContext()
|
||||
|
||||
const { data: downloadedTracks } = useAllDownloadedTracks()
|
||||
|
||||
const downloadQuality = useDownloadQualityContext()
|
||||
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
const deviceProfile = useStreamingDeviceProfile()
|
||||
|
||||
const { mutate: addToQueue } = useAddToQueue()
|
||||
|
||||
@@ -161,7 +181,7 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele
|
||||
api,
|
||||
networkStatus,
|
||||
downloadedTracks,
|
||||
streamingQuality,
|
||||
deviceProfile,
|
||||
downloadQuality,
|
||||
tracks,
|
||||
queuingType: QueuingType.DirectlyQueued,
|
||||
@@ -172,7 +192,7 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele
|
||||
animation={'quick'}
|
||||
backgroundColor={'transparent'}
|
||||
flex={1}
|
||||
gap={'$2'}
|
||||
gap={'$2.5'}
|
||||
justifyContent='flex-start'
|
||||
onPress={() => {
|
||||
addToQueue(mutation)
|
||||
@@ -203,42 +223,24 @@ function BackgroundGradient(): React.JSX.Element {
|
||||
|
||||
function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element {
|
||||
const { api } = useJellifyContext()
|
||||
const { useDownloadMultiple, downloadedTracks, useRemoveDownload, pendingDownloads } =
|
||||
useNetworkContext()
|
||||
const { useDownloadMultiple, pendingDownloads } = useNetworkContext()
|
||||
|
||||
const { mutate: downloadMultiple } = useDownloadMultiple
|
||||
const useRemoveDownload = useDeleteDownloads()
|
||||
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
const downloadQuality = useDownloadQualityContext()
|
||||
const deviceProfile = useDownloadingDeviceProfile()
|
||||
|
||||
const isDownloaded = useIsDownloaded(items.map(({ Id }) => Id))
|
||||
|
||||
const downloadItems = useCallback(() => {
|
||||
if (!api) return
|
||||
|
||||
const tracks = items.map((item) =>
|
||||
mapDtoToTrack(
|
||||
api,
|
||||
item,
|
||||
downloadedTracks ?? [],
|
||||
QueuingType.FromSelection,
|
||||
downloadQuality,
|
||||
streamingQuality,
|
||||
),
|
||||
)
|
||||
downloadMultiple(tracks)
|
||||
const tracks = items.map((item) => mapDtoToTrack(api, item, [], deviceProfile))
|
||||
useDownloadMultiple(tracks)
|
||||
}, [useDownloadMultiple, items])
|
||||
|
||||
const removeDownloads = useCallback(() => {
|
||||
items.forEach((download) => useRemoveDownload.mutate(download))
|
||||
}, [useRemoveDownload, items])
|
||||
|
||||
const isDownloaded = useMemo(
|
||||
() =>
|
||||
items.filter(
|
||||
(item) =>
|
||||
(downloadedTracks ?? []).filter((track) => item.Id === track.item.Id).length >
|
||||
0,
|
||||
).length === items.length,
|
||||
[items, downloadedTracks],
|
||||
const removeDownloads = useCallback(
|
||||
() => useRemoveDownload(items.map(({ Id }) => Id)),
|
||||
[useRemoveDownload, items],
|
||||
)
|
||||
|
||||
const isPending = useMemo(
|
||||
@@ -269,7 +271,7 @@ function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element
|
||||
<ListItem
|
||||
animation={'quick'}
|
||||
backgroundColor={'transparent'}
|
||||
gap={'$2'}
|
||||
gap={'$2.5'}
|
||||
justifyContent='flex-start'
|
||||
onPress={downloadItems}
|
||||
pressStyle={{ opacity: 0.5 }}
|
||||
@@ -286,7 +288,7 @@ function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element
|
||||
<ListItem
|
||||
animation={'quick'}
|
||||
backgroundColor={'transparent'}
|
||||
gap={'$2'}
|
||||
gap={'$2.5'}
|
||||
justifyContent='flex-start'
|
||||
onPress={removeDownloads}
|
||||
pressStyle={{ opacity: 0.5 }}
|
||||
@@ -383,3 +385,34 @@ function ViewArtistMenuRow({
|
||||
<></>
|
||||
)
|
||||
}
|
||||
|
||||
function StatsRow({
|
||||
item,
|
||||
streamingMediaSourceInfo,
|
||||
downloadedMediaSourceInfo,
|
||||
}: {
|
||||
item: BaseItemDto
|
||||
streamingMediaSourceInfo?: MediaSourceInfo
|
||||
downloadedMediaSourceInfo?: MediaSourceInfo
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<ListItem
|
||||
backgroundColor={'transparent'}
|
||||
gap={'$2.5'}
|
||||
justifyContent='flex-start'
|
||||
onPress={() => {
|
||||
navigationRef.goBack() // dismiss context modal
|
||||
navigationRef.navigate('AudioSpecs', {
|
||||
item,
|
||||
streamingMediaSourceInfo,
|
||||
downloadedMediaSourceInfo,
|
||||
})
|
||||
}}
|
||||
pressStyle={{ opacity: 0.5 }}
|
||||
>
|
||||
<Icon small name='sine-wave' color='$primary' />
|
||||
|
||||
<Text bold>Open Audio Specs</Text>
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react'
|
||||
import { getToken, ScrollView, Separator, View } from 'tamagui'
|
||||
import RecentlyAdded from './helpers/just-added'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import { useDiscoverContext } from '../../providers/Discover'
|
||||
import { RefreshControl } from 'react-native'
|
||||
import PublicPlaylists from './helpers/public-playlists'
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { RootStackParamList } from '../../../screens/types'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import HorizontalCardList from '../../../components/Global/components/horizontal-list'
|
||||
import { ItemCard } from '../../../components/Global/components/item-card'
|
||||
import { useDiscoverContext } from '../../../providers/Discover'
|
||||
import { View, XStack } from 'tamagui'
|
||||
import { H2, H4 } from '../../../components/Global/helpers/text'
|
||||
import { H4 } from '../../../components/Global/helpers/text'
|
||||
import Icon from '../../Global/components/icon'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import DiscoverStackParamList from '../../../screens/Discover/types'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { View, XStack } from 'tamagui'
|
||||
import { useDiscoverContext } from '../../../providers/Discover'
|
||||
import { RootStackParamList } from '../../../screens/types'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import Icon from '../../Global/components/icon'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { useNetworkContext } from '../../../providers/Network'
|
||||
import { Spacer } from 'tamagui'
|
||||
import Icon from './icon'
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
|
||||
import { memo, useMemo } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { useIsDownloaded } from '../../../api/queries/download'
|
||||
|
||||
function DownloadedIcon({ item }: { item: BaseItemDto }) {
|
||||
const { downloadedTracks } = useNetworkContext()
|
||||
|
||||
const isDownloaded = useMemo(
|
||||
() => downloadedTracks?.find((downloadedTrack) => downloadedTrack.item.Id === item.Id),
|
||||
[downloadedTracks, item.Id],
|
||||
)
|
||||
const isDownloaded = useIsDownloaded([item.Id])
|
||||
|
||||
return isDownloaded ? (
|
||||
<Animated.View entering={FadeIn} exiting={FadeOut}>
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function FavoriteContextMenuRow({ item }: { item: BaseItemDto }):
|
||||
exiting={FadeOut}
|
||||
key={`${item.Id}-remove-favorite-row`}
|
||||
>
|
||||
<XStack alignItems='center' justifyContent='flex-start' gap={'$2'}>
|
||||
<XStack alignItems='center' justifyContent='flex-start' gap={'$2.5'}>
|
||||
<Icon name={'heart'} small color={'$primary'} />
|
||||
|
||||
<Text bold>Remove from favorites</Text>
|
||||
@@ -51,7 +51,6 @@ export default function FavoriteContextMenuRow({ item }: { item: BaseItemDto }):
|
||||
animation={'quick'}
|
||||
backgroundColor={'transparent'}
|
||||
justifyContent='flex-start'
|
||||
gap={'$2'}
|
||||
onPress={() => {
|
||||
toggleFavorite(!!isFavorite, {
|
||||
item,
|
||||
@@ -61,7 +60,7 @@ export default function FavoriteContextMenuRow({ item }: { item: BaseItemDto }):
|
||||
pressStyle={{ opacity: 0.5 }}
|
||||
>
|
||||
<Animated.View entering={FadeIn} exiting={FadeOut} key={`${item.Id}-favorite-row`}>
|
||||
<XStack alignItems='center' justifyContent='flex-start' gap={'$2'}>
|
||||
<XStack alignItems='center' justifyContent='flex-start' gap={'$2.5'}>
|
||||
<Icon small name={'heart-outline'} color={'$primary'} />
|
||||
|
||||
<Text bold>Add to favorites</Text>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import useStreamingDeviceProfile from '../../../stores/device-profile'
|
||||
import { warmItemContext } from '../../../hooks/use-item-context'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import { useStreamingQualityContext } from '../../../providers/Settings'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
|
||||
import { FlashList, FlashListProps, ViewToken } from '@shopify/flash-list'
|
||||
import React, { useRef } from 'react'
|
||||
@@ -18,15 +18,13 @@ export default function HorizontalCardList({
|
||||
}: HorizontalCardListProps): React.JSX.Element {
|
||||
const { api, user } = useJellifyContext()
|
||||
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
const deviceProfile = useStreamingDeviceProfile()
|
||||
|
||||
const onViewableItemsChangedRef = useRef(
|
||||
({ viewableItems }: { viewableItems: ViewToken<BaseItemDto>[] }) => {
|
||||
viewableItems
|
||||
.filter(({ isViewable }) => isViewable)
|
||||
.forEach(({ isViewable, item }) => {
|
||||
if (isViewable) warmItemContext(api, user, item, streamingQuality)
|
||||
})
|
||||
.forEach(({ item }) => warmItemContext(api, user, item, deviceProfile))
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ import { BaseStackParamList } from '../../../screens/types'
|
||||
import { useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import { useNetworkContext } from '../../../providers/Network'
|
||||
import { useDownloadQualityContext, useStreamingQualityContext } from '../../../providers/Settings'
|
||||
import { useDownloadQualityContext } from '../../../providers/Settings'
|
||||
import useStreamingDeviceProfile from '../../../stores/device-profile'
|
||||
import { useAllDownloadedTracks } from '../../../api/queries/download'
|
||||
|
||||
interface ItemRowProps {
|
||||
item: BaseItemDto
|
||||
@@ -43,9 +45,11 @@ export default function ItemRow({
|
||||
}: ItemRowProps): React.JSX.Element {
|
||||
const { api } = useJellifyContext()
|
||||
|
||||
const { downloadedTracks, networkStatus } = useNetworkContext()
|
||||
const { networkStatus } = useNetworkContext()
|
||||
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
const { data: downloadedTracks } = useAllDownloadedTracks()
|
||||
|
||||
const deviceProfile = useStreamingDeviceProfile()
|
||||
|
||||
const downloadQuality = useDownloadQualityContext()
|
||||
|
||||
@@ -58,7 +62,7 @@ export default function ItemRow({
|
||||
api,
|
||||
downloadedTracks,
|
||||
networkStatus,
|
||||
streamingQuality,
|
||||
deviceProfile,
|
||||
downloadQuality,
|
||||
track: item,
|
||||
tracklist: [item],
|
||||
|
||||
@@ -17,8 +17,11 @@ import ItemImage from './image'
|
||||
import useItemContext from '../../../hooks/use-item-context'
|
||||
import { useNowPlaying, useQueue } from '../../../providers/Player/hooks/queries'
|
||||
import { useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
|
||||
import { useDownloadQualityContext, useStreamingQualityContext } from '../../../providers/Settings'
|
||||
import { useDownloadQualityContext } from '../../../providers/Settings'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import useStreamingDeviceProfile from '../../../stores/device-profile'
|
||||
import useStreamedMediaInfo from '../../../api/queries/media'
|
||||
import { useAllDownloadedTracks, useDownloadedTrack } from '../../../api/queries/download'
|
||||
|
||||
export interface TrackProps {
|
||||
track: BaseItemDto
|
||||
@@ -56,14 +59,20 @@ export default function Track({
|
||||
|
||||
const { api } = useJellifyContext()
|
||||
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
const deviceProfile = useStreamingDeviceProfile()
|
||||
|
||||
const downloadQuality = useDownloadQualityContext()
|
||||
|
||||
const { data: nowPlaying } = useNowPlaying()
|
||||
const { data: playQueue } = useQueue()
|
||||
const { mutate: loadNewQueue } = useLoadNewQueue()
|
||||
const { downloadedTracks, networkStatus } = useNetworkContext()
|
||||
const { networkStatus } = useNetworkContext()
|
||||
|
||||
const { data: mediaInfo } = useStreamedMediaInfo(track.Id)
|
||||
|
||||
const { data: downloadedTracks } = useAllDownloadedTracks()
|
||||
|
||||
const offlineAudio = useDownloadedTrack(track.Id)
|
||||
|
||||
useItemContext(track)
|
||||
|
||||
@@ -73,13 +82,6 @@ export default function Track({
|
||||
[nowPlaying?.item.Id, track.Id],
|
||||
)
|
||||
|
||||
const offlineAudio = useMemo(
|
||||
() => downloadedTracks?.find((t) => t.item.Id === track.Id),
|
||||
[downloadedTracks, track.Id],
|
||||
)
|
||||
|
||||
const isDownloaded = useMemo(() => offlineAudio?.item?.Id, [offlineAudio])
|
||||
|
||||
const isOffline = useMemo(
|
||||
() => networkStatus === networkStatusTypes.DISCONNECTED,
|
||||
[networkStatus],
|
||||
@@ -99,7 +101,7 @@ export default function Track({
|
||||
loadNewQueue({
|
||||
api,
|
||||
downloadedTracks,
|
||||
streamingQuality,
|
||||
deviceProfile,
|
||||
downloadQuality,
|
||||
networkStatus,
|
||||
track,
|
||||
@@ -110,7 +112,7 @@ export default function Track({
|
||||
startPlayback: true,
|
||||
})
|
||||
}
|
||||
}, [onPress, track, index, memoizedTracklist, queue, useLoadNewQueue])
|
||||
}, [onPress, track, index, memoizedTracklist, queue, useLoadNewQueue, downloadedTracks])
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
if (onLongPress) {
|
||||
@@ -119,9 +121,13 @@ export default function Track({
|
||||
navigationRef.navigate('Context', {
|
||||
item: track,
|
||||
navigation,
|
||||
streamingMediaSourceInfo: mediaInfo?.MediaSources
|
||||
? mediaInfo!.MediaSources![0]
|
||||
: undefined,
|
||||
downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
|
||||
})
|
||||
}
|
||||
}, [onLongPress, track, isNested])
|
||||
}, [onLongPress, track, isNested, offlineAudio])
|
||||
|
||||
const handleIconPress = useCallback(() => {
|
||||
if (showRemove) {
|
||||
@@ -129,16 +135,21 @@ export default function Track({
|
||||
} else {
|
||||
navigationRef.navigate('Context', {
|
||||
item: track,
|
||||
navigation,
|
||||
streamingMediaSourceInfo: mediaInfo?.MediaSources
|
||||
? mediaInfo!.MediaSources![0]
|
||||
: undefined,
|
||||
downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
|
||||
})
|
||||
}
|
||||
}, [showRemove, onRemove, track, isNested])
|
||||
}, [showRemove, onRemove, track, isNested, offlineAudio])
|
||||
|
||||
// Memoize text color to prevent recalculation
|
||||
const textColor = useMemo(() => {
|
||||
if (isPlaying) return theme.primary.val
|
||||
if (isOffline) return isDownloaded ? theme.color : theme.neutral.val
|
||||
if (isOffline) return offlineAudio ? theme.color : theme.neutral.val
|
||||
return theme.color
|
||||
}, [isPlaying, isOffline, isDownloaded, theme.primary.val, theme.color, theme.neutral.val])
|
||||
}, [isPlaying, isOffline, offlineAudio, theme.primary.val, theme.color, theme.neutral.val])
|
||||
|
||||
// Memoize artists text
|
||||
const artistsText = useMemo(() => track.Artists?.join(', ') ?? '', [track.Artists])
|
||||
|
||||
@@ -12,15 +12,19 @@ import HomeStackParamList from '../../../screens/Home/types'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { RootStackParamList } from '../../../screens/types'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import { useDownloadQualityContext, useStreamingQualityContext } from '../../../providers/Settings'
|
||||
import { useDownloadQualityContext } from '../../../providers/Settings'
|
||||
import { useNetworkContext } from '../../../providers/Network'
|
||||
import useStreamingDeviceProfile from '../../../stores/device-profile'
|
||||
import { useAllDownloadedTracks } from '../../../api/queries/download'
|
||||
|
||||
export default function FrequentlyPlayedTracks(): React.JSX.Element {
|
||||
const { api } = useJellifyContext()
|
||||
|
||||
const { networkStatus, downloadedTracks } = useNetworkContext()
|
||||
const { networkStatus } = useNetworkContext()
|
||||
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
const { data: downloadedTracks } = useAllDownloadedTracks()
|
||||
|
||||
const deviceProfile = useStreamingDeviceProfile()
|
||||
|
||||
const downloadQuality = useDownloadQualityContext()
|
||||
|
||||
@@ -71,7 +75,7 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
|
||||
onPress={() => {
|
||||
loadNewQueue({
|
||||
api,
|
||||
streamingQuality,
|
||||
deviceProfile,
|
||||
downloadQuality,
|
||||
downloadedTracks,
|
||||
networkStatus,
|
||||
|
||||
@@ -15,14 +15,18 @@ import HomeStackParamList from '../../../screens/Home/types'
|
||||
import { useNowPlaying } from '../../../providers/Player/hooks/queries'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import { useNetworkContext } from '../../../providers/Network'
|
||||
import { useDownloadQualityContext, useStreamingQualityContext } from '../../../providers/Settings'
|
||||
import { useDownloadQualityContext } from '../../../providers/Settings'
|
||||
import useStreamingDeviceProfile from '../../../stores/device-profile'
|
||||
import { useAllDownloadedTracks } from '../../../api/queries/download'
|
||||
|
||||
export default function RecentlyPlayed(): React.JSX.Element {
|
||||
const { api } = useJellifyContext()
|
||||
|
||||
const { downloadedTracks, networkStatus } = useNetworkContext()
|
||||
const { networkStatus } = useNetworkContext()
|
||||
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
const { data: downloadedTracks } = useAllDownloadedTracks()
|
||||
|
||||
const deviceProfile = useStreamingDeviceProfile()
|
||||
|
||||
const downloadQuality = useDownloadQualityContext()
|
||||
|
||||
@@ -73,7 +77,7 @@ export default function RecentlyPlayed(): React.JSX.Element {
|
||||
loadNewQueue({
|
||||
api,
|
||||
downloadedTracks,
|
||||
streamingQuality,
|
||||
deviceProfile,
|
||||
networkStatus,
|
||||
downloadQuality,
|
||||
track: recentlyPlayedTrack,
|
||||
|
||||
@@ -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
|
||||
@@ -15,7 +15,7 @@ import { useNowPlaying } from '../../../providers/Player/hooks/queries'
|
||||
import { useActiveTrack } from 'react-native-track-player'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import { useEffect } from 'react'
|
||||
import usePlayerEngineStore, { PlayerEngine } from '../../../zustand/engineStore'
|
||||
import usePlayerEngineStore, { PlayerEngine } from '../../../stores/player-engine'
|
||||
|
||||
export default function Footer(): React.JSX.Element {
|
||||
const navigation = useNavigation<NativeStackNavigationProp<PlayerParamList>>()
|
||||
|
||||
@@ -38,17 +38,17 @@ export default function PlayerHeader(): React.JSX.Element {
|
||||
color={theme.color.val}
|
||||
name={Platform.OS === 'android' ? 'chevron-left' : 'chevron-down'}
|
||||
size={22}
|
||||
style={{ flex: 1, margin: 'auto' }}
|
||||
style={{ marginVertical: 'auto', width: 22 }}
|
||||
/>
|
||||
|
||||
<YStack alignItems='center' flex={1}>
|
||||
<YStack alignItems='center' flexGrow={1}>
|
||||
<Text>Playing from</Text>
|
||||
<Text bold numberOfLines={1} lineBreakStrategyIOS='standard'>
|
||||
{playingFrom}
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<Spacer flex={1} />
|
||||
<Spacer width={22} />
|
||||
</XStack>
|
||||
|
||||
<YStack
|
||||
@@ -57,7 +57,6 @@ export default function PlayerHeader(): React.JSX.Element {
|
||||
paddingHorizontal={'$2'}
|
||||
maxHeight={'70%'}
|
||||
marginVertical={'auto'}
|
||||
paddingVertical={Platform.OS === 'android' ? '$4' : '$2'}
|
||||
>
|
||||
<Animated.View
|
||||
entering={FadeIn}
|
||||
|
||||
62
src/components/Player/components/quality-badge.tsx
Normal file
62
src/components/Player/components/quality-badge.tsx
Normal 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
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import { UPDATE_INTERVAL } from '../../../player/config'
|
||||
import { ProgressMultiplier } from '../component.config'
|
||||
import { useReducedHapticsContext } from '../../../providers/Settings'
|
||||
import { useNowPlaying, useProgress } from '../../../providers/Player/hooks/queries'
|
||||
import QualityBadge from './quality-badge'
|
||||
import { useDisplayAudioQualityBadge } from '../../../stores/player-settings'
|
||||
|
||||
// Create a simple pan gesture
|
||||
const scrubGesture = Gesture.Pan().runOnJS(true)
|
||||
@@ -21,7 +23,12 @@ export default function Scrubber(): React.JSX.Element {
|
||||
const reducedHaptics = useReducedHapticsContext()
|
||||
|
||||
// Get progress from the track player with the specified update interval
|
||||
const { position, duration } = useProgress(UPDATE_INTERVAL)
|
||||
// We *don't* use the duration from this hook because it will have a value of "0"
|
||||
// in the event we are transcoding a track...
|
||||
const { position } = useProgress(UPDATE_INTERVAL)
|
||||
|
||||
// ...instead we use the duration on the track object
|
||||
const { duration } = nowPlaying!
|
||||
|
||||
// Single source of truth for the current position
|
||||
const [displayPosition, setDisplayPosition] = useState<number>(0)
|
||||
@@ -32,6 +39,8 @@ export default function Scrubber(): React.JSX.Element {
|
||||
const currentTrackIdRef = useRef<string | null>(null)
|
||||
const lastPositionRef = useRef<number>(0)
|
||||
|
||||
const [displayAudioQualityBadge] = useDisplayAudioQualityBadge()
|
||||
|
||||
// Memoize expensive calculations
|
||||
const maxDuration = useMemo(() => {
|
||||
return Math.round(duration * ProgressMultiplier)
|
||||
@@ -147,16 +156,22 @@ export default function Scrubber(): React.JSX.Element {
|
||||
props={sliderProps}
|
||||
/>
|
||||
|
||||
<XStack paddingTop={'$2'}>
|
||||
<YStack alignItems='flex-start' flex={2}>
|
||||
<XStack alignItems='center' paddingTop={'$2'}>
|
||||
<YStack alignItems='flex-start' flexShrink={1}>
|
||||
<RunTimeSeconds alignment='left'>{currentSeconds}</RunTimeSeconds>
|
||||
</YStack>
|
||||
|
||||
<YStack alignItems='center' flex={1}>
|
||||
{/** Track metadata can go here */}
|
||||
<YStack alignItems='center' flexGrow={1}>
|
||||
{nowPlaying?.mediaSourceInfo && displayAudioQualityBadge && (
|
||||
<QualityBadge
|
||||
item={nowPlaying.item}
|
||||
sourceType={nowPlaying.sourceType}
|
||||
mediaSourceInfo={nowPlaying.mediaSourceInfo}
|
||||
/>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
<YStack alignItems='flex-end' flex={2}>
|
||||
<YStack alignItems='flex-end' flexShrink={1}>
|
||||
<RunTimeSeconds alignment='right'>{totalSeconds}</RunTimeSeconds>
|
||||
</YStack>
|
||||
</XStack>
|
||||
|
||||
@@ -81,7 +81,19 @@ export default function SongInfo(): React.JSX.Element {
|
||||
<XStack justifyContent='flex-end' alignItems='center' flexShrink={1} gap={'$3'}>
|
||||
<Icon
|
||||
name='dots-horizontal-circle-outline'
|
||||
onPress={() => navigationRef.navigate('Context', { item: nowPlaying!.item })}
|
||||
onPress={() =>
|
||||
navigationRef.navigate('Context', {
|
||||
item: nowPlaying!.item,
|
||||
streamingMediaSourceInfo:
|
||||
nowPlaying!.sourceType === 'stream'
|
||||
? nowPlaying!.mediaSourceInfo
|
||||
: undefined,
|
||||
downloadedMediaSourceInfo:
|
||||
nowPlaying!.sourceType === 'download'
|
||||
? nowPlaying!.mediaSourceInfo
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<FavoriteButton item={nowPlaying!.item} />
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function PlayerScreen(): React.JSX.Element {
|
||||
{/* flexGrow 1 */}
|
||||
<PlayerHeader />
|
||||
|
||||
<YStack justifyContent='flex-start' gap={'$4'} flexShrink={1}>
|
||||
<YStack justifyContent='flex-start' gap={'$5'} flexShrink={1}>
|
||||
<SongInfo />
|
||||
|
||||
<Scrubber />
|
||||
|
||||
@@ -166,8 +166,9 @@ export const Miniplayer = React.memo(function Miniplayer(): React.JSX.Element {
|
||||
})
|
||||
|
||||
function MiniPlayerRuntime(): React.JSX.Element {
|
||||
const progress = useProgress(UPDATE_INTERVAL)
|
||||
const { position } = useProgress(UPDATE_INTERVAL)
|
||||
const { data: nowPlaying } = useNowPlaying()
|
||||
const { duration } = nowPlaying!
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
@@ -178,7 +179,7 @@ function MiniPlayerRuntime(): React.JSX.Element {
|
||||
<XStack gap={'$1'} justifyContent='flex-start' height={'$1'}>
|
||||
<YStack justifyContent='center' marginRight={'$2'} paddingRight={'auto'}>
|
||||
<RunTimeSeconds alignment='left'>
|
||||
{Math.max(0, Math.floor(progress?.position ?? 0))}
|
||||
{Math.max(0, Math.floor(position))}
|
||||
</RunTimeSeconds>
|
||||
</YStack>
|
||||
|
||||
@@ -188,7 +189,7 @@ function MiniPlayerRuntime(): React.JSX.Element {
|
||||
|
||||
<YStack justifyContent='center' marginLeft={'$2'}>
|
||||
<RunTimeSeconds color={'$neutral'} alignment='right'>
|
||||
{Math.max(0, Math.floor(progress?.duration ?? 0))}
|
||||
{Math.max(0, Math.floor(duration))}
|
||||
</RunTimeSeconds>
|
||||
</YStack>
|
||||
</XStack>
|
||||
|
||||
@@ -15,10 +15,14 @@ import { useNetworkContext } from '../../../../src/providers/Network'
|
||||
import { ActivityIndicator } from 'react-native'
|
||||
import { mapDtoToTrack } from '../../../utils/mappings'
|
||||
import { QueuingType } from '../../../enums/queuing-type'
|
||||
import { useDownloadQualityContext, useStreamingQualityContext } from '../../../providers/Settings'
|
||||
import { useDownloadQualityContext } from '../../../providers/Settings'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import LibraryStackParamList from '@/src/screens/Library/types'
|
||||
import { useLoadNewQueue } from '../../../providers/Player/hooks/mutations'
|
||||
import useStreamingDeviceProfile, {
|
||||
useDownloadingDeviceProfile,
|
||||
} from '../../../stores/device-profile'
|
||||
import { useAllDownloadedTracks } from '../../../api/queries/download'
|
||||
|
||||
export default function PlayliistTracklistHeader(
|
||||
playlist: BaseItemDto,
|
||||
@@ -149,21 +153,24 @@ function PlaylistHeaderControls({
|
||||
}): React.JSX.Element {
|
||||
const { useDownloadMultiple, pendingDownloads } = useNetworkContext()
|
||||
const downloadQuality = useDownloadQualityContext()
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
const streamingDeviceProfile = useStreamingDeviceProfile()
|
||||
const downloadingDeviceProfile = useDownloadingDeviceProfile()
|
||||
const { mutate: loadNewQueue } = useLoadNewQueue()
|
||||
const isDownloading = pendingDownloads.length != 0
|
||||
const { api } = useJellifyContext()
|
||||
|
||||
const { networkStatus, downloadedTracks } = useNetworkContext()
|
||||
const { networkStatus } = useNetworkContext()
|
||||
|
||||
const { data: downloadedTracks } = useAllDownloadedTracks()
|
||||
|
||||
const navigation = useNavigation<NativeStackNavigationProp<LibraryStackParamList>>()
|
||||
|
||||
const downloadPlaylist = () => {
|
||||
if (!api) return
|
||||
const jellifyTracks = playlistTracks.map((item) =>
|
||||
mapDtoToTrack(api, item, [], undefined, downloadQuality, streamingQuality),
|
||||
mapDtoToTrack(api, item, [], downloadingDeviceProfile),
|
||||
)
|
||||
useDownloadMultiple.mutate(jellifyTracks)
|
||||
useDownloadMultiple(jellifyTracks)
|
||||
}
|
||||
|
||||
const playPlaylist = (shuffled: boolean = false) => {
|
||||
@@ -174,7 +181,7 @@ function PlaylistHeaderControls({
|
||||
downloadQuality,
|
||||
networkStatus,
|
||||
downloadedTracks,
|
||||
streamingQuality,
|
||||
deviceProfile: streamingDeviceProfile,
|
||||
track: playlistTracks[0],
|
||||
index: 0,
|
||||
tracklist: playlistTracks,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import { useRef } from 'react'
|
||||
import { warmItemContext } from '../../hooks/use-item-context'
|
||||
import { useJellifyContext } from '../../providers'
|
||||
import { useStreamingQualityContext } from '../../providers/Settings'
|
||||
import useStreamingDeviceProfile from '../../stores/device-profile'
|
||||
|
||||
export interface PlaylistsProps {
|
||||
canEdit?: boolean | undefined
|
||||
@@ -34,12 +34,12 @@ export default function Playlists({
|
||||
|
||||
const { api, user } = useJellifyContext()
|
||||
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
const deviceProfile = useStreamingDeviceProfile()
|
||||
|
||||
const onViewableItemsChangedRef = useRef(
|
||||
({ viewableItems }: { viewableItems: ViewToken<BaseItemDto>[] }) => {
|
||||
viewableItems.forEach(({ isViewable, item }) => {
|
||||
if (isViewable) warmItemContext(api, user, item, streamingQuality)
|
||||
if (isViewable) warmItemContext(api, user, item, deviceProfile)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2,25 +2,30 @@ import SettingsListGroup from './settings-list-group'
|
||||
import { RadioGroup, YStack } from 'tamagui'
|
||||
import { RadioGroupItemWithLabel } from '../../Global/helpers/radio-group-item-with-label'
|
||||
import { Text } from '../../Global/helpers/text'
|
||||
import { getQualityLabel, getBandwidthEstimate } from '../utils/quality'
|
||||
import {
|
||||
StreamingQuality,
|
||||
useSetStreamingQualityContext,
|
||||
useStreamingQualityContext,
|
||||
} from '../../../providers/Settings'
|
||||
import useStreamingDeviceProfile from '../../../stores/device-profile'
|
||||
import { useDisplayAudioQualityBadge } from '../../../stores/player-settings'
|
||||
import { SwitchWithLabel } from '../../Global/helpers/switch-with-label'
|
||||
|
||||
export default function PlaybackTab(): React.JSX.Element {
|
||||
const deviceProfile = useStreamingDeviceProfile()
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
const setStreamingQuality = useSetStreamingQualityContext()
|
||||
|
||||
const [displayAudioQualityBadge, setDisplayAudioQualityBadge] = useDisplayAudioQualityBadge()
|
||||
|
||||
return (
|
||||
<SettingsListGroup
|
||||
settingsList={[
|
||||
{
|
||||
title: 'Streaming Quality',
|
||||
subTitle: `Current: ${getQualityLabel(streamingQuality)} • ${getBandwidthEstimate(streamingQuality)}`,
|
||||
iconName: 'sine-wave',
|
||||
iconColor: getStreamingQualityIconColor(streamingQuality),
|
||||
subTitle: `${deviceProfile?.Name ?? 'Not set'}`,
|
||||
iconName: 'radio-tower',
|
||||
iconColor: '$borderColor',
|
||||
children: (
|
||||
<YStack gap='$2' paddingVertical='$2'>
|
||||
<Text fontSize='$3' marginBottom='$2'>
|
||||
@@ -56,6 +61,20 @@ export default function PlaybackTab(): React.JSX.Element {
|
||||
</YStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Show Audio Quality Badge',
|
||||
subTitle: 'Displays audio quality in the player',
|
||||
iconName: 'sine-wave',
|
||||
iconColor: '$borderColor',
|
||||
children: (
|
||||
<SwitchWithLabel
|
||||
onCheckedChange={setDisplayAudioQualityBadge}
|
||||
size={'$2'}
|
||||
checked={displayAudioQualityBadge}
|
||||
label={displayAudioQualityBadge ? 'Enabled' : 'Disabled'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -12,13 +12,14 @@ import { useNetworkContext } from '../../../providers/Network'
|
||||
import { RadioGroup, YStack } from 'tamagui'
|
||||
import { Text } from '../../Global/helpers/text'
|
||||
import { getQualityLabel } from '../utils/quality'
|
||||
import { useAllDownloadedTracks } from '../../../api/queries/download'
|
||||
export default function StorageTab(): React.JSX.Element {
|
||||
const autoDownload = useAutoDownloadContext()
|
||||
const setAutoDownload = useSetAutoDownloadContext()
|
||||
const downloadQuality = useDownloadQualityContext()
|
||||
const setDownloadQuality = useSetDownloadQualityContext()
|
||||
|
||||
const { downloadedTracks } = useNetworkContext()
|
||||
const { data: downloadedTracks } = useAllDownloadedTracks()
|
||||
|
||||
return (
|
||||
<SettingsListGroup
|
||||
|
||||
@@ -2,11 +2,12 @@ import React, { useEffect, useState } from 'react'
|
||||
import { StyleSheet, Pressable, Alert, FlatList } from 'react-native'
|
||||
import RNFS from 'react-native-fs'
|
||||
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'
|
||||
import { deleteAudioCache } from '../../components/Network/offlineModeUtils'
|
||||
import { deleteAudioCache } from '../../api/mutations/download/offlineModeUtils'
|
||||
import { useNetworkContext } from '../../providers/Network'
|
||||
import Icon from '../Global/components/icon'
|
||||
import { getToken, View } from 'tamagui'
|
||||
import { Text } from '../Global/helpers/text'
|
||||
import { useAllDownloadedTracks } from '../../api/queries/download'
|
||||
|
||||
// 🔹 Single Download Item with animated progress bar
|
||||
function DownloadItem({
|
||||
@@ -43,7 +44,9 @@ export default function StorageBar(): React.JSX.Element {
|
||||
const [used, setUsed] = useState(0)
|
||||
const [total, setTotal] = useState(1)
|
||||
|
||||
const { downloadedTracks, activeDownloads: activeDownloadsArray } = useNetworkContext()
|
||||
const { activeDownloads: activeDownloadsArray } = useNetworkContext()
|
||||
|
||||
const { data: downloadedTracks } = useAllDownloadedTracks()
|
||||
|
||||
const usageShared = useSharedValue(0)
|
||||
const percentUsed = used / total
|
||||
|
||||
@@ -8,10 +8,11 @@ import { queryClient } from '../../constants/query-client'
|
||||
import { QueryKeys } from '../../enums/query-keys'
|
||||
import { FlashList, ViewToken } from '@shopify/flash-list'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import { BaseStackParamList } from '@/src/screens/types'
|
||||
import { BaseStackParamList } from '../../screens/types'
|
||||
import { warmItemContext } from '../../hooks/use-item-context'
|
||||
import { useJellifyContext } from '../../providers'
|
||||
import { useStreamingQualityContext } from '../../providers/Settings'
|
||||
import useStreamingDeviceProfile from '../../stores/device-profile'
|
||||
import { useAllDownloadedTracks } from '../../api/queries/download'
|
||||
|
||||
interface TracksProps {
|
||||
tracks: (string | number | BaseItemDto)[] | undefined
|
||||
@@ -34,8 +35,8 @@ export default function Tracks({
|
||||
}: TracksProps): React.JSX.Element {
|
||||
const { api, user } = useJellifyContext()
|
||||
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
const { downloadedTracks } = useNetworkContext()
|
||||
const deviceProfile = useStreamingDeviceProfile()
|
||||
const { data: downloadedTracks } = useAllDownloadedTracks()
|
||||
|
||||
// Memoize the expensive tracks processing to prevent memory leaks
|
||||
const tracksToDisplay = React.useMemo(() => {
|
||||
@@ -82,7 +83,7 @@ export default function Tracks({
|
||||
const onViewableItemsChangedRef = useRef(
|
||||
({ viewableItems }: { viewableItems: ViewToken<BaseItemDto>[] }) => {
|
||||
viewableItems.forEach(({ isViewable, item }) => {
|
||||
if (isViewable) warmItemContext(api, user, item, streamingQuality)
|
||||
if (isViewable) warmItemContext(api, user, item, deviceProfile)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ import Toast from 'react-native-toast-message'
|
||||
import JellifyToastConfig from '../constants/toast.config'
|
||||
import { useColorScheme } from 'react-native'
|
||||
import { CarPlayProvider } from '../providers/CarPlay'
|
||||
import { useSelectPlayerEngine } from '../zustand/engineStore'
|
||||
import { useSelectPlayerEngine } from '../stores/player-engine'
|
||||
/**
|
||||
* The main component for the Jellify app. Children are wrapped in the {@link JellifyProvider}
|
||||
* @returns The {@link Jellify} component
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { BaseItemDto, BaseItemKind, DeviceProfile } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { JellifyUser } from '../types/JellifyUser'
|
||||
import { Api } from '@jellyfin/sdk'
|
||||
import { queryClient } from '../constants/query-client'
|
||||
import { QueryKeys } from '../enums/query-keys'
|
||||
import { getQualityParams } from '../utils/mappings'
|
||||
import { fetchMediaInfo } from '../api/queries/media'
|
||||
import { StreamingQuality, useStreamingQualityContext } from '../providers/Settings'
|
||||
import { fetchMediaInfo } from '../api/queries/media/utils'
|
||||
import { fetchAlbumDiscs, fetchItem } from '../api/queries/item'
|
||||
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
|
||||
import { fetchUserData } from '../api/queries/favorites'
|
||||
import { useJellifyContext } from '../providers'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../stores/device-profile'
|
||||
|
||||
export default function useItemContext(item: BaseItemDto): void {
|
||||
const { api, user } = useJellifyContext()
|
||||
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
const streamingDeviceProfile = useStreamingDeviceProfile()
|
||||
|
||||
const downloadingDeviceProfile = useDownloadingDeviceProfile()
|
||||
|
||||
const prefetchedContext = useRef<Set<string>>(new Set())
|
||||
|
||||
@@ -28,15 +29,16 @@ export default function useItemContext(item: BaseItemDto): void {
|
||||
// Mark this item's context as warmed, preventing reruns
|
||||
prefetchedContext.current.add(effectSig)
|
||||
|
||||
warmItemContext(api, user, item, streamingQuality)
|
||||
}, [api, user, streamingQuality])
|
||||
warmItemContext(api, user, item, streamingDeviceProfile, downloadingDeviceProfile)
|
||||
}, [api, user, streamingDeviceProfile])
|
||||
}
|
||||
|
||||
export function warmItemContext(
|
||||
api: Api | undefined,
|
||||
user: JellifyUser | undefined,
|
||||
item: BaseItemDto,
|
||||
streamingQuality: StreamingQuality,
|
||||
streamingDeviceProfile: DeviceProfile | undefined,
|
||||
downloadingDeviceProfile?: DeviceProfile | undefined,
|
||||
): void {
|
||||
const { Id, Type, AlbumId, UserData } = item
|
||||
|
||||
@@ -45,7 +47,8 @@ export function warmItemContext(
|
||||
|
||||
console.debug(`Warming context query cache for item ${Id}`)
|
||||
|
||||
if (Type === BaseItemKind.Audio) warmTrackContext(api, user, item, streamingQuality)
|
||||
if (Type === BaseItemKind.Audio)
|
||||
warmTrackContext(api, user, item, streamingDeviceProfile, downloadingDeviceProfile)
|
||||
|
||||
if (Type === BaseItemKind.MusicArtist)
|
||||
queryClient.setQueryData([QueryKeys.ArtistById, Id], item)
|
||||
@@ -117,16 +120,29 @@ function warmTrackContext(
|
||||
api: Api | undefined,
|
||||
user: JellifyUser | undefined,
|
||||
track: BaseItemDto,
|
||||
streamingQuality: StreamingQuality,
|
||||
streamingDeviceProfile: DeviceProfile | undefined,
|
||||
downloadingDeviceProfile: DeviceProfile | undefined,
|
||||
): void {
|
||||
const { Id, AlbumId, ArtistItems } = track
|
||||
|
||||
const mediaSourcesQueryKey = [QueryKeys.MediaSources, streamingQuality, Id]
|
||||
const streamingMediaSourceQueryKey = [QueryKeys.MediaSources, streamingDeviceProfile?.Name, Id]
|
||||
|
||||
if (queryClient.getQueryState(mediaSourcesQueryKey)?.status !== 'success')
|
||||
if (queryClient.getQueryState(streamingMediaSourceQueryKey)?.status !== 'success')
|
||||
queryClient.ensureQueryData({
|
||||
queryKey: mediaSourcesQueryKey,
|
||||
queryFn: () => fetchMediaInfo(api, user, getQualityParams(streamingQuality), Id!),
|
||||
queryKey: streamingMediaSourceQueryKey,
|
||||
queryFn: () => fetchMediaInfo(api, user, streamingDeviceProfile, Id!),
|
||||
})
|
||||
|
||||
const downloadedMediaSourceQueryKey = [
|
||||
QueryKeys.MediaSources,
|
||||
downloadingDeviceProfile?.Name,
|
||||
Id,
|
||||
]
|
||||
|
||||
if (queryClient.getQueryState(downloadedMediaSourceQueryKey)?.status !== 'success')
|
||||
queryClient.ensureQueryData({
|
||||
queryKey: downloadedMediaSourceQueryKey,
|
||||
queryFn: () => fetchMediaInfo(api, user, downloadingDeviceProfile, track.Id),
|
||||
})
|
||||
|
||||
const albumQueryKey = [QueryKeys.Album, AlbumId]
|
||||
|
||||
@@ -5,7 +5,9 @@ import { CarPlay } from 'react-native-carplay'
|
||||
import { useJellifyContext } from '../index'
|
||||
import { useLoadNewQueue } from '../Player/hooks/mutations'
|
||||
import { useNetworkContext } from '../Network'
|
||||
import { useDownloadQualityContext, useStreamingQualityContext } from '../Settings'
|
||||
import { useDownloadQualityContext } from '../Settings'
|
||||
import useStreamingDeviceProfile from '../../stores/device-profile'
|
||||
import { useAllDownloadedTracks } from '../../api/queries/download'
|
||||
|
||||
interface CarPlayContext {
|
||||
carplayConnected: boolean
|
||||
@@ -15,9 +17,11 @@ const CarPlayContextInitializer = () => {
|
||||
const { api, library } = useJellifyContext()
|
||||
const [carplayConnected, setCarPlayConnected] = useState(CarPlay ? CarPlay.connected : false)
|
||||
|
||||
const { networkStatus, downloadedTracks } = useNetworkContext()
|
||||
const { networkStatus } = useNetworkContext()
|
||||
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
const { data: downloadedTracks } = useAllDownloadedTracks()
|
||||
|
||||
const deviceProfile = useStreamingDeviceProfile()
|
||||
const downloadQuality = useDownloadQualityContext()
|
||||
|
||||
const { mutate: loadNewQueue } = useLoadNewQueue()
|
||||
@@ -34,7 +38,7 @@ const CarPlayContextInitializer = () => {
|
||||
api,
|
||||
downloadedTracks,
|
||||
networkStatus,
|
||||
streamingQuality,
|
||||
deviceProfile,
|
||||
downloadQuality,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { BaseItemDto, ItemSortBy, SortOrder } from '@jellyfin/sdk/lib/generated-
|
||||
import { useJellifyContext } from '..'
|
||||
import { fetchArtists } from '../../api/queries/artist'
|
||||
import { RefObject, useMemo, useRef } from 'react'
|
||||
import QueryConfig from '../../api/queries/query.config'
|
||||
import QueryConfig, { ApiLimits } from '../../api/queries/query.config'
|
||||
import { fetchTracks } from '../../api/queries/tracks'
|
||||
import { fetchAlbums } from '../../api/queries/album'
|
||||
import { useLibrarySortAndFilterContext } from './sorting-filtering'
|
||||
@@ -101,7 +101,7 @@ const LibraryContextInitializer = () => {
|
||||
select: selectArtists,
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
|
||||
return lastPage.length === QueryConfig.limits.library ? lastPageParam + 1 : undefined
|
||||
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
|
||||
},
|
||||
getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => {
|
||||
return firstPageParam === 0 ? null : firstPageParam - 1
|
||||
|
||||
@@ -1,40 +1,24 @@
|
||||
import React, { createContext, ReactNode, useContext, useEffect, useState, useMemo } from 'react'
|
||||
import { JellifyDownload, JellifyDownloadProgress } from '../../types/JellifyDownload'
|
||||
import { useMutation, UseMutationResult, useQuery } from '@tanstack/react-query'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { mapDtoToTrack } from '../../utils/mappings'
|
||||
import { deleteAudio, getAudioCache, saveAudio } from '../../components/Network/offlineModeUtils'
|
||||
import { QueryKeys } from '../../enums/query-keys'
|
||||
import { JellifyDownloadProgress } from '../../types/JellifyDownload'
|
||||
import { UseMutateFunction, useMutation } from '@tanstack/react-query'
|
||||
import { saveAudio } from '../../api/mutations/download/offlineModeUtils'
|
||||
import { networkStatusTypes } from '../../components/Network/internetConnectionWatcher'
|
||||
import { useJellifyContext } from '..'
|
||||
import { useDownloadQualityContext, useStreamingQualityContext } from '../Settings'
|
||||
import { isUndefined } from 'lodash'
|
||||
import RNFS from 'react-native-fs'
|
||||
import { JellifyStorage } from './types'
|
||||
import JellifyTrack from '../../types/JellifyTrack'
|
||||
import { useAllDownloadedTracks } from '../../api/queries/download'
|
||||
|
||||
interface NetworkContext {
|
||||
useDownload: UseMutationResult<boolean | void, Error, BaseItemDto, unknown>
|
||||
useRemoveDownload: UseMutationResult<void, Error, BaseItemDto, unknown>
|
||||
storageUsage: JellifyStorage | undefined
|
||||
downloadedTracks: JellifyDownload[] | undefined
|
||||
useDownloadMultiple: UseMutateFunction<boolean, Error, JellifyTrack[], unknown>
|
||||
activeDownloads: JellifyDownloadProgress | undefined
|
||||
networkStatus: networkStatusTypes | null
|
||||
setNetworkStatus: (status: networkStatusTypes | null) => void
|
||||
useDownloadMultiple: UseMutationResult<boolean, Error, JellifyTrack[], unknown>
|
||||
pendingDownloads: JellifyTrack[]
|
||||
downloadingDownloads: JellifyTrack[]
|
||||
completedDownloads: JellifyTrack[]
|
||||
failedDownloads: JellifyTrack[]
|
||||
clearDownloads: () => void
|
||||
}
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 1
|
||||
const NetworkContextInitializer = () => {
|
||||
const { api, sessionId } = useJellifyContext()
|
||||
const downloadQuality = useDownloadQualityContext()
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
|
||||
const [downloadProgress, setDownloadProgress] = useState<JellifyDownloadProgress>({})
|
||||
const [networkStatus, setNetworkStatus] = useState<networkStatusTypes | null>(null)
|
||||
|
||||
@@ -44,21 +28,7 @@ const NetworkContextInitializer = () => {
|
||||
const [completed, setCompleted] = useState<JellifyTrack[]>([])
|
||||
const [failed, setFailed] = useState<JellifyTrack[]>([])
|
||||
|
||||
const fetchStorageInUse: () => Promise<JellifyStorage> = async () => {
|
||||
const totalStorage = await RNFS.getFSInfo()
|
||||
const storageInUse = await RNFS.stat(RNFS.DocumentDirectoryPath)
|
||||
|
||||
return {
|
||||
totalStorage: totalStorage.totalSpace,
|
||||
freeSpace: totalStorage.freeSpace,
|
||||
storageInUseByJellify: storageInUse.size,
|
||||
}
|
||||
}
|
||||
const { data: downloadedTracks, refetch: refetchDownloadedTracks } = useQuery({
|
||||
queryKey: [QueryKeys.AudioCache],
|
||||
queryFn: getAudioCache,
|
||||
staleTime: Infinity, // Never stale, we will manually refetch when downloads are completed
|
||||
})
|
||||
const { data: downloadedTracks, refetch: refetchDownloadedTracks } = useAllDownloadedTracks()
|
||||
|
||||
useEffect(() => {
|
||||
if (pending.length > 0 && downloading.length < MAX_CONCURRENT_DOWNLOADS) {
|
||||
@@ -89,59 +59,12 @@ const NetworkContextInitializer = () => {
|
||||
}
|
||||
}, [pending, downloading])
|
||||
|
||||
const useDownload = useMutation({
|
||||
mutationFn: (trackItem: BaseItemDto) => {
|
||||
if (isUndefined(api)) throw new Error('API client not initialized')
|
||||
|
||||
const track = mapDtoToTrack(
|
||||
api,
|
||||
trackItem,
|
||||
[],
|
||||
undefined,
|
||||
downloadQuality,
|
||||
streamingQuality,
|
||||
)
|
||||
|
||||
return saveAudio(track, setDownloadProgress, false)
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
console.debug(`Downloaded ${variables.Id} successfully`)
|
||||
refetchDownloadedTracks()
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: storageUsage } = useQuery({
|
||||
queryKey: [QueryKeys.StorageInUse],
|
||||
queryFn: () => fetchStorageInUse(),
|
||||
})
|
||||
|
||||
const { mutate: clearDownloads } = useMutation({
|
||||
mutationFn: async () => {
|
||||
return downloadedTracks?.forEach((track) => {
|
||||
deleteAudio(track.item)
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetchDownloadedTracks()
|
||||
},
|
||||
})
|
||||
|
||||
const useRemoveDownload = useMutation({
|
||||
mutationFn: (trackItem: BaseItemDto) => deleteAudio(trackItem),
|
||||
onSuccess: (data, { Id }) => {
|
||||
console.debug(`Removed ${Id} from storage`)
|
||||
|
||||
refetchDownloadedTracks()
|
||||
},
|
||||
})
|
||||
|
||||
const addToQueue = async (items: JellifyTrack[]) => {
|
||||
setPending((prev) => [...prev, ...items])
|
||||
return true
|
||||
}
|
||||
|
||||
const useDownloadMultiple = useMutation({
|
||||
const { mutate: useDownloadMultiple } = useMutation({
|
||||
mutationFn: (tracks: JellifyTrack[]) => {
|
||||
return addToQueue(tracks)
|
||||
},
|
||||
@@ -151,89 +74,27 @@ const NetworkContextInitializer = () => {
|
||||
})
|
||||
|
||||
return {
|
||||
useDownload,
|
||||
useRemoveDownload,
|
||||
activeDownloads: downloadProgress,
|
||||
downloadedTracks,
|
||||
networkStatus,
|
||||
setNetworkStatus,
|
||||
storageUsage,
|
||||
useDownloadMultiple,
|
||||
pendingDownloads: pending,
|
||||
downloadingDownloads: downloading,
|
||||
completedDownloads: completed,
|
||||
failedDownloads: failed,
|
||||
clearDownloads,
|
||||
}
|
||||
}
|
||||
|
||||
const NetworkContext = createContext<NetworkContext>({
|
||||
useDownload: {
|
||||
mutate: () => {},
|
||||
mutateAsync: async () => {},
|
||||
data: undefined,
|
||||
error: null,
|
||||
variables: undefined,
|
||||
isError: false,
|
||||
isIdle: true,
|
||||
isPaused: false,
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
status: 'idle',
|
||||
reset: () => {},
|
||||
context: {},
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
submittedAt: 0,
|
||||
},
|
||||
useRemoveDownload: {
|
||||
mutate: () => {},
|
||||
mutateAsync: async () => {},
|
||||
data: undefined,
|
||||
error: null,
|
||||
variables: undefined,
|
||||
isError: false,
|
||||
isIdle: true,
|
||||
isPaused: false,
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
status: 'idle',
|
||||
reset: () => {},
|
||||
context: {},
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
submittedAt: 0,
|
||||
},
|
||||
downloadedTracks: [],
|
||||
activeDownloads: {},
|
||||
networkStatus: networkStatusTypes.ONLINE,
|
||||
setNetworkStatus: () => {},
|
||||
storageUsage: undefined,
|
||||
useDownloadMultiple: {
|
||||
mutate: () => {},
|
||||
mutateAsync: async () => {
|
||||
return true
|
||||
},
|
||||
data: undefined,
|
||||
error: null,
|
||||
variables: undefined,
|
||||
isError: false,
|
||||
isIdle: true,
|
||||
isPaused: false,
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
status: 'idle',
|
||||
reset: () => {},
|
||||
context: {},
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
submittedAt: 0,
|
||||
},
|
||||
useDownloadMultiple: () => {},
|
||||
pendingDownloads: [],
|
||||
downloadingDownloads: [],
|
||||
completedDownloads: [],
|
||||
failedDownloads: [],
|
||||
clearDownloads: () => {},
|
||||
})
|
||||
|
||||
export const NetworkContextProvider: ({
|
||||
@@ -249,7 +110,6 @@ export const NetworkContextProvider: ({
|
||||
[
|
||||
context.downloadedTracks?.length,
|
||||
context.networkStatus,
|
||||
context.storageUsage,
|
||||
context.pendingDownloads.length,
|
||||
context.downloadingDownloads.length,
|
||||
context.completedDownloads.length,
|
||||
|
||||
5
src/providers/Network/types.d.ts
vendored
5
src/providers/Network/types.d.ts
vendored
@@ -1,5 +0,0 @@
|
||||
export type JellifyStorage = {
|
||||
totalStorage: number
|
||||
freeSpace: number
|
||||
storageInUseByJellify: number
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { AddToQueueMutation, QueueMutation } from '../interfaces'
|
||||
import { QueuingType } from '../../../enums/queuing-type'
|
||||
import { shuffleJellifyTracks } from '../utils/shuffle'
|
||||
import TrackPlayer from 'react-native-track-player'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import Toast from 'react-native-toast-message'
|
||||
import { findPlayQueueIndexStart } from '../utils'
|
||||
import JellifyTrack from '@/src/types/JellifyTrack'
|
||||
@@ -13,13 +12,11 @@ import { setPlayQueue, setQueueRef, setShuffled, setUnshuffledQueue } from '.'
|
||||
|
||||
export async function loadQueue({
|
||||
index,
|
||||
track,
|
||||
tracklist,
|
||||
queue: queueRef,
|
||||
shuffled = false,
|
||||
api,
|
||||
downloadQuality,
|
||||
streamingQuality,
|
||||
deviceProfile,
|
||||
networkStatus = networkStatusTypes.ONLINE,
|
||||
downloadedTracks,
|
||||
}: QueueMutation) {
|
||||
@@ -43,9 +40,8 @@ export async function loadQueue({
|
||||
api!,
|
||||
item,
|
||||
downloadedTracks ?? [],
|
||||
deviceProfile!,
|
||||
QueuingType.FromSelection,
|
||||
downloadQuality,
|
||||
streamingQuality,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -111,21 +107,13 @@ export async function loadQueue({
|
||||
export const playNextInQueue = async ({
|
||||
api,
|
||||
downloadedTracks,
|
||||
downloadQuality,
|
||||
streamingQuality,
|
||||
deviceProfile,
|
||||
tracks,
|
||||
}: AddToQueueMutation) => {
|
||||
console.debug(`Playing item next in queue`)
|
||||
|
||||
const tracksToPlayNext = tracks.map((item) =>
|
||||
mapDtoToTrack(
|
||||
api!,
|
||||
item,
|
||||
downloadedTracks ?? [],
|
||||
QueuingType.PlayingNext,
|
||||
downloadQuality,
|
||||
streamingQuality,
|
||||
),
|
||||
mapDtoToTrack(api!, item, downloadedTracks ?? [], deviceProfile!, QueuingType.PlayingNext),
|
||||
)
|
||||
|
||||
const currentIndex = await TrackPlayer.getActiveTrackIndex()
|
||||
@@ -149,8 +137,7 @@ export const playNextInQueue = async ({
|
||||
|
||||
export const playInQueue = async ({
|
||||
api,
|
||||
downloadQuality,
|
||||
streamingQuality,
|
||||
deviceProfile,
|
||||
downloadedTracks,
|
||||
tracks,
|
||||
}: AddToQueueMutation) => {
|
||||
@@ -169,9 +156,8 @@ export const playInQueue = async ({
|
||||
api!,
|
||||
item,
|
||||
downloadedTracks ?? [],
|
||||
deviceProfile!,
|
||||
QueuingType.DirectlyQueued,
|
||||
downloadQuality,
|
||||
streamingQuality,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { handleDeshuffle, handleShuffle } from '../functions/shuffle'
|
||||
import JellifyTrack from '@/src/types/JellifyTrack'
|
||||
import calculateTrackVolume from '../utils/normalization'
|
||||
import { useNowPlaying, usePlaybackState } from './queries'
|
||||
import usePlayerEngineStore, { PlayerEngine } from '../../../zustand/engineStore'
|
||||
import usePlayerEngineStore, { PlayerEngine } from '../../../stores/player-engine'
|
||||
import { useRemoteMediaClient } from 'react-native-google-cast'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import { RootStackParamList } from '../../../screens/types'
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
QUEUE_QUERY,
|
||||
REPEAT_MODE_QUERY,
|
||||
} from '../constants/queries'
|
||||
import usePlayerEngineStore from '../../../zustand/engineStore'
|
||||
import { PlayerEngine } from '../../../zustand/engineStore'
|
||||
import usePlayerEngineStore from '../../../stores/player-engine'
|
||||
import { PlayerEngine } from '../../../stores/player-engine'
|
||||
import {
|
||||
MediaPlayerState,
|
||||
useMediaStatus,
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { createContext } from 'use-context-selector'
|
||||
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
|
||||
import { Event, useTrackPlayerEvents } from 'react-native-track-player'
|
||||
import { Event, State, useTrackPlayerEvents } from 'react-native-track-player'
|
||||
import { refetchNowPlaying } from './functions/queries'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useAudioNormalization, useInitialization } from './hooks/mutations'
|
||||
import { useCurrentIndex, useNowPlaying, useQueue } from './hooks/queries'
|
||||
import {
|
||||
cacheTrackIfConfigured,
|
||||
handlePlaybackProgress,
|
||||
handlePlaybackState,
|
||||
} from './utils/handlers'
|
||||
import { useJellifyContext } from '..'
|
||||
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api'
|
||||
import { handleActiveTrackChanged } from './functions'
|
||||
import { useAutoDownloadContext, useStreamingQualityContext } from '../Settings'
|
||||
import { useNetworkContext } from '../Network'
|
||||
import JellifyTrack from '@/src/types/JellifyTrack'
|
||||
import { useAutoDownloadContext } from '../Settings'
|
||||
import JellifyTrack from '../../types/JellifyTrack'
|
||||
import { useIsRestoring } from '@tanstack/react-query'
|
||||
import {
|
||||
useReportPlaybackProgress,
|
||||
useReportPlaybackStarted,
|
||||
useReportPlaybackStopped,
|
||||
} from '../../api/mutations/playback'
|
||||
import { useDownloadAudioItem } from '../../api/mutations/download'
|
||||
|
||||
const PLAYER_EVENTS: Event[] = [
|
||||
Event.PlaybackActiveTrackChanged,
|
||||
@@ -29,14 +27,7 @@ interface PlayerContext {}
|
||||
export const PlayerContext = createContext<PlayerContext>({})
|
||||
|
||||
export const PlayerProvider: () => React.JSX.Element = () => {
|
||||
const { api } = useJellifyContext()
|
||||
|
||||
const playStateApi = api ? getPlaystateApi(api) : undefined
|
||||
|
||||
const autoDownload = useAutoDownloadContext()
|
||||
const streamingQuality = useStreamingQualityContext()
|
||||
|
||||
const { downloadedTracks, networkStatus } = useNetworkContext()
|
||||
|
||||
usePerformanceMonitor('PlayerProvider', 3)
|
||||
|
||||
@@ -50,38 +41,58 @@ export const PlayerProvider: () => React.JSX.Element = () => {
|
||||
|
||||
const { mutate: normalizeAudioVolume } = useAudioNormalization()
|
||||
|
||||
const isRestoring = useIsRestoring()
|
||||
const { mutate: reportPlaybackStarted } = useReportPlaybackStarted()
|
||||
const { mutate: reportPlaybackProgress } = useReportPlaybackProgress()
|
||||
const { mutate: reportPlaybackStopped } = useReportPlaybackStopped()
|
||||
|
||||
const prefetchedTrackIds = useRef<Set<string>>(new Set())
|
||||
const [downloadProgress, downloadAudioItem] = useDownloadAudioItem()
|
||||
|
||||
const isRestoring = useIsRestoring()
|
||||
|
||||
useTrackPlayerEvents(PLAYER_EVENTS, (event) => {
|
||||
switch (event.type) {
|
||||
case Event.PlaybackActiveTrackChanged:
|
||||
if (event.track) normalizeAudioVolume(event.track as JellifyTrack)
|
||||
|
||||
handleActiveTrackChanged()
|
||||
refetchNowPlaying()
|
||||
|
||||
if (event.lastTrack)
|
||||
reportPlaybackStopped({
|
||||
track: event.lastTrack as JellifyTrack,
|
||||
lastPosition: event.lastPosition,
|
||||
duration: (event.lastTrack as JellifyTrack).duration,
|
||||
})
|
||||
break
|
||||
case Event.PlaybackProgressUpdated:
|
||||
handlePlaybackProgress(
|
||||
playStateApi,
|
||||
streamingQuality,
|
||||
event.duration,
|
||||
event.position,
|
||||
)
|
||||
cacheTrackIfConfigured(
|
||||
autoDownload,
|
||||
currentIndex,
|
||||
nowPlaying,
|
||||
playQueue,
|
||||
downloadedTracks,
|
||||
prefetchedTrackIds.current,
|
||||
networkStatus,
|
||||
event.position,
|
||||
event.duration,
|
||||
)
|
||||
console.debug(`Completion percentage: ${event.position / event.duration}`)
|
||||
if (nowPlaying)
|
||||
reportPlaybackProgress({
|
||||
track: nowPlaying,
|
||||
position: event.position,
|
||||
})
|
||||
|
||||
if (event.position / event.duration > 0.3 && autoDownload && nowPlaying)
|
||||
downloadAudioItem({ item: nowPlaying.item, autoCached: true })
|
||||
break
|
||||
case Event.PlaybackState:
|
||||
handlePlaybackState(playStateApi, streamingQuality, event.state)
|
||||
switch (event.state) {
|
||||
case State.Playing:
|
||||
if (nowPlaying)
|
||||
reportPlaybackStarted({
|
||||
track: nowPlaying,
|
||||
})
|
||||
break
|
||||
case State.Paused:
|
||||
case State.Stopped:
|
||||
case State.Ended:
|
||||
if (nowPlaying)
|
||||
reportPlaybackStopped({
|
||||
track: nowPlaying,
|
||||
lastPosition: 0,
|
||||
duration: nowPlaying.duration,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QueuingType } from '../../enums/queuing-type'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { BaseItemDto, DeviceProfile } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { Queue } from '../../player/types/queue-item'
|
||||
import { Api } from '@jellyfin/sdk'
|
||||
import { networkStatusTypes } from '../../components/Network/internetConnectionWatcher'
|
||||
@@ -25,7 +25,7 @@ export interface QueueMutation {
|
||||
|
||||
downloadQuality: DownloadQuality
|
||||
|
||||
streamingQuality: StreamingQuality
|
||||
deviceProfile: DeviceProfile | undefined
|
||||
|
||||
/**
|
||||
* The track that will be played first in the queue.
|
||||
@@ -80,7 +80,7 @@ export interface AddToQueueMutation {
|
||||
|
||||
downloadQuality: DownloadQuality
|
||||
|
||||
streamingQuality: StreamingQuality
|
||||
deviceProfile: DeviceProfile | undefined
|
||||
|
||||
/**
|
||||
* The tracks to add to the queue.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -23,6 +23,11 @@ const MIN_REDUCTION_DB = -10
|
||||
*
|
||||
* @param track - The track to calculate the normalization gain for.
|
||||
* @returns The normalization gain for the track.
|
||||
*
|
||||
* Audio Normalization in Jellify would not be possible without the help
|
||||
* of Chaphasilor - present maintainer and designer of Finamp.
|
||||
*
|
||||
* @see https://github.com/Chaphasilor
|
||||
*/
|
||||
export default function calculateTrackVolume(track: JellifyTrack): number {
|
||||
const { NormalizationGain } = track.item
|
||||
|
||||
@@ -3,6 +3,11 @@ import { storage } from '../../constants/storage'
|
||||
import { MMKVStorageKeys } from '../../enums/mmkv-storage-keys'
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { createContext, useContextSelector } from 'use-context-selector'
|
||||
import {
|
||||
useDownloadingDeviceProfileStore,
|
||||
useStreamingDeviceProfileStore,
|
||||
} from '../../stores/device-profile'
|
||||
import { getDeviceProfile } from './utils'
|
||||
|
||||
export type DownloadQuality = 'original' | 'high' | 'medium' | 'low'
|
||||
export type StreamingQuality = 'original' | 'high' | 'medium' | 'low'
|
||||
@@ -61,11 +66,11 @@ const SettingsContextInitializer = () => {
|
||||
const [devTools, setDevTools] = useState(false)
|
||||
|
||||
const [downloadQuality, setDownloadQuality] = useState<DownloadQuality>(
|
||||
downloadQualityInit ?? 'medium',
|
||||
downloadQualityInit ?? 'original',
|
||||
)
|
||||
|
||||
const [streamingQuality, setStreamingQuality] = useState<StreamingQuality>(
|
||||
streamingQualityInit ?? 'high',
|
||||
streamingQualityInit ?? 'original',
|
||||
)
|
||||
|
||||
const [reducedHaptics, setReducedHaptics] = useState(
|
||||
@@ -74,6 +79,13 @@ const SettingsContextInitializer = () => {
|
||||
|
||||
const [theme, setTheme] = useState<Theme>(themeInit ?? 'system')
|
||||
|
||||
const setStreamingDeviceProfile = useStreamingDeviceProfileStore(
|
||||
(state) => state.setDeviceProfile,
|
||||
)
|
||||
const setDownloadingDeviceProfile = useDownloadingDeviceProfileStore(
|
||||
(state) => state.setDeviceProfile,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
storage.set(MMKVStorageKeys.SendMetrics, sendMetrics)
|
||||
}, [sendMetrics])
|
||||
@@ -84,10 +96,14 @@ const SettingsContextInitializer = () => {
|
||||
|
||||
useEffect(() => {
|
||||
storage.set(MMKVStorageKeys.DownloadQuality, downloadQuality)
|
||||
|
||||
setDownloadingDeviceProfile(getDeviceProfile(downloadQuality, 'download'))
|
||||
}, [downloadQuality])
|
||||
|
||||
useEffect(() => {
|
||||
storage.set(MMKVStorageKeys.StreamingQuality, streamingQuality)
|
||||
|
||||
setStreamingDeviceProfile(getDeviceProfile(streamingQuality, 'stream'))
|
||||
}, [streamingQuality])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
260
src/providers/Settings/utils/index.ts
Normal file
260
src/providers/Settings/utils/index.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -15,9 +15,7 @@ import { storage } from '../constants/storage'
|
||||
import { MMKVStorageKeys } from '../enums/mmkv-storage-keys'
|
||||
import { Api } from '@jellyfin/sdk/lib/api'
|
||||
import { JellyfinInfo } from '../api/info'
|
||||
import uuid from 'react-native-uuid'
|
||||
import { queryClient } from '../constants/query-client'
|
||||
import { MediaInfoApi } from '@jellyfin/sdk/lib/generated-client/api'
|
||||
|
||||
/**
|
||||
* The context for the Jellify provider.
|
||||
@@ -48,11 +46,6 @@ interface JellifyContext {
|
||||
*/
|
||||
library: JellifyLibrary | undefined
|
||||
|
||||
/**
|
||||
* The ID for the current session.
|
||||
*/
|
||||
sessionId: string
|
||||
|
||||
/**
|
||||
* The function to set the context {@link JellifyServer}.
|
||||
*/
|
||||
@@ -81,14 +74,6 @@ const JellifyContextInitializer = () => {
|
||||
const libraryJson = storage.getString(MMKVStorageKeys.Library)
|
||||
const apiJson = storage.getString(MMKVStorageKeys.Api)
|
||||
|
||||
/**
|
||||
* TODO: This is not the correct way to generate a session ID.
|
||||
*
|
||||
* Per Niels, we should be using the {@link MediaInfoApi} to retrieve a
|
||||
* a server-side session ID stored on the {@link PlaybackInfoResponse}.
|
||||
*/
|
||||
const sessionId = uuid.v4()
|
||||
|
||||
const [api, setApi] = useState<Api | undefined>(apiJson ? JSON.parse(apiJson) : undefined)
|
||||
const [server, setServer] = useState<JellifyServer | undefined>(
|
||||
serverJson ? JSON.parse(serverJson) : undefined,
|
||||
@@ -147,7 +132,6 @@ const JellifyContextInitializer = () => {
|
||||
server,
|
||||
user,
|
||||
library,
|
||||
sessionId,
|
||||
setServer,
|
||||
setUser,
|
||||
setLibrary,
|
||||
@@ -161,7 +145,6 @@ const JellifyContext = createContext<JellifyContext>({
|
||||
server: undefined,
|
||||
user: undefined,
|
||||
library: undefined,
|
||||
sessionId: '',
|
||||
setServer: () => {},
|
||||
setUser: () => {},
|
||||
setLibrary: () => {},
|
||||
@@ -190,7 +173,6 @@ export const JellifyProvider: ({ children }: { children: ReactNode }) => React.J
|
||||
context.server?.url,
|
||||
context.user?.id,
|
||||
context.library?.musicLibraryId,
|
||||
context.sessionId,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ import { ContextProps } from '../types'
|
||||
export default function ItemContextScreen({ route, navigation }: ContextProps): React.JSX.Element {
|
||||
return (
|
||||
<ItemContext
|
||||
navigation={navigation}
|
||||
item={route.params.item}
|
||||
stackNavigation={route.params.navigation}
|
||||
navigation={navigation}
|
||||
navigationCallback={route.params.navigationCallback}
|
||||
streamingMediaSourceInfo={route.params.streamingMediaSourceInfo}
|
||||
downloadedMediaSourceInfo={route.params.downloadedMediaSourceInfo}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Queue from '../../components/Player/queue'
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack'
|
||||
import MultipleArtistsSheet from '../Context/multiple-artists'
|
||||
import { PlayerParamList } from './types'
|
||||
import AudioSpecsSheet from '../Stats'
|
||||
|
||||
export const PlayerStack = createNativeStackNavigator<PlayerParamList>()
|
||||
|
||||
|
||||
@@ -7,12 +7,13 @@ import { useJellifyContext } from '../../providers'
|
||||
import { useNetworkContext } from '../../providers/Network'
|
||||
import { useResetQueue } from '../../providers/Player/hooks/mutations'
|
||||
import navigationRef from '../../../navigation'
|
||||
import { useClearAllDownloads } from '../../api/mutations/download'
|
||||
|
||||
export default function SignOutModal({ navigation }: SignOutModalProps): React.JSX.Element {
|
||||
const { server } = useJellifyContext()
|
||||
|
||||
const { mutate: resetQueue } = useResetQueue()
|
||||
const { clearDownloads } = useNetworkContext()
|
||||
const clearDownloads = useClearAllDownloads()
|
||||
|
||||
return (
|
||||
<YStack margin={'$6'}>
|
||||
|
||||
6
src/screens/Stats/index.tsx
Normal file
6
src/screens/Stats/index.tsx
Normal 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} />
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import Player from './Player'
|
||||
import Player, { PlayerStack } from './Player'
|
||||
import Tabs from './Tabs'
|
||||
import { RootStackParamList } from './types'
|
||||
import { getToken, useTheme, YStack } from 'tamagui'
|
||||
@@ -13,6 +13,7 @@ import TextTicker from 'react-native-text-ticker'
|
||||
import { TextTickerConfig } from '../components/Player/component.config'
|
||||
import { Text } from '../components/Global/helpers/text'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import AudioSpecsSheet from './Stats'
|
||||
|
||||
const RootStack = createNativeStackNavigator<RootStackParamList>()
|
||||
|
||||
@@ -77,6 +78,17 @@ export default function Root(): React.JSX.Element {
|
||||
sheetGrabberVisible: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<RootStack.Screen
|
||||
name='AudioSpecs'
|
||||
component={AudioSpecsSheet}
|
||||
options={({ route }) => ({
|
||||
header: () => ContextSheetHeader(route.params.item),
|
||||
presentation: 'formSheet',
|
||||
sheetAllowedDetents: 'fitToContents',
|
||||
sheetGrabberVisible: true,
|
||||
})}
|
||||
/>
|
||||
</RootStack.Navigator>
|
||||
)
|
||||
}
|
||||
|
||||
11
src/screens/types.d.ts
vendored
11
src/screens/types.d.ts
vendored
@@ -1,5 +1,5 @@
|
||||
import { QueryKeys } from '../enums/query-keys'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { NativeStackNavigationProp, NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||
import { Queue } from '../player/types/queue-item'
|
||||
import { MaterialTopTabBarProps } from '@react-navigation/material-top-tabs'
|
||||
@@ -59,6 +59,8 @@ export type RootStackParamList = {
|
||||
|
||||
Context: {
|
||||
item: BaseItemDto
|
||||
streamingMediaSourceInfo?: MediaSourceInfo
|
||||
downloadedMediaSourceInfo?: MediaSourceInfo
|
||||
navigation?: Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
|
||||
navigationCallback?: (screen: 'Album' | 'Artist', item: BaseItemDto) => void
|
||||
}
|
||||
@@ -66,6 +68,12 @@ export type RootStackParamList = {
|
||||
AddToPlaylist: {
|
||||
track: BaseItemDto
|
||||
}
|
||||
|
||||
AudioSpecs: {
|
||||
item: BaseItemDto
|
||||
streamingMediaSourceInfo?: MediaSourceInfo
|
||||
downloadedMediaSourceInfo?: MediaSourceInfo
|
||||
}
|
||||
}
|
||||
|
||||
export type LoginProps = NativeStackNavigationProp<RootStackParamList, 'Login'>
|
||||
@@ -73,6 +81,7 @@ export type TabProps = NativeStackScreenProps<RootStackParamList, 'Tabs'>
|
||||
export type PlayerProps = NativeStackScreenProps<RootStackParamList, 'PlayerRoot'>
|
||||
export type ContextProps = NativeStackScreenProps<RootStackParamList, 'Context'>
|
||||
export type AddToPlaylistProps = NativeStackScreenProps<RootStackParamList, 'AddToPlaylist'>
|
||||
export type AudioSpecsProps = NativeStackScreenProps<RootStackParamList, 'AudioSpecs'>
|
||||
|
||||
export type ArtistsProps = {
|
||||
artistsInfiniteQuery: UseInfiniteQueryResult<
|
||||
|
||||
46
src/stores/device-profile.ts
Normal file
46
src/stores/device-profile.ts
Normal 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)
|
||||
}
|
||||
39
src/stores/player-settings.ts
Normal file
39
src/stores/player-settings.ts
Normal 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)
|
||||
0
src/stores/streaming-quality.ts
Normal file
0
src/stores/streaming-quality.ts
Normal file
@@ -1,21 +1,14 @@
|
||||
import { PitchAlgorithm, RatingType, Track, TrackType } from 'react-native-track-player'
|
||||
import { RatingType, Track } from 'react-native-track-player'
|
||||
import { QueuingType } from '../enums/queuing-type'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
|
||||
export type SourceType = 'stream' | 'download'
|
||||
|
||||
interface JellifyTrack extends Track {
|
||||
url: string
|
||||
type?: TrackType | undefined
|
||||
userAgent?: string | undefined
|
||||
contentType?: string | undefined
|
||||
pitchAlgorithm?: PitchAlgorithm | undefined
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
headers?: { [key: string]: any } | undefined
|
||||
|
||||
title?: string | undefined
|
||||
album?: string | undefined
|
||||
artist?: string | undefined
|
||||
duration?: number | undefined
|
||||
duration: number
|
||||
artwork?: string | undefined
|
||||
description?: string | undefined
|
||||
genre?: string | undefined
|
||||
@@ -23,7 +16,10 @@ interface JellifyTrack extends Track {
|
||||
rating?: RatingType | undefined
|
||||
isLiveStream?: boolean | undefined
|
||||
|
||||
sourceType: SourceType
|
||||
item: BaseItemDto
|
||||
sessionId: string | null | undefined
|
||||
mediaSourceInfo?: MediaSourceInfo
|
||||
|
||||
/**
|
||||
* Represents the type of queuing for this song, be it that it was
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import {
|
||||
BaseItemDto,
|
||||
DeviceProfile,
|
||||
ImageType,
|
||||
MediaSourceInfo,
|
||||
PlaybackInfoResponse,
|
||||
} from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import JellifyTrack from '../types/JellifyTrack'
|
||||
import TrackPlayer, { Track, TrackType } from 'react-native-track-player'
|
||||
import { QueuingType } from '../enums/queuing-type'
|
||||
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
|
||||
import { AudioApi, UniversalAudioApi } from '@jellyfin/sdk/lib/generated-client/api'
|
||||
import { AudioApi } from '@jellyfin/sdk/lib/generated-client/api'
|
||||
import { JellifyDownload } from '../types/JellifyDownload'
|
||||
import { Api } from '@jellyfin/sdk/lib/api'
|
||||
import RNFS from 'react-native-fs'
|
||||
@@ -17,21 +19,7 @@ import { queryClient } from '../constants/query-client'
|
||||
import { QueryKeys } from '../enums/query-keys'
|
||||
import { isUndefined } from 'lodash'
|
||||
import uuid from 'react-native-uuid'
|
||||
|
||||
/**
|
||||
* The container that the Jellyfin server will attempt to transcode to
|
||||
*
|
||||
* This is set to `ts` (MPEG-TS), as that is what HLS relies upon
|
||||
*
|
||||
* Finamp and Jellyfin Web also have this set to `ts`
|
||||
* @see https://jmshrv.com/posts/jellyfin-api/#playback-in-the-case-of-music
|
||||
*/
|
||||
const transcodingContainer = 'ts'
|
||||
|
||||
/*
|
||||
* The type of track to use for the player
|
||||
*/
|
||||
const type = TrackType.Default
|
||||
import { convertRunTimeTicksToSeconds } from './runtimeticks'
|
||||
|
||||
/**
|
||||
* Gets quality-specific parameters for transcoding
|
||||
@@ -68,6 +56,11 @@ export function getQualityParams(
|
||||
}
|
||||
}
|
||||
|
||||
type TrackMediaInfo = Pick<
|
||||
JellifyTrack,
|
||||
'url' | 'image' | 'duration' | 'item' | 'mediaSourceInfo' | 'sessionId' | 'sourceType' | 'type'
|
||||
>
|
||||
|
||||
/**
|
||||
* A mapper function that can be used to get a RNTP {@link Track} compliant object
|
||||
* from a Jellyfin server {@link BaseItemDto}. Applies a queuing type to the track
|
||||
@@ -84,41 +77,99 @@ export function mapDtoToTrack(
|
||||
api: Api,
|
||||
item: BaseItemDto,
|
||||
downloadedTracks: JellifyDownload[],
|
||||
deviceProfile: DeviceProfile,
|
||||
queuingType?: QueuingType,
|
||||
downloadQuality: DownloadQuality = 'medium',
|
||||
streamingQuality?: StreamingQuality | undefined,
|
||||
): JellifyTrack {
|
||||
const downloads = downloadedTracks.filter((download) => download.item.Id === item.Id)
|
||||
|
||||
let url: string
|
||||
let image: string | undefined
|
||||
const mediaInfo = queryClient.getQueryData([
|
||||
QueryKeys.MediaSources,
|
||||
deviceProfile?.Name,
|
||||
item.Id,
|
||||
]) as PlaybackInfoResponse | undefined
|
||||
|
||||
if (downloads.length > 0 && downloads[0].path) {
|
||||
url = `file://${RNFS.DocumentDirectoryPath}/${downloads[0].path.split('/').pop()}`
|
||||
image = `file://${RNFS.DocumentDirectoryPath}/${downloads[0].artwork?.split('/').pop()}`
|
||||
} else {
|
||||
url = buildAudioApiUrl(api, item, streamingQuality, downloadQuality)
|
||||
image = item.AlbumId
|
||||
? getImageApi(api).getItemImageUrlById(item.AlbumId, ImageType.Primary)
|
||||
: undefined
|
||||
}
|
||||
let trackMediaInfo: TrackMediaInfo
|
||||
|
||||
// Prioritize downloads over streaming to save bandwidth
|
||||
if (downloads.length > 0 && downloads[0].path)
|
||||
trackMediaInfo = buildDownloadedTrack(downloads[0])
|
||||
/**
|
||||
* Prioritize transcoding over direct play
|
||||
* so that unsupported codecs playback properly
|
||||
*
|
||||
* (i.e. ALAC audio on Android)
|
||||
*/ else if (mediaInfo?.MediaSources && mediaInfo.MediaSources[0].TranscodingUrl) {
|
||||
trackMediaInfo = buildTranscodedTrack(
|
||||
api,
|
||||
item,
|
||||
mediaInfo!.MediaSources![0],
|
||||
mediaInfo?.PlaySessionId,
|
||||
)
|
||||
} else
|
||||
trackMediaInfo = {
|
||||
url: buildAudioApiUrl(api, item, deviceProfile),
|
||||
image: item.AlbumId
|
||||
? getImageApi(api).getItemImageUrlById(item.AlbumId, ImageType.Primary)
|
||||
: undefined,
|
||||
duration: convertRunTimeTicksToSeconds(item.RunTimeTicks!),
|
||||
item,
|
||||
sessionId: mediaInfo?.PlaySessionId,
|
||||
mediaSourceInfo:
|
||||
mediaInfo && mediaInfo.MediaSources ? mediaInfo.MediaSources[0] : undefined,
|
||||
sourceType: 'stream',
|
||||
type: TrackType.Default,
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
type,
|
||||
headers: {
|
||||
'X-Emby-Token': api.accessToken,
|
||||
},
|
||||
...trackMediaInfo,
|
||||
title: item.Name,
|
||||
album: item.Album,
|
||||
artist: item.Artists?.join(' • '),
|
||||
duration: item.RunTimeTicks,
|
||||
artwork: image,
|
||||
item,
|
||||
artwork: trackMediaInfo.image,
|
||||
QueuingType: queuingType ?? QueuingType.DirectlyQueued,
|
||||
} as JellifyTrack
|
||||
}
|
||||
|
||||
function buildDownloadedTrack(downloadedTrack: JellifyDownload): TrackMediaInfo {
|
||||
return {
|
||||
type: TrackType.Default,
|
||||
url: `file://${RNFS.DocumentDirectoryPath}/${downloadedTrack.path!.split('/').pop()}`,
|
||||
image: `file://${RNFS.DocumentDirectoryPath}/${downloadedTrack.artwork!.split('/').pop()}`,
|
||||
duration: convertRunTimeTicksToSeconds(
|
||||
downloadedTrack.mediaSourceInfo?.RunTimeTicks || downloadedTrack.item.RunTimeTicks || 0,
|
||||
),
|
||||
item: downloadedTrack.item,
|
||||
mediaSourceInfo: downloadedTrack.mediaSourceInfo,
|
||||
sessionId: downloadedTrack.sessionId,
|
||||
sourceType: 'download',
|
||||
}
|
||||
}
|
||||
|
||||
function buildTranscodedTrack(
|
||||
api: Api,
|
||||
item: BaseItemDto,
|
||||
mediaSourceInfo: MediaSourceInfo,
|
||||
sessionId: string | null | undefined,
|
||||
): TrackMediaInfo {
|
||||
const { AlbumId, RunTimeTicks } = item
|
||||
|
||||
return {
|
||||
type: TrackType.HLS,
|
||||
url: `${api.basePath}${mediaSourceInfo.TranscodingUrl}`,
|
||||
image: AlbumId
|
||||
? getImageApi(api).getItemImageUrlById(AlbumId, ImageType.Primary)
|
||||
: undefined,
|
||||
duration: convertRunTimeTicksToSeconds(RunTimeTicks ?? 0),
|
||||
mediaSourceInfo,
|
||||
item,
|
||||
sessionId,
|
||||
sourceType: 'stream',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a URL targeting the {@link AudioApi}, using data contained in the
|
||||
* {@link PlaybackInfoResponse}
|
||||
@@ -131,19 +182,14 @@ export function mapDtoToTrack(
|
||||
function buildAudioApiUrl(
|
||||
api: Api,
|
||||
item: BaseItemDto,
|
||||
streamingQuality: StreamingQuality | undefined,
|
||||
downloadQuality: DownloadQuality,
|
||||
deviceProfile: DeviceProfile | undefined,
|
||||
): string {
|
||||
// Use streamingQuality for URL generation, fallback to downloadQuality for backward compatibility
|
||||
const qualityForStreaming = streamingQuality || downloadQuality
|
||||
const qualityParams = getQualityParams(qualityForStreaming)
|
||||
|
||||
console.debug(
|
||||
`Mapping BaseItemDTO to Track object with streaming quality: ${qualityForStreaming}`,
|
||||
`Mapping BaseItemDTO to Track object with streaming quality: ${deviceProfile?.Name}`,
|
||||
)
|
||||
const mediaInfo = queryClient.getQueryData([
|
||||
QueryKeys.MediaSources,
|
||||
streamingQuality,
|
||||
deviceProfile?.Name,
|
||||
item.Id,
|
||||
]) as PlaybackInfoResponse | undefined
|
||||
|
||||
@@ -157,7 +203,6 @@ function buildAudioApiUrl(
|
||||
playSessionId: mediaInfo?.PlaySessionId ?? uuid.v4(),
|
||||
startTimeTicks: '0',
|
||||
static: 'true',
|
||||
...qualityParams,
|
||||
}
|
||||
|
||||
if (mediaSource.Container! !== 'mpeg') container = mediaSource.Container!
|
||||
@@ -166,45 +211,12 @@ function buildAudioApiUrl(
|
||||
playSessionId: uuid.v4(),
|
||||
StartTimeTicks: '0',
|
||||
static: 'true',
|
||||
...qualityParams,
|
||||
}
|
||||
|
||||
if (item.Container! !== 'mpeg') container = item.Container!
|
||||
}
|
||||
|
||||
return `${api.basePath}/Audio/${item.Id!}/stream.${container}?${new URLSearchParams(urlParams)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Per Niels we should not be using the {@link UniversalAudioApi},
|
||||
* but rather the {@link AudioApi}.
|
||||
*
|
||||
* Builds a URL targeting the {@link UniversalAudioApi}, used as a fallback
|
||||
* when there is no {@link PlaybackInfoResponse} available
|
||||
*
|
||||
* @param api The API instance
|
||||
* @param item The item to build the URL for
|
||||
* @param sessionId The session ID
|
||||
* @param qualityParams The quality parameters
|
||||
* @returns The URL for the universal audio API
|
||||
*/
|
||||
function buildUniversalAudioApiUrl(
|
||||
api: Api,
|
||||
item: BaseItemDto,
|
||||
sessionId: string,
|
||||
qualityParams: Record<string, string>,
|
||||
): string {
|
||||
const urlParams = {
|
||||
Container: item.Container!,
|
||||
TranscodingContainer: transcodingContainer,
|
||||
EnableRemoteMedia: 'true',
|
||||
EnableRedirection: 'true',
|
||||
api_key: api.accessToken,
|
||||
StartTimeTicks: '0',
|
||||
PlaySessionId: sessionId,
|
||||
...qualityParams,
|
||||
}
|
||||
return `${api.basePath}/Audio/${item.Id!}/universal?${new URLSearchParams(urlParams)}`
|
||||
return `${api.basePath}/Audio/${item.Id!}/stream?${new URLSearchParams(urlParams)}`
|
||||
}
|
||||
|
||||
function mediaSourceExists(mediaInfo: PlaybackInfoResponse | undefined): boolean {
|
||||
|
||||
8
src/utils/url-parsers.ts
Normal file
8
src/utils/url-parsers.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function parseBitrateFromTranscodingUrl(transcodingUrl: string): number {
|
||||
return parseInt(
|
||||
transcodingUrl
|
||||
.split('&')
|
||||
.find((part) => part.includes('AudioBitrate'))!
|
||||
.split('=')[1],
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user