diff --git a/App.tsx b/App.tsx
index 0b6af4c2..6e236d1e 100644
--- a/App.tsx
+++ b/App.tsx
@@ -77,9 +77,21 @@ export default function App(): React.JSX.Element {
-
-
-
+
+
+
+
+
@@ -104,23 +116,11 @@ function Container({ playerIsReady }: { playerIsReady: boolean }): React.JSX.Ele
: JellifyLightTheme
}
>
-
-
-
- {playerIsReady && }
-
-
-
+
+
+ {playerIsReady && }
+
+
)
}
diff --git a/README.md b/README.md
index 7ae290a3..41778663 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/jest/contextual/PlayerProvider.test.tsx b/jest/contextual/PlayerProvider.test.tsx
index 2c4afc71..cf5954e4 100644
--- a/jest/contextual/PlayerProvider.test.tsx
+++ b/jest/contextual/PlayerProvider.test.tsx
@@ -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()
diff --git a/jest/functional/Normalization.test.ts b/jest/functional/Normalization.test.ts
index ee643bc5..126e7e1e 100644
--- a/jest/functional/Normalization.test.ts
+++ b/jest/functional/Normalization.test.ts
@@ -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)
diff --git a/jest/functional/Player-Index.test.ts b/jest/functional/Player-Index.test.ts
index 5048f870..c7a369b3 100644
--- a/jest/functional/Player-Index.test.ts
+++ b/jest/functional/Player-Index.test.ts
@@ -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,
diff --git a/jest/functional/Shuffle.test.tsx b/jest/functional/Shuffle.test.tsx
index b1ee54f3..9abc9f08 100644
--- a/jest/functional/Shuffle.test.tsx
+++ b/jest/functional/Shuffle.test.tsx
@@ -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}`,
diff --git a/jest/functional/Player-Handlers.test.ts b/jest/functional/player-utils.test.ts
similarity index 65%
rename from jest/functional/Player-Handlers.test.ts
rename to jest/functional/player-utils.test.ts
index 214c2ff1..0f900011 100644
--- a/jest/functional/Player-Handlers.test.ts
+++ b/jest/functional/player-utils.test.ts
@@ -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()
})
diff --git a/src/api/mutations/download/index.ts b/src/api/mutations/download/index.ts
new file mode 100644
index 00000000..2e454ebf
--- /dev/null
+++ b/src/api/mutations/download/index.ts
@@ -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,
+] = () => {
+ const { api } = useJellifyContext()
+
+ const { data: downloadedTracks, refetch } = useAllDownloadedTracks()
+
+ const deviceProfile = useDownloadingDeviceProfile()
+
+ const [downloadProgress, setDownloadProgress] = useState({})
+
+ 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
+}
diff --git a/src/components/Network/offlineModeUtils.ts b/src/api/mutations/download/offlineModeUtils.ts
similarity index 95%
rename from src/components/Network/offlineModeUtils.ts
rename to src/api/mutations/download/offlineModeUtils.ts
index 29ff0858..ba7bfa0b 100644
--- a/src/components/Network/offlineModeUtils.ts
+++ b/src/api/mutations/download/offlineModeUtils.ts
@@ -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}`)
diff --git a/src/api/mutations/playback/functions/playback-completed.ts b/src/api/mutations/playback/functions/playback-completed.ts
new file mode 100644
index 00000000..4f9cef9c
--- /dev/null
+++ b/src/api/mutations/playback/functions/playback-completed.ts
@@ -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> {
+ 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,
+ },
+ })
+}
diff --git a/src/api/mutations/playback/functions/playback-progress.ts b/src/api/mutations/playback/functions/playback-progress.ts
new file mode 100644
index 00000000..82087a22
--- /dev/null
+++ b/src/api/mutations/playback/functions/playback-progress.ts
@@ -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> {
+ 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),
+ },
+ })
+}
diff --git a/src/api/mutations/playback/functions/playback-started.ts b/src/api/mutations/playback/functions/playback-started.ts
new file mode 100644
index 00000000..bcbf7242
--- /dev/null
+++ b/src/api/mutations/playback/functions/playback-started.ts
@@ -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,
+ },
+ })
+}
diff --git a/src/api/mutations/playback/functions/playback-stopped.ts b/src/api/mutations/playback/functions/playback-stopped.ts
new file mode 100644
index 00000000..1a664e77
--- /dev/null
+++ b/src/api/mutations/playback/functions/playback-stopped.ts
@@ -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> {
+ if (!api) return Promise.reject('API instance not set')
+
+ const { sessionId, item } = track
+
+ return await getPlaystateApi(api).reportPlaybackStopped({
+ playbackStopInfo: {
+ SessionId: sessionId,
+ ItemId: item.Id,
+ },
+ })
+}
diff --git a/src/api/mutations/playback/index.ts b/src/api/mutations/playback/index.ts
new file mode 100644
index 00000000..4f331255
--- /dev/null
+++ b/src/api/mutations/playback/index.ts
@@ -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),
+ })
+}
diff --git a/src/api/mutations/playback/utils/index.ts b/src/api/mutations/playback/utils/index.ts
new file mode 100644
index 00000000..b5cdcf2d
--- /dev/null
+++ b/src/api/mutations/playback/utils/index.ts
@@ -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
+}
diff --git a/src/api/queries/artist.ts b/src/api/queries/artist.ts
index 08cfc67a..3dcf4abc 100644
--- a/src/api/queries/artist.ts
+++ b/src/api/queries/artist.ts
@@ -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')
diff --git a/src/api/queries/download/index.ts b/src/api/queries/download/index.ts
new file mode 100644
index 00000000..be825bb6
--- /dev/null
+++ b/src/api/queries/download/index.ts
@@ -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
diff --git a/src/api/queries/download/keys.ts b/src/api/queries/download/keys.ts
new file mode 100644
index 00000000..934dfe4f
--- /dev/null
+++ b/src/api/queries/download/keys.ts
@@ -0,0 +1,6 @@
+enum DownloadQueryKeys {
+ DownloadedTrack = 'DOWNLOADED_TRACK',
+ DownloadedTracks = 'DownloadedTracks',
+}
+
+export default DownloadQueryKeys
diff --git a/src/api/queries/download/utils/storage-in-use.ts b/src/api/queries/download/utils/storage-in-use.ts
new file mode 100644
index 00000000..581325c6
--- /dev/null
+++ b/src/api/queries/download/utils/storage-in-use.ts
@@ -0,0 +1,20 @@
+import RNFS from 'react-native-fs'
+
+type JellifyStorage = {
+ totalStorage: number
+ freeSpace: number
+ storageInUseByJellify: number
+}
+
+const fetchStorageInUse: () => Promise = 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
diff --git a/src/api/queries/media/index.ts b/src/api/queries/media/index.ts
new file mode 100644
index 00000000..7af387be
--- /dev/null
+++ b/src/api/queries/media/index.ts
@@ -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),
+ })
+}
diff --git a/src/api/queries/media.ts b/src/api/queries/media/utils/index.ts
similarity index 57%
rename from src/api/queries/media.ts
rename to src/api/queries/media/utils/index.ts
index 2c4b170d..ae00c7f2 100644
--- a/src/api/queries/media.ts
+++ b/src/api/queries/media/utils/index.ts
@@ -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 {
- 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 }) => {
diff --git a/src/api/queries/query.config.ts b/src/api/queries/query.config.ts
index 46aff7df..6bfa0020 100644
--- a/src/api/queries/query.config.ts
+++ b/src/api/queries/query.config.ts
@@ -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
diff --git a/src/components/Album/index.tsx b/src/components/Album/index.tsx
index 426c79a8..b84d075f 100644
--- a/src/components/Album/index.tsx
+++ b/src/components/Album/index.tsx
@@ -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,
diff --git a/src/components/Albums/component.tsx b/src/components/Albums/component.tsx
index 00b69b86..f9ddb461 100644
--- a/src/components/Albums/component.tsx
+++ b/src/components/Albums/component.tsx
@@ -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[] }) => {
viewableItems.forEach(({ isViewable, item }) => {
if (isViewable && typeof item === 'object')
- warmItemContext(api, user, item, streamingQuality)
+ warmItemContext(api, user, item, deviceProfile)
})
},
)
diff --git a/src/components/Artist/albums.tsx b/src/components/Artist/albums.tsx
index 76a80bba..7e8fbfd3 100644
--- a/src/components/Artist/albums.tsx
+++ b/src/components/Artist/albums.tsx
@@ -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[] }) => {
viewableItems.forEach(({ isViewable, item }) => {
- if (isViewable) warmItemContext(api, user, item, streamingQuality)
+ if (isViewable) warmItemContext(api, user, item, deviceProfile)
})
},
)
diff --git a/src/components/Artist/tab-bar.tsx b/src/components/Artist/tab-bar.tsx
index cdd11997..1d93252f 100644
--- a/src/components/Artist/tab-bar.tsx
+++ b/src/components/Artist/tab-bar.tsx
@@ -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,
diff --git a/src/components/Artists/component.tsx b/src/components/Artists/component.tsx
index 7e17b561..3c922c7b 100644
--- a/src/components/Artists/component.tsx
+++ b/src/components/Artists/component.tsx
@@ -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[] }) => {
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}
/>
diff --git a/src/components/AudioSpecs/index.tsx b/src/components/AudioSpecs/index.tsx
new file mode 100644
index 00000000..7f507be1
--- /dev/null
+++ b/src/components/AudioSpecs/index.tsx
@@ -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
+}
+
+export default function AudioSpecs({
+ item,
+ streamingMediaSourceInfo,
+ downloadedMediaSourceInfo,
+ navigation,
+}: AudioSpecsProps): React.JSX.Element {
+ const { bottom } = useSafeAreaInsets()
+
+ return (
+
+ {streamingMediaSourceInfo && (
+
+ )}
+
+ {downloadedMediaSourceInfo && (
+
+ )}
+
+ )
+}
+
+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 (
+
+
+ {`${capitalize(type)} Specs`}
+
+
+
+
+
+ {type === 'download'
+ ? 'Downloaded File'
+ : TranscodingUrl
+ ? 'Transcoded Stream'
+ : 'Direct Stream'}
+
+
+ {bitrate && (
+
+
+
+
+ {`${Math.floor(bitrate / 1000)}kbps`}
+
+
+ )}
+
+ {container && (
+
+
+
+
+ {container.toUpperCase()}
+
+
+ )}
+
+ )
+}
diff --git a/src/components/CarPlay/Home.tsx b/src/components/CarPlay/Home.tsx
index cb0fa4b6..4f1da4b9 100644
--- a/src/components/CarPlay/Home.tsx
+++ b/src/components/CarPlay/Home.tsx
@@ -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,
),
)
diff --git a/src/components/CarPlay/Navigation.tsx b/src/components/CarPlay/Navigation.tsx
index 43169357..f515b259 100644
--- a/src/components/CarPlay/Navigation.tsx
+++ b/src/components/CarPlay/Navigation.tsx
@@ -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,
diff --git a/src/components/CarPlay/Tracks.tsx b/src/components/CarPlay/Tracks.tsx
index d592802b..7f48efc1 100644
--- a/src/components/CarPlay/Tracks.tsx
+++ b/src/components/CarPlay/Tracks.tsx
@@ -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,
diff --git a/src/components/Context/index.tsx b/src/components/Context/index.tsx
index e3f4982d..19a6ed5b 100644
--- a/src/components/Context/index.tsx
+++ b/src/components/Context/index.tsx
@@ -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, 'navigate' | 'dispatch'>
interface ContextProps {
item: BaseItemDto
+ streamingMediaSourceInfo?: MediaSourceInfo
+ downloadedMediaSourceInfo?: MediaSourceInfo
stackNavigation?: StackNavigation
navigation: NativeStackNavigationProp
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 && }
+ {(streamingMediaSourceInfo || downloadedMediaSourceInfo) && (
+
+ )}
+
{renderViewAlbumRow && (
{
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
>
)
}
+
+function StatsRow({
+ item,
+ streamingMediaSourceInfo,
+ downloadedMediaSourceInfo,
+}: {
+ item: BaseItemDto
+ streamingMediaSourceInfo?: MediaSourceInfo
+ downloadedMediaSourceInfo?: MediaSourceInfo
+}): React.JSX.Element {
+ return (
+ {
+ navigationRef.goBack() // dismiss context modal
+ navigationRef.navigate('AudioSpecs', {
+ item,
+ streamingMediaSourceInfo,
+ downloadedMediaSourceInfo,
+ })
+ }}
+ pressStyle={{ opacity: 0.5 }}
+ >
+
+
+ Open Audio Specs
+
+ )
+}
diff --git a/src/components/Discover/component.tsx b/src/components/Discover/component.tsx
index e1b4fcc0..fc8bdd67 100644
--- a/src/components/Discover/component.tsx
+++ b/src/components/Discover/component.tsx
@@ -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'
diff --git a/src/components/Discover/helpers/just-added.tsx b/src/components/Discover/helpers/just-added.tsx
index 75f64f3b..adaf0c26 100644
--- a/src/components/Discover/helpers/just-added.tsx
+++ b/src/components/Discover/helpers/just-added.tsx
@@ -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'
diff --git a/src/components/Discover/helpers/public-playlists.tsx b/src/components/Discover/helpers/public-playlists.tsx
index bdc4d451..0f2efe54 100644
--- a/src/components/Discover/helpers/public-playlists.tsx
+++ b/src/components/Discover/helpers/public-playlists.tsx
@@ -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'
diff --git a/src/components/Global/components/downloaded-icon.tsx b/src/components/Global/components/downloaded-icon.tsx
index f8765d26..b62e5061 100644
--- a/src/components/Global/components/downloaded-icon.tsx
+++ b/src/components/Global/components/downloaded-icon.tsx
@@ -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 ? (
diff --git a/src/components/Global/components/favorite-context-menu-row.tsx b/src/components/Global/components/favorite-context-menu-row.tsx
index 2a7f9e9d..08deea49 100644
--- a/src/components/Global/components/favorite-context-menu-row.tsx
+++ b/src/components/Global/components/favorite-context-menu-row.tsx
@@ -39,7 +39,7 @@ export default function FavoriteContextMenuRow({ item }: { item: BaseItemDto }):
exiting={FadeOut}
key={`${item.Id}-remove-favorite-row`}
>
-
+
Remove from favorites
@@ -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 }}
>
-
+
Add to favorites
diff --git a/src/components/Global/components/horizontal-list.tsx b/src/components/Global/components/horizontal-list.tsx
index 809a5cee..d60607bb 100644
--- a/src/components/Global/components/horizontal-list.tsx
+++ b/src/components/Global/components/horizontal-list.tsx
@@ -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[] }) => {
viewableItems
.filter(({ isViewable }) => isViewable)
- .forEach(({ isViewable, item }) => {
- if (isViewable) warmItemContext(api, user, item, streamingQuality)
- })
+ .forEach(({ item }) => warmItemContext(api, user, item, deviceProfile))
},
)
diff --git a/src/components/Global/components/item-row.tsx b/src/components/Global/components/item-row.tsx
index abbd896b..c948db2a 100644
--- a/src/components/Global/components/item-row.tsx
+++ b/src/components/Global/components/item-row.tsx
@@ -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],
diff --git a/src/components/Global/components/track.tsx b/src/components/Global/components/track.tsx
index a0c30a70..ac264b81 100644
--- a/src/components/Global/components/track.tsx
+++ b/src/components/Global/components/track.tsx
@@ -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])
diff --git a/src/components/Home/helpers/frequent-tracks.tsx b/src/components/Home/helpers/frequent-tracks.tsx
index 3cedaa7f..e5cc28f1 100644
--- a/src/components/Home/helpers/frequent-tracks.tsx
+++ b/src/components/Home/helpers/frequent-tracks.tsx
@@ -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,
diff --git a/src/components/Home/helpers/recently-played.tsx b/src/components/Home/helpers/recently-played.tsx
index d10b9afd..04954e9e 100644
--- a/src/components/Home/helpers/recently-played.tsx
+++ b/src/components/Home/helpers/recently-played.tsx
@@ -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,
diff --git a/src/components/Library/categories.ts b/src/components/Library/categories.ts
deleted file mode 100644
index 4ffcb2e8..00000000
--- a/src/components/Library/categories.ts
+++ /dev/null
@@ -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
diff --git a/src/components/Player/components/footer.tsx b/src/components/Player/components/footer.tsx
index e7507678..eecbf235 100644
--- a/src/components/Player/components/footer.tsx
+++ b/src/components/Player/components/footer.tsx
@@ -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>()
diff --git a/src/components/Player/components/header.tsx b/src/components/Player/components/header.tsx
index 4457a7fc..1475316c 100644
--- a/src/components/Player/components/header.tsx
+++ b/src/components/Player/components/header.tsx
@@ -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 }}
/>
-
+
Playing from
{playingFrom}
-
+
{
+ navigationRef.navigate('AudioSpecs', {
+ item,
+ streamingMediaSourceInfo: sourceType === 'stream' ? mediaSourceInfo : undefined,
+ downloadedMediaSourceInfo:
+ sourceType === 'download' ? mediaSourceInfo : undefined,
+ })
+ }}
+ >
+
+ {`${Math.floor(bitrate / 1000)}kbps ${formatContainerName(bitrate, container)}`}
+
+
+ ) : (
+ <>>
+ )
+}
+
+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
+}
diff --git a/src/components/Player/components/scrubber.tsx b/src/components/Player/components/scrubber.tsx
index 9ca78c1d..c7f87719 100644
--- a/src/components/Player/components/scrubber.tsx
+++ b/src/components/Player/components/scrubber.tsx
@@ -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(0)
@@ -32,6 +39,8 @@ export default function Scrubber(): React.JSX.Element {
const currentTrackIdRef = useRef(null)
const lastPositionRef = useRef(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}
/>
-
-
+
+
{currentSeconds}
-
- {/** Track metadata can go here */}
+
+ {nowPlaying?.mediaSourceInfo && displayAudioQualityBadge && (
+
+ )}
-
+
{totalSeconds}
diff --git a/src/components/Player/components/song-info.tsx b/src/components/Player/components/song-info.tsx
index ee9bee6b..86b93e86 100644
--- a/src/components/Player/components/song-info.tsx
+++ b/src/components/Player/components/song-info.tsx
@@ -81,7 +81,19 @@ export default function SongInfo(): React.JSX.Element {
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,
+ })
+ }
/>
diff --git a/src/components/Player/index.tsx b/src/components/Player/index.tsx
index b46eae41..ab69a7a7 100644
--- a/src/components/Player/index.tsx
+++ b/src/components/Player/index.tsx
@@ -67,7 +67,7 @@ export default function PlayerScreen(): React.JSX.Element {
{/* flexGrow 1 */}
-
+
diff --git a/src/components/Player/mini-player.tsx b/src/components/Player/mini-player.tsx
index 82c624ca..61d63bed 100644
--- a/src/components/Player/mini-player.tsx
+++ b/src/components/Player/mini-player.tsx
@@ -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 (
- {Math.max(0, Math.floor(progress?.position ?? 0))}
+ {Math.max(0, Math.floor(position))}
@@ -188,7 +189,7 @@ function MiniPlayerRuntime(): React.JSX.Element {
- {Math.max(0, Math.floor(progress?.duration ?? 0))}
+ {Math.max(0, Math.floor(duration))}
diff --git a/src/components/Playlist/components/header.tsx b/src/components/Playlist/components/header.tsx
index 94f7f857..92b890c3 100644
--- a/src/components/Playlist/components/header.tsx
+++ b/src/components/Playlist/components/header.tsx
@@ -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>()
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,
diff --git a/src/components/Playlists/component.tsx b/src/components/Playlists/component.tsx
index c989a866..753a9f86 100644
--- a/src/components/Playlists/component.tsx
+++ b/src/components/Playlists/component.tsx
@@ -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[] }) => {
viewableItems.forEach(({ isViewable, item }) => {
- if (isViewable) warmItemContext(api, user, item, streamingQuality)
+ if (isViewable) warmItemContext(api, user, item, deviceProfile)
})
},
)
diff --git a/src/components/Settings/components/playback-tab.tsx b/src/components/Settings/components/playback-tab.tsx
index 702d4d85..570e4a81 100644
--- a/src/components/Settings/components/playback-tab.tsx
+++ b/src/components/Settings/components/playback-tab.tsx
@@ -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 (
@@ -56,6 +61,20 @@ export default function PlaybackTab(): React.JSX.Element {
),
},
+ {
+ title: 'Show Audio Quality Badge',
+ subTitle: 'Displays audio quality in the player',
+ iconName: 'sine-wave',
+ iconColor: '$borderColor',
+ children: (
+
+ ),
+ },
]}
/>
)
diff --git a/src/components/Settings/components/storage-tab.tsx b/src/components/Settings/components/storage-tab.tsx
index f4e73f7b..1bad1964 100644
--- a/src/components/Settings/components/storage-tab.tsx
+++ b/src/components/Settings/components/storage-tab.tsx
@@ -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 (
{
@@ -82,7 +83,7 @@ export default function Tracks({
const onViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: ViewToken[] }) => {
viewableItems.forEach(({ isViewable, item }) => {
- if (isViewable) warmItemContext(api, user, item, streamingQuality)
+ if (isViewable) warmItemContext(api, user, item, deviceProfile)
})
},
)
diff --git a/src/components/jellify.tsx b/src/components/jellify.tsx
index 95a1c7e2..9efb4afa 100644
--- a/src/components/jellify.tsx
+++ b/src/components/jellify.tsx
@@ -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
diff --git a/src/hooks/use-item-context.ts b/src/hooks/use-item-context.ts
index d2528d5b..91d03e44 100644
--- a/src/hooks/use-item-context.ts
+++ b/src/hooks/use-item-context.ts
@@ -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>(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]
diff --git a/src/providers/CarPlay/index.tsx b/src/providers/CarPlay/index.tsx
index 2accbeb8..9784d569 100644
--- a/src/providers/CarPlay/index.tsx
+++ b/src/providers/CarPlay/index.tsx
@@ -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,
),
)
diff --git a/src/providers/Library/index.tsx b/src/providers/Library/index.tsx
index 56b1feb8..62db053a 100644
--- a/src/providers/Library/index.tsx
+++ b/src/providers/Library/index.tsx
@@ -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
diff --git a/src/providers/Network/index.tsx b/src/providers/Network/index.tsx
index fe348214..7edcf5fc 100644
--- a/src/providers/Network/index.tsx
+++ b/src/providers/Network/index.tsx
@@ -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
- useRemoveDownload: UseMutationResult
- storageUsage: JellifyStorage | undefined
- downloadedTracks: JellifyDownload[] | undefined
+ useDownloadMultiple: UseMutateFunction
activeDownloads: JellifyDownloadProgress | undefined
networkStatus: networkStatusTypes | null
setNetworkStatus: (status: networkStatusTypes | null) => void
- useDownloadMultiple: UseMutationResult
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({})
const [networkStatus, setNetworkStatus] = useState(null)
@@ -44,21 +28,7 @@ const NetworkContextInitializer = () => {
const [completed, setCompleted] = useState([])
const [failed, setFailed] = useState([])
- const fetchStorageInUse: () => Promise = 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({
- 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,
diff --git a/src/providers/Network/types.d.ts b/src/providers/Network/types.d.ts
deleted file mode 100644
index 122c0c66..00000000
--- a/src/providers/Network/types.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export type JellifyStorage = {
- totalStorage: number
- freeSpace: number
- storageInUseByJellify: number
-}
diff --git a/src/providers/Player/functions/queue.ts b/src/providers/Player/functions/queue.ts
index c7830259..8df57ce0 100644
--- a/src/providers/Player/functions/queue.ts
+++ b/src/providers/Player/functions/queue.ts
@@ -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,
),
)
diff --git a/src/providers/Player/hooks/mutations.ts b/src/providers/Player/hooks/mutations.ts
index 461e5fe6..2c4ce01e 100644
--- a/src/providers/Player/hooks/mutations.ts
+++ b/src/providers/Player/hooks/mutations.ts
@@ -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'
diff --git a/src/providers/Player/hooks/queries.ts b/src/providers/Player/hooks/queries.ts
index 4ff9ae2e..83ff5611 100644
--- a/src/providers/Player/hooks/queries.ts
+++ b/src/providers/Player/hooks/queries.ts
@@ -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,
diff --git a/src/providers/Player/index.tsx b/src/providers/Player/index.tsx
index 9ab60d30..35d99ad4 100644
--- a/src/providers/Player/index.tsx
+++ b/src/providers/Player/index.tsx
@@ -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({})
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>(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
}
})
diff --git a/src/providers/Player/interfaces.ts b/src/providers/Player/interfaces.ts
index 2bc46c16..c42f5006 100644
--- a/src/providers/Player/interfaces.ts
+++ b/src/providers/Player/interfaces.ts
@@ -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.
diff --git a/src/providers/Player/utils/handlers.ts b/src/providers/Player/utils/handlers.ts
deleted file mode 100644
index 791d91c4..00000000
--- a/src/providers/Player/utils/handlers.ts
+++ /dev/null
@@ -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,
- networkStatus: networkStatusTypes | null,
- position: number,
- duration: number,
-): Promise {
- // 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
-}
diff --git a/src/providers/Player/utils/normalization.ts b/src/providers/Player/utils/normalization.ts
index c460b37c..62ece342 100644
--- a/src/providers/Player/utils/normalization.ts
+++ b/src/providers/Player/utils/normalization.ts
@@ -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
diff --git a/src/providers/Settings/index.tsx b/src/providers/Settings/index.tsx
index 9fa3b6cf..5560cadc 100644
--- a/src/providers/Settings/index.tsx
+++ b/src/providers/Settings/index.tsx
@@ -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(
- downloadQualityInit ?? 'medium',
+ downloadQualityInit ?? 'original',
)
const [streamingQuality, setStreamingQuality] = useState(
- streamingQualityInit ?? 'high',
+ streamingQualityInit ?? 'original',
)
const [reducedHaptics, setReducedHaptics] = useState(
@@ -74,6 +79,13 @@ const SettingsContextInitializer = () => {
const [theme, setTheme] = useState(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(() => {
diff --git a/src/providers/Settings/utils/index.ts b/src/providers/Settings/utils/index.ts
new file mode 100644
index 00000000..bfd03351
--- /dev/null
+++ b/src/providers/Settings/utils/index.ts
@@ -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,
+ },
+ ],
+}
diff --git a/src/providers/index.tsx b/src/providers/index.tsx
index 32c9d0ff..b2caf464 100644
--- a/src/providers/index.tsx
+++ b/src/providers/index.tsx
@@ -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(apiJson ? JSON.parse(apiJson) : undefined)
const [server, setServer] = useState(
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({
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,
],
)
diff --git a/src/screens/Context/index.tsx b/src/screens/Context/index.tsx
index f0e8351c..c304ca35 100644
--- a/src/screens/Context/index.tsx
+++ b/src/screens/Context/index.tsx
@@ -4,10 +4,12 @@ import { ContextProps } from '../types'
export default function ItemContextScreen({ route, navigation }: ContextProps): React.JSX.Element {
return (
)
}
diff --git a/src/screens/Player/index.tsx b/src/screens/Player/index.tsx
index 65ef7df0..6a11c991 100644
--- a/src/screens/Player/index.tsx
+++ b/src/screens/Player/index.tsx
@@ -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()
diff --git a/src/screens/Settings/sign-out-modal.tsx b/src/screens/Settings/sign-out-modal.tsx
index 5e1f54c5..a885aed4 100644
--- a/src/screens/Settings/sign-out-modal.tsx
+++ b/src/screens/Settings/sign-out-modal.tsx
@@ -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 (
diff --git a/src/screens/Stats/index.tsx b/src/screens/Stats/index.tsx
new file mode 100644
index 00000000..9226649b
--- /dev/null
+++ b/src/screens/Stats/index.tsx
@@ -0,0 +1,6 @@
+import AudioSpecs from '../../components/AudioSpecs'
+import { AudioSpecsProps } from '../types'
+
+export default function AudioSpecsSheet({ route, navigation }: AudioSpecsProps): React.JSX.Element {
+ return
+}
diff --git a/src/screens/index.tsx b/src/screens/index.tsx
index 0ca615fb..424ced53 100644
--- a/src/screens/index.tsx
+++ b/src/screens/index.tsx
@@ -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()
@@ -77,6 +78,17 @@ export default function Root(): React.JSX.Element {
sheetGrabberVisible: true,
}}
/>
+
+ ({
+ header: () => ContextSheetHeader(route.params.item),
+ presentation: 'formSheet',
+ sheetAllowedDetents: 'fitToContents',
+ sheetGrabberVisible: true,
+ })}
+ />
)
}
diff --git a/src/screens/types.d.ts b/src/screens/types.d.ts
index 285940d8..c81c7321 100644
--- a/src/screens/types.d.ts
+++ b/src/screens/types.d.ts
@@ -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, '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
@@ -73,6 +81,7 @@ export type TabProps = NativeStackScreenProps
export type PlayerProps = NativeStackScreenProps
export type ContextProps = NativeStackScreenProps
export type AddToPlaylistProps = NativeStackScreenProps
+export type AudioSpecsProps = NativeStackScreenProps
export type ArtistsProps = {
artistsInfiniteQuery: UseInfiniteQueryResult<
diff --git a/src/stores/device-profile.ts b/src/stores/device-profile.ts
new file mode 100644
index 00000000..b1bc3aed
--- /dev/null
+++ b/src/stores/device-profile.ts
@@ -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()(
+ 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()(
+ devtools(
+ persist(
+ (set) => ({
+ deviceProfile: {},
+ setDeviceProfile: (data: DeviceProfile) => set({ deviceProfile: data }),
+ }),
+ {
+ name: 'downloading-device-profile-storage',
+ },
+ ),
+ ),
+)
+
+export const useDownloadingDeviceProfile = () => {
+ return useDownloadingDeviceProfileStore((state) => state.deviceProfile)
+}
diff --git a/src/zustand/engineStore.ts b/src/stores/player-engine.ts
similarity index 100%
rename from src/zustand/engineStore.ts
rename to src/stores/player-engine.ts
diff --git a/src/stores/player-settings.ts b/src/stores/player-settings.ts
new file mode 100644
index 00000000..594ac3fb
--- /dev/null
+++ b/src/stores/player-settings.ts
@@ -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()(
+ 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)
diff --git a/src/stores/streaming-quality.ts b/src/stores/streaming-quality.ts
new file mode 100644
index 00000000..e69de29b
diff --git a/src/types/JellifyTrack.ts b/src/types/JellifyTrack.ts
index d9b83ad7..0b599b3a 100644
--- a/src/types/JellifyTrack.ts
+++ b/src/types/JellifyTrack.ts
@@ -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
diff --git a/src/utils/mappings.ts b/src/utils/mappings.ts
index ef8cdc99..90577b38 100644
--- a/src/utils/mappings.ts
+++ b/src/utils/mappings.ts
@@ -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 {
- 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 {
diff --git a/src/utils/url-parsers.ts b/src/utils/url-parsers.ts
new file mode 100644
index 00000000..5ba5c14b
--- /dev/null
+++ b/src/utils/url-parsers.ts
@@ -0,0 +1,8 @@
+export function parseBitrateFromTranscodingUrl(transcodingUrl: string): number {
+ return parseInt(
+ transcodingUrl
+ .split('&')
+ .find((part) => part.includes('AudioBitrate'))!
+ .split('=')[1],
+ )
+}