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], + ) +}