starting migration to nitro player for downloads

This commit is contained in:
Violet Caulfield
2026-02-14 07:09:32 -06:00
parent 2e1acb0a86
commit 5eabd83120
57 changed files with 407 additions and 1086 deletions
+2
View File
@@ -23,6 +23,7 @@ import { useAutoStore } from './src/stores/auto'
import { registerAutoService } from './src/services/carplay'
import QueryPersistenceConfig from './src/configs/query-persistence.config'
import registerTrackPlayer from './src/services/player'
import configureDownloadManager from './src/services/downloads'
LogBox.ignoreAllLogs()
@@ -49,6 +50,7 @@ export default function App(): React.JSX.Element {
useEffect(() => {
registerTrackPlayer()
configureDownloadManager()
return registerAutoService(onConnect, onDisconnect)
}, []) // Empty deps - only run once on mount
+25 -19
View File
@@ -4,6 +4,8 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application
android:name=".MainApplication"
@@ -15,24 +17,28 @@
android:theme="@style/AppTheme"
android:usesCleartextTraffic="${usesCleartextTraffic}"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.reactnative.googlecast.GoogleCastOptionsProvider" />
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.reactnative.googlecast.GoogleCastOptionsProvider" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
</application>
</manifest>
+23 -17
View File
@@ -1,20 +1,22 @@
import JellifyTrack from '../../src/types/JellifyTrack'
import calculateTrackVolume from '../../src/hooks/player/functions/normalization'
import { TrackItem } from 'react-native-nitro-player'
import calculateTrackVolume from '../../src/utils/audio/normalization'
describe('Normalization Module', () => {
it('should calculate the volume for a track with a normalization gain of 6', () => {
const track: JellifyTrack = {
const track: TrackItem = {
id: 'test-track-1',
title: 'Test Track 1',
artist: 'Test Artist',
album: 'Test Album',
url: 'https://example.com/track.mp3',
item: {
NormalizationGain: 6, // 6 Gain means the track is quieter than the target volume
extraPayload: {
item: JSON.stringify({
NormalizationGain: 6, // 6 Gain means the track is quieter than the target volume
}),
sourceType: 'stream',
sessionId: 'TEST_SESSION_ID',
},
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
}
const volume = calculateTrackVolume(track)
@@ -23,18 +25,20 @@ describe('Normalization Module', () => {
})
it('should calculate the volume for a track with a normalization gain of 0', () => {
const track: JellifyTrack = {
const track: TrackItem = {
id: 'test-track-2',
title: 'Test Track 2',
artist: 'Test Artist',
album: 'Test Album',
url: 'https://example.com/track.mp3',
item: {
NormalizationGain: 0, // 0 Gain means the track is at the target volume
extraPayload: {
item: JSON.stringify({
NormalizationGain: 0, // 0 Gain means the track is at the target volume
}),
sourceType: 'stream',
sessionId: 'TEST_SESSION_ID',
},
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
}
const volume = calculateTrackVolume(track)
@@ -43,18 +47,20 @@ describe('Normalization Module', () => {
})
it('should calculate the volume for a track with a normalization gain of -10', () => {
const track: JellifyTrack = {
const track: TrackItem = {
id: 'test-track-3',
title: 'Test Track 3',
artist: 'Test Artist',
album: 'Test Album',
url: 'https://example.com/track.mp3',
item: {
NormalizationGain: -10, // -10 Gain means the track is louder than the target volume
extraPayload: {
item: JSON.stringify({
NormalizationGain: -10, // -10 Gain means the track is louder than the target volume
}),
sourceType: 'stream',
sessionId: 'TEST_SESSION_ID',
},
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
}
const volume = calculateTrackVolume(track)
+12 -10
View File
@@ -1,12 +1,10 @@
import 'react-native'
import { shuffleJellifyTracks } from '../../src/hooks/player/functions/utils/shuffle'
import { QueuingType } from '../../src/enums/queuing-type'
import JellifyTrack from '../../src/types/JellifyTrack'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { TrackItem } from 'react-native-nitro-player'
// Test the shuffle utility function directly
describe('Shuffle Utility Function', () => {
const createMockTracks = (count: number): JellifyTrack[] => {
const createMockTracks = (count: number): TrackItem[] => {
return Array.from({ length: count }, (_, i) => ({
url: `https://example.com/${i + 1}`,
id: `track-${i + 1}`,
@@ -16,11 +14,15 @@ describe('Shuffle Utility Function', () => {
duration: 420,
sessionId: 'TEST_SESSION_ID',
sourceType: 'stream',
item: {
Id: `${i + 1}`,
Name: `Track ${i + 1}`,
Artists: [`Artist ${i + 1}`],
} as BaseItemDto,
extraPayload: {
item: JSON.stringify({
Id: `${i + 1}`,
Name: `Track ${i + 1}`,
Artists: [`Artist ${i + 1}`],
}),
sourceType: 'stream',
sessionId: 'TEST_SESSION_ID',
},
}))
}
@@ -32,7 +34,7 @@ describe('Shuffle Utility Function', () => {
expect(result.original).toEqual(tracks)
// Verify all tracks are still present (just reordered)
const originalIds = tracks.map((t) => t.item.Id).sort()
const originalIds = tracks.map((t) => t.id).sort()
const shuffledIds = result.shuffled.map((t) => t.id).sort()
expect(shuffledIds).toEqual(originalIds)
})
-77
View File
@@ -1,77 +0,0 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { useDownloadingDeviceProfile } from '../../../stores/device-profile'
import { UseMutateFunction, useMutation } from '@tanstack/react-query'
import { mapDtoToTrack } from '../../../utils/mapping/item-to-track'
import { deleteAudio, saveAudio } from './offlineModeUtils'
import { useState } from 'react'
import { JellifyDownloadProgress } from '../../../types/JellifyDownload'
import { useAllDownloadedTracks } from '../../queries/download'
export const useDownloadAudioItem: () => [
JellifyDownloadProgress,
UseMutateFunction<boolean, Error, { item: BaseItemDto; autoCached: boolean }, void>,
] = () => {
const { data: downloadedTracks, refetch } = useAllDownloadedTracks()
const deviceProfile = useDownloadingDeviceProfile()
const [downloadProgress, setDownloadProgress] = useState<JellifyDownloadProgress>({})
return [
downloadProgress,
useMutation({
onMutate: () => {},
mutationFn: async ({
item,
autoCached,
}: {
item: BaseItemDto
autoCached: boolean
}) => {
// If we already have this track downloaded, resolve the promise
if (downloadedTracks?.filter((download) => download.id === item.Id).length ?? 0 > 0)
return Promise.resolve(false)
const track = await mapDtoToTrack(item, 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.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) => {},
onSettled: () => refetch(),
}).mutate
}
@@ -1,373 +0,0 @@
import { createMMKV } from 'react-native-mmkv'
import RNFS from 'react-native-fs'
import JellifyTrack, { getTrackExtraPayload } from '../../../types/JellifyTrack'
import axios from 'axios'
import {
JellifyDownload,
JellifyDownloadProgress,
JellifyDownloadProgressState,
} from '../../../types/JellifyDownload'
import { queryClient } from '../../../constants/query-client'
import { AUDIO_CACHE_QUERY } from '../../queries/download/constants'
import { TrackItem } from 'react-native-nitro-player'
import { getTrackMediaSourceInfo } from '@/src/utils/track-extra-payload'
type DownloadedFileInfo = {
uri: string
path: string
fileName: string
size: number
}
const getExtensionFromUrl = (url: string): string | null => {
const sanitized = url.split('?')[0]
const lastSegment = sanitized.split('/').pop() ?? ''
const match = lastSegment.match(/\.([a-zA-Z0-9]+)$/)
return match?.[1] ?? null
}
const normalizeExtension = (ext: string | undefined | null) => {
if (!ext) return null
let extension
const clean = ext.toLowerCase()
if (clean.includes('mpeg')) extension = 'mp3'
else if (clean.includes('m4a')) extension = 'm4a'
else extension = clean
return extension
}
const extensionFromContentType = (contentType: string | undefined): string | null => {
if (!contentType) return null
if (!contentType.includes('/')) return null
const [, subtypeRaw] = contentType.split('/')
const container = subtypeRaw.split(';')[0]
return normalizeExtension(container)
}
export type DeleteDownloadsResult = {
deletedCount: number
freedBytes: number
failedCount: number
}
export async function downloadJellyfinFile(
url: string,
name: string,
songName: string,
setDownloadProgress: JellifyDownloadProgressState,
preferredExtension?: string | null,
): Promise<DownloadedFileInfo> {
// Validate URL before attempting download to prevent NPE in native code
if (!url || url.trim() === '') {
throw new Error('Invalid download URL: URL is empty or undefined')
}
try {
const urlExtension = normalizeExtension(getExtensionFromUrl(url))
const hintedExtension = normalizeExtension(preferredExtension)
let extension = urlExtension ?? hintedExtension ?? null
if (!extension) {
try {
const headRes = await axios.head(url)
const headExtension = extensionFromContentType(headRes.headers['content-type'])
if (headExtension) extension = headExtension
} catch (error) {
console.warn(
'HEAD request failed when determining download type, using default',
error,
)
}
}
if (!extension) extension = 'bin' // fallback without assuming a specific codec
// Build path
const fileName = `${name}.${extension}`
const downloadDest = `${RNFS.DocumentDirectoryPath}/${fileName}`
setDownloadProgress((prev: JellifyDownloadProgress) => ({
...prev,
[url]: { progress: 0, name: fileName, songName: songName },
}))
// Step 4: Start download with progress
const options = {
fromUrl: url,
toFile: downloadDest,
/* eslint-disable @typescript-eslint/no-explicit-any */
begin: (res: any) => {},
progress: (data: any) => {
const percent = +(data.bytesWritten / data.contentLength).toFixed(2)
setDownloadProgress((prev: JellifyDownloadProgress) => ({
...prev,
[url]: { progress: percent, name: fileName, songName: songName },
}))
},
background: true,
progressDivider: 1,
}
const result = await RNFS.downloadFile(options).promise
const metadata = await RNFS.stat(downloadDest)
return {
uri: `file://${downloadDest}`,
path: downloadDest,
fileName,
size: Number(metadata.size),
}
} catch (error) {
console.error('Download failed:', error)
throw error
}
}
const mmkv = createMMKV({
id: 'offlineMode',
encryptionKey: 'offlineMode',
})
const MMKV_OFFLINE_MODE_KEYS = {
AUDIO_CACHE: 'audioCache',
AUDIO_CACHE_LIMIT: 'audioCacheLimit',
}
export const getDefaultAudioCacheLimit = () => {
if (!mmkv.contains(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE_LIMIT)) {
mmkv.set(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE_LIMIT, 20)
}
}
getDefaultAudioCacheLimit()
const AUDIO_CACHE_LIMIT = mmkv.getNumber(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE_LIMIT)
export const saveAudio = async (
track: TrackItem,
setDownloadProgress: JellifyDownloadProgressState,
isAutoDownloaded: boolean = true,
): Promise<boolean> => {
if (
isAutoDownloaded &&
AUDIO_CACHE_LIMIT &&
(!Number.isFinite(AUDIO_CACHE_LIMIT) || AUDIO_CACHE_LIMIT <= 0)
) {
// If the cache limit is not set or is not a number, or is less than 0, Dont Auto Download
return false
}
const existingRaw = mmkv.getString(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE)
let existingArray: JellifyDownload[] = []
try {
if (existingRaw) {
existingArray = JSON.parse(existingRaw)
}
} catch (error) {
//Ignore
}
// Validate track URL before attempting download
if (!track.url || track.url.trim() === '') {
console.error('Cannot download track: URL is missing', track.id)
return false
}
try {
const downloadedTrackFile = await downloadJellyfinFile(
track.url,
track.id as string,
track.title as string,
setDownloadProgress,
getTrackMediaSourceInfo(track)!.Container,
)
let downloadedArtworkFile: DownloadedFileInfo | undefined
// Check for non-empty artwork URL (empty string passes truthy check but fails download)
if (track.artwork && typeof track.artwork === 'string' && track.artwork.trim() !== '') {
downloadedArtworkFile = await downloadJellyfinFile(
track.artwork,
`${track.id}-artwork`,
track.title as string,
setDownloadProgress,
undefined,
)
}
track.url = downloadedTrackFile.uri
if (downloadedArtworkFile) track.artwork = downloadedArtworkFile.uri
const index = existingArray.findIndex((t) => t.id === track.id)
const downloadEntry: JellifyDownload = {
...track,
savedAt: new Date().toISOString(),
isAutoDownloaded,
path: downloadedTrackFile.uri,
fileSizeBytes: downloadedTrackFile.size,
artworkSizeBytes: downloadedArtworkFile?.size,
}
if (index >= 0) {
// Replace existing
existingArray[index] = downloadEntry
} else {
// Add new
existingArray.push(downloadEntry)
}
} catch (error) {
return false
}
mmkv.set(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE, JSON.stringify(existingArray))
queryClient.invalidateQueries(AUDIO_CACHE_QUERY)
return true
}
export const deleteAudio = async (itemId: string | undefined | null) => {
if (!itemId) return
await deleteDownloadsByIds([itemId])
}
const setAudioCache = (downloads: JellifyDownload[]) => {
mmkv.set(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE, JSON.stringify(downloads))
}
export const getAudioCache = (): JellifyDownload[] => {
const existingRaw = mmkv.getString(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE)
let existingArray: JellifyDownload[] = []
try {
if (existingRaw) {
existingArray = JSON.parse(existingRaw)
}
} catch (error) {
//Ignore
}
return existingArray
}
const stripFileScheme = (path: string) => path.replace('file://', '')
const isLocalFile = (path: string) =>
path.startsWith('file://') || path.startsWith(RNFS.DocumentDirectoryPath)
const deleteLocalFileIfExists = async (
path: string | undefined,
fallbackSize?: number,
): Promise<number> => {
if (!path || !isLocalFile(path)) return 0
const normalizedPath = stripFileScheme(path)
try {
const exists = await RNFS.exists(normalizedPath)
let size = fallbackSize ?? 0
if (exists && !fallbackSize) {
const stat = await RNFS.stat(normalizedPath)
size = Number(stat.size)
}
if (exists) await RNFS.unlink(normalizedPath)
return size
} catch (error) {
console.warn('Failed to delete file', normalizedPath, error)
return 0
}
}
const deleteDownloadAssets = async (download: JellifyDownload): Promise<number> => {
let freedBytes = 0
freedBytes += await deleteLocalFileIfExists(download.path, download.fileSizeBytes)
freedBytes += await deleteLocalFileIfExists(download.artwork!, download.artworkSizeBytes)
return freedBytes
}
export const deleteDownloadsByIds = async (
itemIds: (string | null | undefined)[],
): Promise<DeleteDownloadsResult> => {
const targets = new Set(itemIds.filter(Boolean) as string[])
if (targets.size === 0)
return {
deletedCount: 0,
failedCount: 0,
freedBytes: 0,
}
const downloads = getAudioCache()
const remaining: JellifyDownload[] = []
let freedBytes = 0
let deletedCount = 0
let failedCount = 0
for (const download of downloads) {
if (!targets.has(download.id as string)) {
remaining.push(download)
continue
}
try {
freedBytes += await deleteDownloadAssets(download)
deletedCount += 1
} catch (error) {
failedCount += 1
remaining.push(download)
console.error('Failed to delete download', download.id, error)
}
}
setAudioCache(remaining)
queryClient.invalidateQueries(AUDIO_CACHE_QUERY)
return {
deletedCount,
failedCount,
freedBytes,
}
}
export const deleteAudioCache = async (): Promise<DeleteDownloadsResult> => {
const downloads = getAudioCache()
const result = await deleteDownloadsByIds(downloads.map((download) => download.id))
mmkv.remove(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE)
return result
}
export const purneAudioCache = async () => {
const existingRaw = mmkv.getString(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE)
if (!existingRaw) return
let existingArray: JellifyDownload[] = []
try {
existingArray = JSON.parse(existingRaw)
} catch (e) {
return
}
const autoDownloads = existingArray
.filter((item) => item.isAutoDownloaded)
.sort((a, b) => new Date(a.savedAt).getTime() - new Date(b.savedAt).getTime()) // oldest first
const excess = autoDownloads.length - (AUDIO_CACHE_LIMIT ?? 20)
if (excess <= 0) return
// Remove the oldest `excess` files
const itemsToDelete = autoDownloads.slice(0, excess)
for (const item of itemsToDelete) {
await deleteDownloadAssets(item)
existingArray = existingArray.filter((i) => i.id !== item.id)
}
mmkv.set(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE, JSON.stringify(existingArray))
}
export const setAudioCacheLimit = (limit: number) => {
mmkv.set(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE_LIMIT, limit)
}
export const getAudioCacheLimit = () => {
return mmkv.getNumber(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE_LIMIT)
}
-25
View File
@@ -1,25 +0,0 @@
import { BaseItemDto, DeviceProfile } from '@jellyfin/sdk/lib/generated-client'
import { getAudioCache, saveAudio } from '../offlineModeUtils'
import { mapDtoToTrack } from '../../../../utils/mapping/item-to-track'
import { getApi } from '../../../../stores'
export default async function saveAudioItem(
item: BaseItemDto,
deviceProfile: DeviceProfile,
autoCached: boolean = false,
) {
const api = getApi()
if (!api) return Promise.reject('API Instance not set')
const downloadedTracks = getAudioCache()
// If we already have this track downloaded, resolve the promise
if (downloadedTracks?.filter((download) => download.id === item.Id).length ?? 0 > 0)
return Promise.resolve(false)
const track = await mapDtoToTrack(item, deviceProfile)
// TODO: fix download progresses
return saveAudio(track, () => {}, autoCached)
}
@@ -1,21 +1,26 @@
import JellifyTrack from '../../../../types/JellifyTrack'
import { TrackExtraPayload } from '../../../../types/JellifyTrack'
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api'
import { Api } from '@jellyfin/sdk'
import { TrackItem } from 'react-native-nitro-player'
import getTrackDto, { getTrackMediaSourceInfo } from '../../../../utils/mapping/track-extra-payload'
import { getApi } from '../../../../stores'
export default async function reportPlaybackCompleted(track: TrackItem): Promise<void> {
const api = getApi()
export default async function reportPlaybackCompleted(
api: Api | undefined,
track: JellifyTrack,
): Promise<void> {
if (!api) return Promise.reject('API instance not set')
const { sessionId, item, mediaSourceInfo, id } = track
const { id } = track
const { sessionId } = track.extraPayload as TrackExtraPayload
const item = getTrackDto(track)
const mediaSourceInfo = getTrackMediaSourceInfo(track)
try {
await getPlaystateApi(api).reportPlaybackStopped({
playbackStopInfo: {
SessionId: sessionId,
ItemId: id,
PositionTicks: mediaSourceInfo?.RunTimeTicks || item.RunTimeTicks,
PositionTicks: mediaSourceInfo?.RunTimeTicks || item?.RunTimeTicks,
},
})
} catch (error) {
@@ -1,16 +1,19 @@
import JellifyTrack from '../../../../types/JellifyTrack'
import { convertSecondsToRunTimeTicks } from '../../../../utils/mapping/ticks-to-seconds'
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api'
import { Api } from '@jellyfin/sdk'
import { TrackItem } from 'react-native-nitro-player/lib/types/PlayerQueue'
import { TrackExtraPayload } from '../../../../types/JellifyTrack'
export default async function reportPlaybackProgress(
api: Api | undefined,
track: JellifyTrack,
track: TrackItem,
position: number,
): Promise<void> {
if (!api) return Promise.reject('API instance not set')
const { sessionId, id } = track
const { id } = track
const { sessionId } = track.extraPayload as TrackExtraPayload
try {
await getPlaystateApi(api).reportPlaybackProgress({
@@ -2,7 +2,7 @@ import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api'
import { convertSecondsToRunTimeTicks } from '../../../../utils/mapping/ticks-to-seconds'
import { getApi } from '../../../../stores'
import { TrackItem } from 'react-native-nitro-player'
import { getTrackExtraPayload } from '../../../../types/JellifyTrack'
import { TrackExtraPayload } from '../../../../types/JellifyTrack'
export default async function reportPlaybackStarted(
track: TrackItem,
@@ -12,7 +12,7 @@ export default async function reportPlaybackStarted(
if (!api) return Promise.reject('API instance not set')
const { sessionId } = getTrackExtraPayload(track)
const { sessionId } = track.extraPayload as TrackExtraPayload
try {
await getPlaystateApi(api).reportPlaybackStart({
@@ -1,16 +1,18 @@
import JellifyTrack from '../../../../types/JellifyTrack'
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api'
import { convertSecondsToRunTimeTicks } from '../../../../utils/mapping/ticks-to-seconds'
import { Api } from '@jellyfin/sdk/lib/api'
import { TrackItem } from 'react-native-nitro-player'
import { TrackExtraPayload } from '../../../../types/JellifyTrack'
export default async function reportPlaybackStopped(
api: Api | undefined,
track: JellifyTrack,
track: TrackItem,
lastPosition?: number | undefined,
): Promise<void> {
if (!api) return Promise.reject('API instance not set')
const { sessionId, id } = track
const { sessionId } = track.extraPayload as TrackExtraPayload
const { id } = track
try {
await getPlaystateApi(api).reportPlaybackStopped({
@@ -1,8 +0,0 @@
import { getAudioCache } from '../../../mutations/download/offlineModeUtils'
import DownloadQueryKeys from '../keys'
export const AUDIO_CACHE_QUERY = {
queryKey: [DownloadQueryKeys.DownloadedTracks],
queryFn: getAudioCache,
staleTime: Infinity, // Never stale, we will manually refetch when downloads are completed
}
-21
View File
@@ -1,21 +0,0 @@
import { QueryKeys } from '../../../enums/query-keys'
import { useQuery } from '@tanstack/react-query'
import fetchStorageInUse from './utils/storage-in-use'
import { AUDIO_CACHE_QUERY } from './constants'
export const useStorageInUse = () =>
useQuery({
queryKey: [QueryKeys.StorageInUse],
queryFn: fetchStorageInUse,
})
export const useAllDownloadedTracks = () => useQuery(AUDIO_CACHE_QUERY)
export const useDownloadedTracks = (itemIds: (string | null | undefined)[]) =>
useAllDownloadedTracks().data?.filter((download) => itemIds.includes(download.id))
export const useDownloadedTrack = (itemId: string | null | undefined) =>
useDownloadedTracks([itemId])?.at(0)
export const useIsDownloaded = (itemIds: (string | null | undefined)[]) =>
useDownloadedTracks(itemIds)?.length === itemIds.length && itemIds.length > 0
-6
View File
@@ -1,6 +0,0 @@
enum DownloadQueryKeys {
DownloadedTrack = 'DOWNLOADED_TRACK',
DownloadedTracks = 'DownloadedTracks',
}
export default DownloadQueryKeys
@@ -1,22 +0,0 @@
import RNFS from 'react-native-fs'
import DeviceInfo from 'react-native-device-info'
type JellifyStorage = {
totalStorage: number
freeSpace: number
storageInUseByJellify: number
}
const fetchStorageInUse: () => Promise<JellifyStorage> = async () => {
const totalStorage = await RNFS.getFSInfo()
const storageInUse = await RNFS.stat(RNFS.DocumentDirectoryPath)
const freeDiskStorage = await DeviceInfo.getFreeDiskStorage()
return {
totalStorage: totalStorage.totalSpace,
freeSpace: freeDiskStorage,
storageInUseByJellify: storageInUse.size,
}
}
export default fetchStorageInUse
+1 -3
View File
@@ -2,10 +2,8 @@ import { useQuery, UseQueryResult } from '@tanstack/react-query'
import LyricsQueryKey from './keys'
import { isUndefined } from 'lodash'
import { fetchRawLyrics } from './utils'
import { getApi, useApi } from '../../../stores'
import { usePlayerQueueStore } from '../../../stores/player/queue'
import { getApi } from '../../../stores'
import { useNowPlaying } from 'react-native-nitro-player'
import JellifyTrack from '../../../types/JellifyTrack'
/**
* A hook that will return a {@link useQuery}
-1
View File
@@ -1,4 +1,3 @@
import JellifyTrack from '@/src/types/JellifyTrack'
import { TrackItem } from 'react-native-nitro-player'
const LyricsQueryKey = (track: TrackItem | null) => ['TRACK_LYRICS', track?.id]
+6 -4
View File
@@ -10,13 +10,13 @@ import {
import { RefObject, useRef } from 'react'
import flattenInfiniteQueryPages from '../../../utils/query-selectors'
import { ApiLimits } from '../../../configs/query.config'
import { useAllDownloadedTracks } from '../download'
import { queryClient } from '../../../constants/query-client'
import UserDataQueryKey from '../user-data/keys'
import { JellifyUser } from '@/src/types/JellifyUser'
import { useJellifyLibrary, getApi, getUser } from '../../../stores'
import useLibraryStore from '../../../stores/library'
import getTrackDto from '../../../utils/track-extra-payload'
import getTrackDto from '../../../utils/mapping/track-extra-payload'
import { DownloadManager } from 'react-native-nitro-player'
const useTracks: (
artistId?: string,
@@ -63,7 +63,7 @@ const useTracks: (
const finalSortOrder =
sortOrder ?? (isLibrarySortDescending ? SortOrder.Descending : SortOrder.Ascending)
const { data: downloadedTracks } = useAllDownloadedTracks()
const downloadedTracks = DownloadManager.getAllDownloadedTracks()
const trackPageParams = useRef<Set<string>>(new Set<string>())
@@ -118,7 +118,9 @@ const useTracks: (
libraryYearMax,
)
} else {
let items = (downloadedTracks ?? []).map((download) => getTrackDto(download))
let items = (downloadedTracks ?? []).map((download) =>
getTrackDto(download.originalTrack),
)
if (libraryYearMin != null || libraryYearMax != null) {
const min = libraryYearMin ?? 0
const max = libraryYearMax ?? new Date().getFullYear()
+1 -1
View File
@@ -15,11 +15,11 @@ import { QueryKeys } from '../../enums/query-keys'
import { fetchAlbumDiscs } from '../../api/queries/item'
import { useQuery } from '@tanstack/react-query'
import useAddToPendingDownloads, { useIsDownloading } from '../../stores/network/downloads'
import { useIsDownloaded } from '../../api/queries/download'
import AlbumTrackListFooter from './footer'
import AlbumTrackListHeader from './header'
import Animated, { Easing, FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
import { useStorageContext } from '../../providers/Storage'
import useIsDownloaded from '../../hooks/downloads'
/**
* The screen for an Album's track list
+13 -11
View File
@@ -24,13 +24,12 @@ import { StackActions } from '@react-navigation/native'
import TextTicker from 'react-native-text-ticker'
import { TextTickerConfig } from '../Player/component.config'
import { useAddToQueue } from '../../hooks/player/callbacks'
import { useIsDownloaded } from '../../api/queries/download'
import { useDeleteDownloads } from '../../api/mutations/download'
import { triggerHaptic } from '../../hooks/use-haptic-feedback'
import { Platform } from 'react-native'
import { useApi } from '../../stores'
import useAddToPendingDownloads, { useIsDownloading } from '../../stores/network/downloads'
import DeletePlaylistRow from './components/delete-playlist-row'
import { DownloadManager, useDownloadProgress } from 'react-native-nitro-player'
import useDownloadTracks, { useDeleteDownloads } from '../../hooks/downloads/mutations'
type StackNavigation = Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
@@ -229,15 +228,18 @@ function AddToQueueMenuRow({ tracks }: { tracks: BaseItemDto[] }): React.JSX.Ele
}
function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element {
const addToDownloadQueue = useAddToPendingDownloads()
const { isPending, mutate: download } = useDownloadTracks()
const useRemoveDownload = useDeleteDownloads()
const progress = useDownloadProgress({ trackIds: items.map((item) => item.Id!) })
const isDownloaded = useIsDownloaded(items.map(({ Id }) => Id))
const isDownloaded =
items.filter((item) =>
DownloadManager.getAllDownloadedTracks()
.map((download) => download.trackId)
.includes(item.Id!),
).length === items.length
const removeDownloads = () => useRemoveDownload(items.map(({ Id }) => Id))
const isPending = useIsDownloading(items)
const removeDownloads = useDeleteDownloads()
return isPending ? (
<ListItem
@@ -260,7 +262,7 @@ function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element
backgroundColor={'transparent'}
gap={'$2.5'}
justifyContent='flex-start'
onPress={() => addToDownloadQueue(items)}
onPress={() => download(items)}
pressStyle={{ opacity: 0.5 }}
>
<Icon
@@ -277,7 +279,7 @@ function DownloadMenuRow({ items }: { items: BaseItemDto[] }): React.JSX.Element
backgroundColor={'transparent'}
gap={'$2.5'}
justifyContent='flex-start'
onPress={removeDownloads}
onPress={() => removeDownloads.mutate(items)}
pressStyle={{ opacity: 0.5 }}
>
<Icon small color='$warning' name='broom' />
@@ -9,7 +9,6 @@ import DownloadedIcon from '../downloaded-icon'
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'
import { useSwipeableRowContext } from '../swipeable-row-context'
import { isExplicit } from '../../../../utils/trackDetails'
import JellifyTrack from '@/src/types/JellifyTrack'
export interface TrackRowContentProps {
track: BaseItemDto
@@ -152,7 +151,7 @@ export default function TrackRowContent({
>
{trackName}
</Text>
{!shouldShowArtists && isExplicit(track as JellifyTrack) && (
{!shouldShowArtists && isExplicit(track) && (
<XStack alignSelf='center' paddingLeft='$2'>
<Icon
name='alpha-e-box-outline'
@@ -173,7 +172,7 @@ export default function TrackRowContent({
>
{artistsText}
</Text>
{isExplicit(track as JellifyTrack) && (
{isExplicit(track) && (
<XStack alignSelf='center' paddingTop='$1' paddingLeft='$1'>
<Icon
name='alpha-e-box-outline'
@@ -10,7 +10,6 @@ import navigationRef from '../../../../../navigation'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { BaseStackParamList } from '../../../../screens/types'
import { useAddToQueue, useLoadNewQueue } from '../../../../hooks/player/callbacks'
import { useDownloadedTrack } from '../../../../api/queries/download'
import SwipeableRow from '../SwipeableRow'
import { useSwipeSettingsStore } from '../../../../stores/settings/swipe'
import { buildSwipeConfig } from '../../helpers/swipe-actions'
@@ -20,6 +19,7 @@ import { useAddFavorite, useRemoveFavorite } from '../../../../api/mutations/fav
import { StackActions } from '@react-navigation/native'
import { useHideRunTimesSetting } from '../../../../stores/settings/app'
import TrackRowContent from './content'
import useIsDownloaded from '../../../../hooks/downloads'
export interface TrackProps {
track: BaseItemDto
@@ -66,7 +66,7 @@ export default function Track({
const addToQueue = useAddToQueue()
const [networkStatus] = useNetworkStatus()
const offlineAudio = useDownloadedTrack(track.Id)
const isDownloaded = useIsDownloaded([track.Id!])
const { mutate: addFavorite } = useAddFavorite()
const { mutate: removeFavorite } = useRemoveFavorite()
@@ -119,7 +119,7 @@ export default function Track({
const textColor = isPlaying
? theme.primary.val
: isOffline
? offlineAudio
? isDownloaded
? undefined
: theme.neutral.val
: undefined
@@ -1,8 +1,7 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import Icon from './icon'
import Animated, { Easing, FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
import { memo } from 'react'
import { useIsDownloaded } from '../../../api/queries/download'
import useIsDownloaded from '../../../hooks/downloads'
function DownloadedIcon({ item }: { item: BaseItemDto }) {
const isDownloaded = useIsDownloaded([item.Id])
@@ -20,8 +19,4 @@ function DownloadedIcon({ item }: { item: BaseItemDto }) {
)
}
// Memoize the component to prevent unnecessary re-renders
export default memo(DownloadedIcon, (prevProps, nextProps) => {
// Only re-render if the item ID changes
return prevProps.item.Id === nextProps.item.Id
})
export default DownloadedIcon
@@ -4,7 +4,7 @@ import { useWindowDimensions } from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import { Blurhash } from 'react-native-blurhash'
import useIsLightMode from '../../../hooks/use-is-light-mode'
import getTrackDto from '../../../utils/track-extra-payload'
import getTrackDto from '../../../utils/mapping/track-extra-payload'
import { getBlurhashFromDto } from '../../../utils/parsing/blurhash'
import { useCurrentTrack } from '../../../stores/player/queue'
+1 -1
View File
@@ -14,7 +14,7 @@ import navigationRef from '../../../../navigation'
import { useQueueRef, useCurrentTrack } from '../../../stores/player/queue'
import TextTicker from 'react-native-text-ticker'
import { TextTickerConfig } from '../component.config'
import getTrackDto from '../../../utils/track-extra-payload'
import getTrackDto from '../../../utils/mapping/track-extra-payload'
export default function PlayerHeader(): React.JSX.Element {
const queueRef = useQueueRef()
@@ -1,21 +1,19 @@
import { Spacer, Square } from 'tamagui'
import { Square } from 'tamagui'
import { Text } from '../../Global/helpers/text'
import navigationRef from '../../../../navigation'
import { parseBitrateFromTranscodingUrl } from '../../../utils/parsing/url'
import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client'
import { SourceType } from '../../../types/JellifyTrack'
import { BUTTON_PRESS_STYLES } from '../../../configs/style.config'
import { useDownloadProgress } from 'react-native-nitro-player'
interface QualityBadgeProps {
item: BaseItemDto
mediaSourceInfo: MediaSourceInfo
sourceType: SourceType
}
export default function QualityBadge({
item,
mediaSourceInfo,
sourceType,
}: QualityBadgeProps): React.JSX.Element {
const container = mediaSourceInfo.TranscodingContainer || mediaSourceInfo.Container
const transcodingUrl = mediaSourceInfo.TranscodingUrl
@@ -24,6 +22,8 @@ export default function QualityBadge({
? parseBitrateFromTranscodingUrl(transcodingUrl)
: mediaSourceInfo.Bitrate
const isDownloaded = useDownloadProgress({ trackIds: [item.Id!] }).overallProgress === 1
return bitrate && container ? (
<Square
justifyContent='center'
@@ -34,9 +34,8 @@ export default function QualityBadge({
onPress={() => {
navigationRef.navigate('AudioSpecs', {
item,
streamingMediaSourceInfo: sourceType === 'stream' ? mediaSourceInfo : undefined,
downloadedMediaSourceInfo:
sourceType === 'download' ? mediaSourceInfo : undefined,
streamingMediaSourceInfo: !isDownloaded ? mediaSourceInfo : undefined,
downloadedMediaSourceInfo: isDownloaded ? mediaSourceInfo : undefined,
})
}}
>
@@ -19,8 +19,7 @@ import {
import { runOnJS } from 'react-native-worklets'
import Slider from '@jellify-music/react-native-reanimated-slider'
import { triggerHaptic } from '../../../hooks/use-haptic-feedback'
import { getTrackExtraPayload } from '../../../types/JellifyTrack'
import getTrackDto, { getTrackMediaSourceInfo } from '../../../utils/track-extra-payload'
import getTrackDto, { getTrackMediaSourceInfo } from '../../../utils/mapping/track-extra-payload'
export default function Scrubber(): React.JSX.Element {
const seekTo = useSeekTo()
@@ -110,11 +109,7 @@ export default function Scrubber(): React.JSX.Element {
<YStack alignItems='center' justifyContent='center' flex={2}>
{nowPlaying && mediaInfo && displayAudioQualityBadge ? (
<QualityBadge
item={item!}
sourceType={getTrackExtraPayload(nowPlaying).sourceType}
mediaSourceInfo={mediaInfo}
/>
<QualityBadge item={item!} mediaSourceInfo={mediaInfo} />
) : (
<Spacer />
)}
+4 -11
View File
@@ -19,8 +19,8 @@ import { useCurrentTrack } from '../../../stores/player/queue'
import { useApi } from '../../../stores'
import { isExplicit } from '../../../utils/trackDetails'
import { triggerHaptic } from '../../../hooks/use-haptic-feedback'
import { MediaSourceInfo, NameGuidPair } from '@jellyfin/sdk/lib/generated-client'
import getTrackDto from '../../../utils/track-extra-payload'
import { MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client'
import getTrackDto from '../../../utils/mapping/track-extra-payload'
type SongInfoProps = {
// Shared animated value coming from Player to drive overlay icons
@@ -136,7 +136,7 @@ export default function SongInfo({ swipeX }: SongInfoProps = {}): React.JSX.Elem
<Text fontSize={'$6'} color={'$color'} onPress={handleArtistPress}>
{currentTrack?.artist ?? 'Unknown Artist'}
</Text>
{isExplicit(currentTrack) && (
{isExplicit(item) && (
<XStack alignSelf='center' paddingTop={5.3} paddingLeft='$1'>
<Icon name='alpha-e-box-outline' color={'$color'} xsmall />
</XStack>
@@ -147,14 +147,7 @@ export default function SongInfo({ swipeX }: SongInfoProps = {}): React.JSX.Elem
<XStack justifyContent='flex-end' alignItems='center' flexShrink={1} gap={'$3'}>
<Icon name='dots-horizontal-circle-outline' onPress={openContextMenu} />
{currentTrack && currentTrack.extraPayload && (
<FavoriteButton
item={{
Id: currentTrack.id,
Name: currentTrack.title,
}}
/>
)}
{currentTrack && item && <FavoriteButton item={item} />}
</XStack>
</XStack>
)
+1 -1
View File
@@ -26,7 +26,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import ItemImage from '../Global/components/image'
import { usePrevious, useSkip } from '../../hooks/player/callbacks'
import { useCurrentTrack } from '../../stores/player/queue'
import getTrackDto from '../../utils/track-extra-payload'
import getTrackDto from '../../utils/mapping/track-extra-payload'
export default function Miniplayer(): React.JSX.Element | null {
const nowPlaying = useCurrentTrack()
+1 -1
View File
@@ -31,11 +31,11 @@ import Animated, {
import { FlashList, ListRenderItem } from '@shopify/flash-list'
import { Text } from '../Global/helpers/text'
import { RefreshControl } from 'react-native'
import { useIsDownloaded } from '../../api/queries/download'
import useAddToPendingDownloads, { useIsDownloading } from '../../stores/network/downloads'
import { useStorageContext } from '../../providers/Storage'
import { queryClient } from '../../constants/query-client'
import { PlaylistTracksQueryKey } from '../../api/queries/playlist/keys'
import useIsDownloaded from '../../hooks/downloads'
export default function Playlist({
playlist,
-1
View File
@@ -5,7 +5,6 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { Text, XStack } from 'tamagui'
import { useLayoutEffect, useRef, useState } from 'react'
import { LayoutChangeEvent, useWindowDimensions } from 'react-native'
import JellifyTrack, { getTrackExtraPayload } from '../../types/JellifyTrack'
import { useRemoveFromQueue, useReorderQueue, useSkip } from '../../hooks/player/callbacks'
import { useCurrentIndex, usePlayerQueueStore, useQueueRef } from '../../stores/player/queue'
import Sortable from 'react-native-sortables'
@@ -2,7 +2,6 @@ import SettingsListGroup from './settings-list-group'
import { SwitchWithLabel } from '../../Global/helpers/switch-with-label'
import { RadioGroupItemWithLabel } from '../../Global/helpers/radio-group-item-with-label'
import { RadioGroup } from 'tamagui'
import { useAllDownloadedTracks } from '../../../api/queries/download'
import {
DownloadQuality,
useAutoDownload,
@@ -11,11 +10,12 @@ import {
import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { SettingsStackParamList } from '../../../screens/Settings/types'
import { useDownloadedTracks } from 'react-native-nitro-player'
export default function StorageTab(): React.JSX.Element {
const [autoDownload, setAutoDownload] = useAutoDownload()
const [downloadQuality, setDownloadQuality] = useDownloadQuality()
const { data: downloadedTracks } = useAllDownloadedTracks()
const { downloadedTracks } = useDownloadedTracks()
const navigation =
useNavigation<NativeStackNavigationProp<SettingsStackParamList, 'Settings'>>()
-3
View File
@@ -22,7 +22,6 @@ import {
useThemeSetting,
} from '../stores/settings/app'
import { GLITCHTIP_DSN } from '../configs/config'
import useDownloadProcessor from '../hooks/use-download-processor'
/**
* The main component for the Jellify app. Children are wrapped in the {@link JellifyProvider}
* @returns The {@link Jellify} component
@@ -84,8 +83,6 @@ function App(): React.JSX.Element {
}
}, [sendMetrics])
useDownloadProcessor()
return (
<StorageProvider>
<PlayerProvider />
+13
View File
@@ -0,0 +1,13 @@
import { useDownloadedTracks } from 'react-native-nitro-player'
const useIsDownloaded = (trackIds: (string | null | undefined)[]) => {
const { downloadedTracks } = useDownloadedTracks()
const downloadedTrackIds = new Set(
downloadedTracks.map((download) => download.originalTrack.id),
)
return trackIds.every((id) => id != null && downloadedTrackIds.has(id))
}
export default useIsDownloaded
+32
View File
@@ -0,0 +1,32 @@
import { useDownloadingDeviceProfileStore } from '../../stores/device-profile'
import { mapDtoToTrack } from '../../utils/mapping/item-to-track'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { useMutation } from '@tanstack/react-query'
import { DownloadManager, PlayerQueue } from 'react-native-nitro-player'
const useDownloadTracks = () =>
useMutation({
mutationFn: async (items: BaseItemDto[]) => {
const { deviceProfile } = useDownloadingDeviceProfileStore.getState()
const playlistId = PlayerQueue.createPlaylist('Context Menu Download')
const tracks = await Promise.all(
items.map((item) => mapDtoToTrack(item, deviceProfile, 'download')),
)
PlayerQueue.addTracksToPlaylist(playlistId, tracks)
await DownloadManager.downloadPlaylist(playlistId, tracks)
},
})
export const useDeleteDownloads = () =>
useMutation({
mutationFn: async (items: BaseItemDto[]) => {
const trackIds = items.map((item) => item.Id)
await Promise.all(trackIds.map((id) => DownloadManager.deleteDownloadedTrack(id!)))
},
})
export default useDownloadTracks
+4 -13
View File
@@ -4,13 +4,13 @@ import { AddToQueueMutation, QueueMutation, QueueOrderMutation } from './interfa
import { QueuingType } from '../../enums/queuing-type'
import Toast from 'react-native-toast-message'
import { handleDeshuffle, handleShuffle } from './functions/shuffle'
import JellifyTrack from '@/src/types/JellifyTrack'
import calculateTrackVolume from './functions/normalization'
import calculateTrackVolume from '../../utils/audio/normalization'
import usePlayerEngineStore, { PlayerEngine } from '../../stores/player/engine'
import { useRemoteMediaClient } from 'react-native-google-cast'
import { triggerHaptic } from '../use-haptic-feedback'
import { usePlayerQueueStore } from '../../stores/player/queue'
import { PlayerQueue, RepeatMode, TrackPlayer, TrackPlayerState } from 'react-native-nitro-player'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
/**
* A mutation to handle toggling the playback state
@@ -124,9 +124,7 @@ export const useAddToQueue = () => {
} finally {
const playlistId = PlayerQueue.getCurrentPlaylistId()!
usePlayerQueueStore
.getState()
.setQueue(PlayerQueue.getPlaylist(playlistId)!.tracks as JellifyTrack[])
usePlayerQueueStore.getState().setQueue(PlayerQueue.getPlaylist(playlistId)!.tracks)
}
}
}
@@ -219,14 +217,7 @@ export const useToggleShuffle = () => {
const newQueue = PlayerQueue.getPlaylist(PlayerQueue.getCurrentPlaylistId()!)!.tracks
usePlayerQueueStore.getState().setQueue(newQueue as JellifyTrack[])
usePlayerQueueStore.getState().setQueue(newQueue)
usePlayerQueueStore.getState().setShuffled(!shuffled)
}
}
export const useAudioNormalization = () => async (track: JellifyTrack) => {
const volume = calculateTrackVolume(track)
await TrackPlayer.setVolume(volume)
return volume
}
-10
View File
@@ -1,10 +0,0 @@
import { usePlayerQueueStore } from '../../../stores/player/queue'
import { TrackItem } from 'react-native-nitro-player'
export function handleActiveTrackChanged(
activeTrack: TrackItem | undefined,
activeIndex: number | undefined,
): void {
usePlayerQueueStore.getState().setCurrentTrack(activeTrack)
usePlayerQueueStore.getState().setCurrentIndex(activeIndex)
}
+8 -4
View File
@@ -4,13 +4,17 @@ import { filterTracksOnNetworkStatus } from './utils/queue'
import { AddToQueueMutation, QueueMutation } from '../interfaces'
import { shuffleJellifyTracks } from './utils/shuffle'
import JellifyTrack from '../../../types/JellifyTrack'
import { setNewQueue, usePlayerQueueStore } from '../../../stores/player/queue'
import { getAudioCache } from '../../../api/mutations/download/offlineModeUtils'
import { isNull } from 'lodash'
import { useStreamingDeviceProfileStore } from '../../../stores/device-profile'
import { useNetworkStore } from '../../../stores/network'
import { PlayerQueue, TrackItem, TrackPlayer } from 'react-native-nitro-player'
import {
DownloadManager,
PlayerQueue,
TrackItem,
TrackPlayer,
useDownloadedTracks,
} from 'react-native-nitro-player'
type LoadQueueResult = {
finalStartIndex: number
@@ -32,7 +36,7 @@ export async function loadQueue({
// Get the item at the start index
const startingTrack = tracklist[index]
const downloadedTracks = getAudioCache()
const downloadedTracks = DownloadManager.getAllDownloadedTracks()
const availableAudioItems = filterTracksOnNetworkStatus(
networkStatus as networkStatusTypes,
+5 -8
View File
@@ -2,13 +2,12 @@ import Toast from 'react-native-toast-message'
import { shuffleJellifyTracks } from './utils/shuffle'
import { isUndefined } from 'lodash'
import { usePlayerQueueStore } from '../../../stores/player/queue'
import { PlayerQueue, TrackItem, TrackPlayer } from 'react-native-nitro-player'
import { DownloadManager, PlayerQueue, TrackItem, TrackPlayer } from 'react-native-nitro-player'
import { useStreamingDeviceProfileStore } from '../../../stores/device-profile'
import { getApi, getLibrary, getUser } from '../../../stores'
import useLibraryStore from '../../../stores/library'
import { queryClient } from '../../../constants/query-client'
import { JellifyDownload } from '../../../types/JellifyDownload'
import { AUDIO_CACHE_QUERY } from '../../../api/queries/download/constants'
import UserDataQueryKey from '../../../api/queries/user-data/keys'
import {
BaseItemDto,
@@ -21,7 +20,7 @@ import {
import { ApiLimits } from '../../../configs/query.config'
import { nitroFetch } from '../../../api/utils/nitro'
import { mapDtoToTrack } from '../../../utils/mapping/item-to-track'
import getTrackDto from '../../../utils/track-extra-payload'
import getTrackDto from '../../../utils/mapping/track-extra-payload'
export async function handleShuffle(): Promise<TrackItem[]> {
const playlistId = PlayerQueue.getCurrentPlaylistId()
@@ -77,9 +76,7 @@ export async function handleShuffle(): Promise<TrackItem[]> {
if (isDownloaded) {
// For downloaded tracks, get from cache and filter client-side
const downloadedTracks = queryClient.getQueryData<JellifyDownload[]>(
AUDIO_CACHE_QUERY.queryKey,
)
const downloadedTracks = DownloadManager.getAllDownloadedTracks()
if (!downloadedTracks || downloadedTracks.length === 0) {
Toast.show({
@@ -97,7 +94,7 @@ export async function handleShuffle(): Promise<TrackItem[]> {
const min = yearMin ?? 0
const max = yearMax ?? new Date().getFullYear()
filteredDownloads = filteredDownloads.filter((download) => {
const y = getTrackDto(download)?.ProductionYear
const y = getTrackDto(download.originalTrack)?.ProductionYear
return y != null && y >= min && y <= max
})
}
@@ -106,7 +103,7 @@ export async function handleShuffle(): Promise<TrackItem[]> {
if (isFavorites) {
filteredDownloads = filteredDownloads.filter((download) => {
const userData = queryClient.getQueryData(
UserDataQueryKey(user, download.id),
UserDataQueryKey(user, download.originalTrack.id),
) as UserItemDataDto | undefined
return userData?.IsFavorite === true
})
+3 -20
View File
@@ -1,29 +1,12 @@
import _, { isNull, isUndefined } from 'lodash'
import JellifyTrack from '../../../../types/JellifyTrack'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { JellifyDownload } from '../../../../types/JellifyDownload'
import { networkStatusTypes } from '../../../../components/Network/internetConnectionWatcher'
import { QueuingType } from '../../../../enums/queuing-type'
export function buildNewQueue(
existingQueue: JellifyTrack[],
tracksToInsert: JellifyTrack[],
insertIndex: number,
) {
let newQueue: JellifyTrack[] = []
if (_.isEmpty(existingQueue)) newQueue = tracksToInsert
else {
newQueue = _.cloneDeep(existingQueue).splice(insertIndex, 0, ...tracksToInsert)
}
return newQueue
}
import { DownloadedTrack } from 'react-native-nitro-player'
export function filterTracksOnNetworkStatus(
networkStatus: networkStatusTypes | undefined | null,
queuedItems: BaseItemDto[],
downloadedTracks: JellifyDownload[],
downloadedTracks: DownloadedTrack[],
) {
if (
isUndefined(networkStatus) ||
@@ -33,6 +16,6 @@ export function filterTracksOnNetworkStatus(
return queuedItems
else
return queuedItems.filter((item) =>
downloadedTracks.map((download) => download.id).includes(item.Id!),
downloadedTracks.map((download) => download.trackId).includes(item.Id!),
)
}
-64
View File
@@ -1,64 +0,0 @@
import { useEffect } from 'react'
import {
useAddToCompletedDownloads,
useAddToCurrentDownloads,
useAddToFailedDownloads,
useDownloadsStore,
useRemoveFromCurrentDownloads,
useRemoveFromPendingDownloads,
} from '../stores/network/downloads'
import { MAX_CONCURRENT_DOWNLOADS } from '../configs/download.config'
import { useAllDownloadedTracks } from '../api/queries/download'
import { saveAudio } from '../api/mutations/download/offlineModeUtils'
const useDownloadProcessor = () => {
const { pendingDownloads, currentDownloads } = useDownloadsStore()
const { data: downloadedTracks } = useAllDownloadedTracks()
const addToCurrentDownloads = useAddToCurrentDownloads()
const removeFromCurrentDownloads = useRemoveFromCurrentDownloads()
const removeFromPendingDownloads = useRemoveFromPendingDownloads()
const addToCompletedDownloads = useAddToCompletedDownloads()
const addToFailedDownloads = useAddToFailedDownloads()
const { refetch: refetchDownloadedTracks } = useAllDownloadedTracks()
return useEffect(() => {
if (pendingDownloads.length > 0 && currentDownloads.length < MAX_CONCURRENT_DOWNLOADS) {
const availableSlots = MAX_CONCURRENT_DOWNLOADS - currentDownloads.length
const filesToStart = pendingDownloads.slice(0, availableSlots)
console.debug('Downloading from queue')
filesToStart.forEach((file) => {
addToCurrentDownloads(file)
removeFromPendingDownloads(file)
if (downloadedTracks?.some((t) => t.id === file.id)) {
removeFromCurrentDownloads(file)
addToCompletedDownloads(file)
return
}
saveAudio(file, () => {}, false).then((success) => {
removeFromCurrentDownloads(file)
if (success) {
addToCompletedDownloads(file)
} else {
addToFailedDownloads(file)
}
})
})
}
if (pendingDownloads.length === 0 && currentDownloads.length === 0) {
refetchDownloadedTracks()
}
}, [pendingDownloads.length, currentDownloads.length])
}
export default useDownloadProcessor
+33 -7
View File
@@ -1,12 +1,13 @@
import { isUndefined } from 'lodash'
import { TrackPlayer, PlayerQueue } from 'react-native-nitro-player'
import { TrackPlayer, PlayerQueue, DownloadManager } from 'react-native-nitro-player'
import { usePlayerQueueStore } from '../../../stores/player/queue'
import { handleActiveTrackChanged } from '../../../hooks/player/functions'
import JellifyTrack from '../../../types/JellifyTrack'
import reportPlaybackStarted from '../../../api/mutations/playback/functions/playback-started'
import { usePlayerSettingsStore } from '../../../stores/settings/player'
import calculateTrackVolume from '../../../hooks/player/functions/normalization'
import calculateTrackVolume from '../../../utils/audio/normalization'
import { setPlaybackPosition, usePlayerPlaybackStore } from '../../../stores/player/playback'
import { useUsageSettingsStore } from '../../../stores/settings/usage'
import isPlaybackFinished from '../../../api/mutations/playback/utils'
import reportPlaybackCompleted from '../../../api/mutations/playback/functions/playback-completed'
export default function Initialize() {
restoreFromStorage()
@@ -17,14 +18,39 @@ export default function Initialize() {
function registerEventHandlers() {
TrackPlayer.onChangeTrack(async (track, reason) => {
console.debug('Track changed:', reason)
handleActiveTrackChanged(track, (await TrackPlayer.getState()).currentIndex)
const { currentIndex } = await TrackPlayer.getState()
// If the track was changed because the current track ended,
// report playback for the track that just ended and automatically
// download the track (if enabled in settings)
if (reason && reason === 'end') {
const previousTrack = usePlayerQueueStore.getState().queue[currentIndex - 1]
const lastPosition = usePlayerPlaybackStore.getState().position
if (previousTrack && isPlaybackFinished(lastPosition, previousTrack.duration)) {
reportPlaybackCompleted(previousTrack)
}
const { autoDownload } = useUsageSettingsStore.getState()
if (previousTrack && autoDownload) {
DownloadManager.downloadTrack(previousTrack)
}
}
// Then we can update the store...
usePlayerQueueStore.getState().setCurrentIndex(currentIndex)
usePlayerQueueStore.getState().setCurrentTrack(track)
// ...report that playback has started for the new track...
reportPlaybackStarted(track, 0)
const enableAudioNormalization = usePlayerSettingsStore.getState().enableAudioNormalization
const { enableAudioNormalization } = usePlayerSettingsStore.getState()
// ...and apply audio normalization if enabled in settings
if (enableAudioNormalization) {
const volume = calculateTrackVolume(track as JellifyTrack)
const volume = calculateTrackVolume(track)
TrackPlayer.setVolume(volume)
}
})
+25 -66
View File
@@ -1,11 +1,12 @@
import React, { PropsWithChildren, createContext, use, useContext, useState } from 'react'
import { useAllDownloadedTracks, useStorageInUse } from '../../api/queries/download'
import { JellifyDownload, JellifyDownloadProgress } from '../../types/JellifyDownload'
import {
DeleteDownloadsResult,
deleteDownloadsByIds,
} from '../../api/mutations/download/offlineModeUtils'
import { useDownloadProgress } from '../../stores/network/downloads'
import {
DownloadedTrack,
DownloadManager,
useDownloadedTracks,
useDownloadStorage,
} from 'react-native-nitro-player'
export type StorageSummary = {
totalSpace: number
@@ -13,9 +14,6 @@ export type StorageSummary = {
usedByDownloads: number
usedPercentage: number
downloadCount: number
autoDownloadCount: number
manualDownloadCount: number
artworkBytes: number
audioBytes: number
}
@@ -31,17 +29,16 @@ export type CleanupSuggestion = {
export type StorageSelectionState = Record<string, boolean>
interface StorageContextValue {
downloads: JellifyDownload[] | undefined
downloads: DownloadedTrack[] | undefined
summary: StorageSummary | undefined
suggestions: CleanupSuggestion[]
selection: StorageSelectionState
toggleSelection: (itemId: string) => void
clearSelection: () => void
deleteSelection: () => Promise<DeleteDownloadsResult | undefined>
deleteDownloads: (itemIds: string[]) => Promise<DeleteDownloadsResult | undefined>
deleteSelection: () => Promise<void>
deleteDownloads: (itemIds: string[]) => Promise<void>
isDeleting: boolean
refresh: () => Promise<void>
refreshing: boolean
activeDownloadsCount: number
activeDownloads: JellifyDownloadProgress | undefined
}
@@ -51,22 +48,14 @@ const StorageContext = createContext<StorageContextValue | undefined>(undefined)
const THIRTY_DAYS_IN_MS = 1000 * 60 * 60 * 24 * 30
const LARGE_DOWNLOAD_THRESHOLD = 50 * 1024 * 1024 // 50MB
const sumDownloadBytes = (download: JellifyDownload | undefined) => {
const sumDownloadBytes = (download: DownloadedTrack | undefined) => {
if (!download) return 0
return (download.fileSizeBytes ?? 0) + (download.artworkSizeBytes ?? 0)
return download.fileSize ?? 0
}
export function StorageProvider({ children }: PropsWithChildren): React.JSX.Element {
const {
data: downloads,
refetch: refetchDownloads,
isFetching: isFetchingDownloads,
} = useAllDownloadedTracks()
const {
data: storageInfo,
refetch: refetchStorageInfo,
isFetching: isFetchingStorage,
} = useStorageInUse()
const { downloadedTracks: downloads, refresh: refetchDownloads } = useDownloadedTracks()
const { storageInfo: storageInfo, refresh: refetchStorageInfo } = useDownloadStorage()
const activeDownloads = useDownloadProgress()
const [selection, setSelection] = useState<StorageSelectionState>({})
@@ -78,26 +67,16 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem
const summary: StorageSummary | undefined = (() => {
if (!downloads || !storageInfo) return undefined
const audioBytes = downloads.reduce(
(acc, download) => acc + (download.fileSizeBytes ?? 0),
0,
)
const artworkBytes = downloads.reduce(
(acc, download) => acc + (download.artworkSizeBytes ?? 0),
0,
)
const usedByDownloads = audioBytes + artworkBytes
const audioBytes = downloads.reduce((acc, download) => acc + (download.fileSize ?? 0), 0)
const usedByDownloads = audioBytes
return {
totalSpace: storageInfo.totalStorage,
freeSpace: storageInfo.freeSpace,
totalSpace: storageInfo.totalSpace,
freeSpace: storageInfo.availableSpace,
usedByDownloads,
usedPercentage:
storageInfo.totalStorage > 0 ? usedByDownloads / storageInfo.totalStorage : 0,
storageInfo.totalSpace > 0 ? usedByDownloads / storageInfo.totalSpace : 0,
downloadCount: downloads.length,
autoDownloadCount: downloads.filter((download) => download.isAutoDownloaded).length,
manualDownloadCount: downloads.filter((download) => !download.isAutoDownloaded).length,
artworkBytes,
audioBytes,
}
})()
@@ -107,13 +86,12 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem
const now = Date.now()
const staleDownloads = downloads.filter((download) => {
const savedAt = new Date(download.savedAt).getTime()
const savedAt = new Date(download.downloadedAt).getTime()
return Number.isFinite(savedAt) && now - savedAt > THIRTY_DAYS_IN_MS
})
const autoDownloads = downloads.filter((download) => download.isAutoDownloaded)
const largeDownloads = downloads.filter(
(download) => (download.fileSizeBytes ?? 0) > LARGE_DOWNLOAD_THRESHOLD,
(download) => (download.fileSize ?? 0) > LARGE_DOWNLOAD_THRESHOLD,
)
const list: CleanupSuggestion[] = []
@@ -123,7 +101,7 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem
id: 'stale-downloads',
title: 'Unused in 30+ days',
description: 'Remove tracks you have not touched recently.',
itemIds: staleDownloads.map((download) => download.id as string),
itemIds: staleDownloads.map((download) => download.trackId),
freedBytes: staleDownloads.reduce(
(acc, download) => acc + sumDownloadBytes(download),
0,
@@ -131,25 +109,12 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem
count: staleDownloads.length,
})
if (autoDownloads.length)
list.push({
id: 'auto-downloads',
title: 'Auto cached tracks',
description: 'Trim automatically cached music to reclaim space quickly.',
itemIds: autoDownloads.map((download) => download.id as string),
freedBytes: autoDownloads.reduce(
(acc, download) => acc + sumDownloadBytes(download),
0,
),
count: autoDownloads.length,
})
if (largeDownloads.length)
list.push({
id: 'large-downloads',
title: 'Large files',
description: 'High bitrate albums occupying the most space.',
itemIds: largeDownloads.map((download) => download.id as string),
itemIds: largeDownloads.map((download) => download.trackId),
freedBytes: largeDownloads.reduce(
(acc, download) => acc + sumDownloadBytes(download),
0,
@@ -169,20 +134,17 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem
const clearSelection = () => setSelection({})
const deleteDownloads = async (
itemIds: string[],
): Promise<DeleteDownloadsResult | undefined> => {
if (!itemIds.length) return undefined
const deleteDownloads = async (itemIds: string[]): Promise<void> => {
if (!itemIds.length) return
setIsDeleting(true)
try {
const result = await deleteDownloadsByIds(itemIds)
await Promise.all(itemIds.map((id) => DownloadManager.deleteDownloadedTrack(id)))
await Promise.all([refetchDownloads(), refetchStorageInfo()])
setSelection((prev) => {
const updated = { ...prev }
itemIds.forEach((id) => delete updated[id])
return updated
})
return result
} finally {
setIsDeleting(false)
}
@@ -204,8 +166,6 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem
}
}
const refreshing = isFetchingDownloads || isFetchingStorage || isManuallyRefreshing
const value: StorageContextValue = {
downloads,
summary,
@@ -217,7 +177,6 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem
deleteDownloads,
isDeleting,
refresh,
refreshing,
activeDownloadsCount,
activeDownloads,
}
+4 -2
View File
@@ -4,11 +4,11 @@ import { H5, Text } from '../../components/Global/helpers/text'
import Button from '../../components/Global/helpers/button'
import Icon from '../../components/Global/components/icon'
import { useResetQueue } from '../../hooks/player/callbacks'
import { useClearAllDownloads } from '../../api/mutations/download'
import { useJellifyServer } from '../../stores'
import { useNavigation } from '@react-navigation/native'
import { RootStackParamList } from '../types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { DownloadManager } from 'react-native-nitro-player'
export default function SignOutModal({ navigation }: SignOutModalProps): React.JSX.Element {
const [server] = useJellifyServer()
@@ -16,7 +16,9 @@ export default function SignOutModal({ navigation }: SignOutModalProps): React.J
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
const resetQueue = useResetQueue()
const clearDownloads = useClearAllDownloads()
const clearDownloads = () => {
DownloadManager.deleteAllDownloads()
}
return (
<YStack margin={'$6'}>
@@ -8,12 +8,12 @@ import { useStorageContext, CleanupSuggestion } from '../../../providers/Storage
import Icon from '../../../components/Global/components/icon'
import Button from '../../../components/Global/helpers/button'
import { formatBytes } from '../../../utils/formatting/bytes'
import { JellifyDownload, JellifyDownloadProgress } from '../../../types/JellifyDownload'
import { JellifyDownloadProgress } from '../../../types/JellifyDownload'
import { useDeletionToast } from './useDeletionToast'
import { Text } from '../../../components/Global/helpers/text'
import { DownloadedTrack } from 'react-native-nitro-player/lib/types/DownloadTypes'
const getDownloadSize = (download: JellifyDownload) =>
(download.fileSizeBytes ?? 0) + (download.artworkSizeBytes ?? 0)
const getDownloadSize = (download: DownloadedTrack) => download.fileSize ?? 0
const formatSavedAt = (timestamp: string) => {
const parsedDate = new Date(timestamp)
@@ -34,7 +34,6 @@ export default function StorageManagementScreen(): React.JSX.Element {
clearSelection,
deleteDownloads,
refresh,
refreshing,
activeDownloadsCount,
activeDownloads,
} = useStorageContext()
@@ -48,7 +47,7 @@ export default function StorageManagementScreen(): React.JSX.Element {
const sortedDownloads = !downloads
? []
: [...downloads].sort(
(a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime(),
(a, b) => new Date(b.downloadedAt).getTime() - new Date(a.downloadedAt).getTime(),
)
const selectedIds = Object.entries(selection)
@@ -59,7 +58,7 @@ export default function StorageManagementScreen(): React.JSX.Element {
!selectedIds.length || !downloads
? 0
: downloads.reduce((total, download) => {
return new Set(selectedIds).has(download.id as string)
return new Set(selectedIds).has(download.trackId)
? total + getDownloadSize(download)
: total
}, 0)
@@ -68,18 +67,16 @@ export default function StorageManagementScreen(): React.JSX.Element {
if (!suggestion.itemIds.length) return
setApplyingSuggestionId(suggestion.id)
try {
const result = await deleteDownloads(suggestion.itemIds)
if (result?.deletedCount)
showDeletionToast(`Removed ${result.deletedCount} downloads`, result.freedBytes)
await deleteDownloads(suggestion.itemIds)
showDeletionToast(`Removed ${suggestion.itemIds.length} downloads`, 0)
} finally {
setApplyingSuggestionId(null)
}
}
const handleDeleteSingle = async (download: JellifyDownload) => {
const result = await deleteDownloads([download.id as string])
if (result?.deletedCount)
showDeletionToast(`Removed ${download.title ?? 'track'}`, result.freedBytes)
const handleDeleteSingle = async (download: DownloadedTrack) => {
await deleteDownloads([download.trackId])
showDeletionToast(`Removed ${download.originalTrack.title ?? 'track'}`, 0)
}
const handleDeleteAll = () =>
@@ -93,13 +90,9 @@ export default function StorageManagementScreen(): React.JSX.Element {
style: 'destructive',
onPress: async () => {
if (!downloads) return
const allIds = downloads.map((d) => d.id as string)
const result = await deleteDownloads(allIds)
if (result?.deletedCount)
showDeletionToast(
`Removed ${result.deletedCount} downloads`,
result.freedBytes,
)
const allIds = downloads.map((d) => d.trackId)
await deleteDownloads(allIds)
showDeletionToast(`Removed ${allIds.length} downloads`, 0)
},
},
],
@@ -115,24 +108,19 @@ export default function StorageManagementScreen(): React.JSX.Element {
text: 'Clear',
style: 'destructive',
onPress: async () => {
const result = await deleteDownloads(selectedIds)
if (result?.deletedCount) {
showDeletionToast(
`Removed ${result.deletedCount} downloads`,
result.freedBytes,
)
clearSelection()
}
await deleteDownloads(selectedIds)
showDeletionToast(`Removed ${selectedIds.length} downloads`, selectedBytes)
clearSelection()
},
},
],
)
const renderDownloadItem: ListRenderItem<JellifyDownload> = ({ item }) => (
const renderDownloadItem: ListRenderItem<DownloadedTrack> = ({ item }) => (
<DownloadRow
download={item}
isSelected={Boolean(selection[item.id as string])}
onToggle={() => toggleSelection(item.id as string)}
isSelected={Boolean(selection[item.trackId])}
onToggle={() => toggleSelection(item.trackId)}
onDelete={() => {
void handleDeleteSingle(item)
}}
@@ -146,7 +134,10 @@ export default function StorageManagementScreen(): React.JSX.Element {
<FlashList
data={sortedDownloads}
keyExtractor={(item) =>
item.id ?? item.url ?? item.title ?? Math.random().toString()
item.trackId ??
item.originalTrack.url ??
item.originalTrack.title ??
Math.random().toString()
}
contentContainerStyle={{
paddingBottom: insets.bottom + 48,
@@ -171,7 +162,6 @@ export default function StorageManagementScreen(): React.JSX.Element {
</XStack>
<StorageSummaryCard
summary={summary}
refreshing={refreshing}
onRefresh={() => {
void refresh()
}}
@@ -199,7 +189,6 @@ export default function StorageManagementScreen(): React.JSX.Element {
}
ListEmptyComponent={
<EmptyState
refreshing={refreshing}
onRefresh={() => {
void refresh()
}}
@@ -213,14 +202,12 @@ export default function StorageManagementScreen(): React.JSX.Element {
const StorageSummaryCard = ({
summary,
refreshing,
onRefresh,
activeDownloadsCount,
activeDownloads,
onDeleteAll,
}: {
summary: ReturnType<typeof useStorageContext>['summary']
refreshing: boolean
onRefresh: () => void
activeDownloadsCount: number
activeDownloads: JellifyDownloadProgress | undefined
@@ -244,13 +231,7 @@ const StorageSummaryCard = ({
circular
backgroundColor='transparent'
hitSlop={10}
icon={() =>
refreshing ? (
<Spinner size='small' color='$color' />
) : (
<Icon name='refresh' color='$color' />
)
}
icon={<Icon name='refresh' color='$color' />}
onPress={onRefresh}
aria-label='Refresh storage overview'
/>
@@ -282,8 +263,7 @@ const StorageSummaryCard = ({
<YStack gap='$2'>
<ProgressBar progress={summary.usedPercentage} />
<Paragraph color='$borderColor'>
{summary.downloadCount} downloads · {summary.manualDownloadCount} manual
· {summary.autoDownloadCount} auto
{summary.downloadCount} downloads
</Paragraph>
</YStack>
<StatGrid summary={summary} />
@@ -378,7 +358,7 @@ const DownloadRow = ({
onToggle,
onDelete,
}: {
download: JellifyDownload
download: DownloadedTrack
isSelected: boolean
onToggle: () => void
onDelete: () => void
@@ -390,9 +370,9 @@ const DownloadRow = ({
color={isSelected ? '$color' : '$borderColor'}
/>
{download.artwork ? (
{download.localArtworkPath ? (
<Image
source={{ uri: download.artwork, width: 50, height: 50 }}
source={{ uri: download.localArtworkPath, width: 50, height: 50 }}
width={50}
height={50}
borderRadius='$2'
@@ -412,12 +392,15 @@ const DownloadRow = ({
<YStack flex={1} gap='$1'>
<SizableText size='$4' fontWeight='600'>
{download.title ?? 'Unknown track'}
{download.originalTrack.title ?? 'Unknown track'}
</SizableText>
<Paragraph color='$borderColor'>
{download.album ?? 'Unknown album'} · {formatBytes(getDownloadSize(download))}
{download.originalTrack.album ?? 'Unknown album'} ·{' '}
{formatBytes(getDownloadSize(download))}
</Paragraph>
<Paragraph color='$borderColor'>
Saved {formatSavedAt(download.downloadedAt.toString())}
</Paragraph>
<Paragraph color='$borderColor'>Saved {formatSavedAt(download.savedAt)}</Paragraph>
</YStack>
<Button
size='$3'
@@ -435,7 +418,7 @@ const DownloadRow = ({
</Pressable>
)
const EmptyState = ({ refreshing, onRefresh }: { refreshing: boolean; onRefresh: () => void }) => (
const EmptyState = ({ onRefresh }: { onRefresh: () => void }) => (
<YStack padding='$6' alignItems='center' gap='$3'>
<SizableText size='$6' fontWeight='600'>
No offline music yet
@@ -448,13 +431,7 @@ const EmptyState = ({ refreshing, onRefresh }: { refreshing: boolean; onRefresh:
borderWidth={1}
backgroundColor='$background'
onPress={onRefresh}
icon={() =>
refreshing ? (
<Spinner size='small' color='$borderColor' />
) : (
<Icon name='refresh' color='$borderColor' />
)
}
icon={<Icon name='refresh' color='$borderColor' />}
>
Refresh
</Button>
@@ -532,8 +509,6 @@ const StatGrid = ({
}) => (
<XStack gap='$3' flexWrap='wrap'>
<StatChip label='Audio files' value={formatBytes(summary.audioBytes)} />
<StatChip label='Artwork' value={formatBytes(summary.artworkBytes)} />
<StatChip label='Auto downloads' value={`${summary.autoDownloadCount}`} />
</XStack>
)
@@ -9,12 +9,8 @@ import { SettingsStackParamList } from './types'
import { useStorageContext } from '../../providers/Storage'
import { formatBytes } from '../../utils/formatting/bytes'
import { useDeletionToast } from './storage-management/useDeletionToast'
import { JellifyDownload } from '../../types/JellifyDownload'
const getDownloadSize = (download: JellifyDownload) =>
(download.fileSizeBytes ?? 0) + (download.artworkSizeBytes ?? 0)
const formatSavedAt = (timestamp: string) => {
const formatSavedAt = (timestamp: number) => {
const parsedDate = new Date(timestamp)
if (Number.isNaN(parsedDate.getTime())) return 'Unknown save date'
return parsedDate.toLocaleDateString(undefined, {
@@ -32,22 +28,20 @@ export default function StorageSelectionModal({
const { bottom } = useSafeAreaInsets()
const selectedDownloads = useMemo(
() => downloads?.filter((download) => selection[download.id as string]) ?? [],
() => downloads?.filter((download) => selection[download.trackId]) ?? [],
[downloads, selection],
)
const selectedBytes = useMemo(
() => selectedDownloads.reduce((total, download) => total + getDownloadSize(download), 0),
() => selectedDownloads.reduce((total, download) => total + download.fileSize, 0),
[selectedDownloads],
)
const handleDelete = useCallback(async () => {
const result = await deleteSelection()
if (result?.deletedCount) {
showDeletionToast(`Deleted ${result.deletedCount} downloads`, result.freedBytes)
navigation.goBack()
}
}, [deleteSelection, navigation, showDeletionToast])
showDeletionToast(`Deleted ${selectedDownloads.length} downloads`, 0)
navigation.goBack()
}, [deleteSelection, navigation, selectedDownloads.length, showDeletionToast])
const handleClose = useCallback(() => {
navigation.goBack()
@@ -101,17 +95,17 @@ export default function StorageSelectionModal({
<Card borderWidth={1} borderColor='$borderColor' borderRadius='$6' flex={1}>
<ScrollView>
{selectedDownloads.map((download, index) => (
<YStack key={download.id as string}>
<YStack key={download.trackId as string}>
<YStack padding='$3' gap='$1'>
<SizableText fontWeight='600'>
{download.title ?? 'Unknown track'}
{download.originalTrack.title ?? 'Unknown track'}
</SizableText>
<Paragraph color='$borderColor'>
{download.album ?? 'Unknown album'} ·{' '}
{formatBytes(getDownloadSize(download))}
{download.originalTrack.album} ·{' '}
{formatBytes(download.fileSize)}
</Paragraph>
<Paragraph color='$borderColor'>
Saved {formatSavedAt(download.savedAt)}
Saved {formatSavedAt(download.downloadedAt)}
</Paragraph>
</YStack>
{index < selectedDownloads.length - 1 && <Separator />}
+13
View File
@@ -0,0 +1,13 @@
import { DownloadManager } from 'react-native-nitro-player'
export default function configureDownloadManager() {
DownloadManager.configure({
maxConcurrentDownloads: 3,
autoRetry: true,
maxRetryAttempts: 3,
backgroundDownloadsEnabled: true,
downloadArtwork: true,
wifiOnlyDownloads: false,
storageLocation: 'private', // 'private' or 'public'
})
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { DeviceProfile } from '@jellyfin/sdk/lib/generated-client'
import { create } from 'zustand'
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
import { mmkvStateStorage } from '../constants/storage'
import { getDeviceProfile } from '../utils/device-profiles'
import { getDeviceProfile } from '../utils/audio/device-profiles'
import StreamingQuality from '../enums/audio-quality'
type DeviceProfileStore = {
-1
View File
@@ -1,6 +1,5 @@
import { mmkvStateStorage } from '../../constants/storage'
import { JellifyDownloadProgress } from '@/src/types/JellifyDownload'
import JellifyTrack from '@/src/types/JellifyTrack'
import { mapDtoToTrack } from '../../utils/mapping/item-to-track'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { create } from 'zustand'
+1 -1
View File
@@ -3,7 +3,7 @@ import { createJSONStorage, devtools, persist } from 'zustand/middleware'
import { mmkvStateStorage } from '../../constants/storage'
import { useStreamingDeviceProfileStore } from '../device-profile'
import { useEffect } from 'react'
import { getDeviceProfile } from '../../utils/device-profiles'
import { getDeviceProfile } from '../../utils/audio/device-profiles'
import StreamingQuality from '../../enums/audio-quality'
type PlayerSettingsStore = {
+16 -86
View File
@@ -1,7 +1,7 @@
import { TrackItem } from 'react-native-nitro-player'
import { QueuingType } from '../enums/queuing-type'
import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client/models'
import getTrackDto from '../utils/track-extra-payload'
import getTrackDto from '../utils/mapping/track-extra-payload'
export type SourceType = 'stream' | 'download'
@@ -28,7 +28,6 @@ export type TrackExtraPayload = Record<string, unknown> & {
* type safety (and convenience!)
*/
item: string
sourceType: SourceType
sessionId: string
/**
@@ -39,87 +38,18 @@ export type TrackExtraPayload = Record<string, unknown> & {
mediaSourceInfo: string
}
/**
* @deprecated Use {@link TrackItem} directly
*/
interface JellifyTrack extends TrackItem {
description?: string | undefined
genre?: string | undefined
date?: string | undefined
isLiveStream?: boolean | undefined
officialRating?: string | undefined
customRating?: string | undefined
sourceType: SourceType
item: BaseItemDtoSlimified
sessionId: string | null | undefined
mediaSourceInfo?: MediaSourceInfo
/**
* Represents the type of queuing for this song, be it that it was
* queued from the selection chosen, queued by the user directly, or marked
* to play next by the user
*/
QueuingType?: QueuingType | undefined
}
/**
* Get the extra payload from a track with proper typing.
* This ensures type-safe access to the extraPayload field which comes from react-native-nitro-player.
*
* @param track The track to get the extra payload from
* @returns The properly typed extra payload, or undefined
*
* @example
* const payload = getTrackExtraPayload(currentTrack);
* const artists = payload?.artistItems;
* const albumId = payload?.AlbumId;
*/
export function getTrackExtraPayload(track: TrackItem | undefined): TrackExtraPayload {
return track?.extraPayload as TrackExtraPayload
}
/**
* A slimmed-down version of JellifyTrack for persistence.
* Excludes large fields like mediaSourceInfo and transient data
* to prevent storage overflow (RangeError: String length exceeds limit).
*
* When hydrating from storage, these fields will need to be rebuilt
* from the API or left undefined until playback is requested.
*/
export type PersistedJellifyTrack = Omit<JellifyTrack, 'mediaSourceInfo' | 'headers'> & {
/** Store only essential media source fields for persistence */
mediaSourceInfo?: Pick<MediaSourceInfo, 'Id' | 'Container' | 'Bitrate'> | undefined
}
/**
* Converts a full JellifyTrack to a PersistedJellifyTrack for storage
*/
export function toPersistedTrack(track: JellifyTrack): PersistedJellifyTrack {
const { mediaSourceInfo, headers, ...rest } = track as JellifyTrack & { headers?: unknown }
return {
...rest,
// Only persist essential media source fields
mediaSourceInfo: mediaSourceInfo
? {
Id: mediaSourceInfo.Id,
Container: mediaSourceInfo.Container,
Bitrate: mediaSourceInfo.Bitrate,
}
: undefined,
}
}
/**
* Converts a PersistedJellifyTrack back to a JellifyTrack
* Note: Some fields like full mediaSourceInfo and headers will be undefined
* and need to be rebuilt when playback is requested
*/
export function fromPersistedTrack(persisted: PersistedJellifyTrack): JellifyTrack {
// Cast is safe because PersistedJellifyTrack has all required fields
// except the omitted ones (mediaSourceInfo, headers) which are optional in JellifyTrack
return persisted as unknown as JellifyTrack
}
export default JellifyTrack
export type SlimifiedBaseItemDto = Pick<
BaseItemDto,
| 'Id'
| 'Name'
| 'AlbumId'
| 'ArtistItems'
| 'ImageBlurHashes'
| 'NormalizationGain'
| 'RunTimeTicks'
| 'OfficialRating'
| 'CustomRating'
| 'ProductionYear'
| 'ImageTags'
| 'Type'
>
@@ -19,10 +19,10 @@ import {
EncodingContext,
MediaStreamProtocol,
} from '@jellyfin/sdk/lib/generated-client'
import { getQualityParams } from './mapping/item-to-track'
import { getQualityParams } from '../mapping/item-to-track'
import { capitalize } from 'lodash'
import { SourceType } from '../types/JellifyTrack'
import StreamingQuality from '../enums/audio-quality'
import { SourceType } from '../../types/JellifyTrack'
import StreamingQuality from '../../enums/audio-quality'
import uuid from 'react-native-uuid'
/**
@@ -1,5 +1,7 @@
import { isNull, isUndefined } from 'lodash'
import JellifyTrack from '../../../types/JellifyTrack'
import { TrackItem } from 'react-native-nitro-player'
import getTrackDto from '../../utils/mapping/track-extra-payload'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
/**
* Tracks in Jellyfin are normalized to a target volume level of -18 LUFS.
@@ -29,8 +31,8 @@ const MIN_REDUCTION_DB = -10
*
* @see https://github.com/Chaphasilor
*/
export default function calculateTrackVolume(track: JellifyTrack): number {
const { NormalizationGain } = track.item
export default function calculateTrackVolume(track: TrackItem): number {
const { NormalizationGain } = getTrackDto(track) as BaseItemDto
/**
* If the track has no normalization gain, return 1 to play the track
+7 -28
View File
@@ -5,8 +5,7 @@ import {
MediaSourceInfo,
PlaybackInfoResponse,
} from '@jellyfin/sdk/lib/generated-client/models'
import JellifyTrack, { getTrackExtraPayload, TrackExtraPayload } from '../../types/JellifyTrack'
import { QueuingType } from '../../enums/queuing-type'
import { SourceType, TrackExtraPayload } from '../../types/JellifyTrack'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import { AudioApi } from '@jellyfin/sdk/lib/generated-client/api'
import { JellifyDownload } from '../../types/JellifyDownload'
@@ -19,7 +18,6 @@ import { convertRunTimeTicksToSeconds } from './ticks-to-seconds'
import { DownloadQuality } from '../../stores/settings/usage'
import MediaInfoQueryKey from '../../api/queries/media/keys'
import StreamingQuality from '../../enums/audio-quality'
import { getAudioCache } from '../../api/mutations/download/offlineModeUtils'
import RNFS from 'react-native-fs'
import { getApi } from '../../stores'
import { TrackItem } from 'react-native-nitro-player'
@@ -27,6 +25,7 @@ import { formatArtistNames } from '../formatting/artist-names'
import { getBlurhashFromDto } from '../parsing/blurhash'
import { MediaInfoQuery } from '../../api/queries/media/queries'
import { TrackMediaInfo } from '../../types/TrackMediaInfo'
import { slimifyDto } from './slimify-dto'
/**
* Ensures a valid session ID is returned.
@@ -119,27 +118,23 @@ export function getQualityParams(
export async function mapDtoToTrack(
item: BaseItemDto,
deviceProfile: DeviceProfile,
source: SourceType = 'stream',
): Promise<TrackItem> {
const api = getApi()!
const downloadedTracks = getAudioCache()
const downloads = downloadedTracks.filter((download) => download.id === item.Id)
const mediaInfo = await queryClient.ensureQueryData<PlaybackInfoResponse>(
MediaInfoQuery(item.Id, 'stream'),
MediaInfoQuery(item.Id, source),
)
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) {
*/
if (mediaInfo?.MediaSources && mediaInfo.MediaSources[0].TranscodingUrl) {
trackMediaInfo = buildTranscodedTrack(
api,
item,
@@ -170,32 +165,16 @@ export async function mapDtoToTrack(
url: trackMediaInfo.url,
artwork: trackMediaInfo.artwork,
extraPayload: {
item: JSON.stringify(item),
item: JSON.stringify(slimifyDto(item)),
mediaSourceInfo: JSON.stringify(
mediaSourceExists(mediaInfo) ? mediaInfo!.MediaSources![0] : {},
),
sessionId: trackMediaInfo.sessionId,
sourceType: trackMediaInfo.sourceType,
blurhash: getBlurhashFromDto(item),
} as TrackExtraPayload,
} as TrackItem
}
function buildDownloadedTrack(downloadedTrack: JellifyDownload): TrackMediaInfo {
// Safely build the image path - artwork is optional and may be undefined
const imagePath = downloadedTrack.artwork
? `file://${RNFS.DocumentDirectoryPath}/${downloadedTrack.artwork.split('/').pop()}`
: undefined
return {
url: `file://${RNFS.DocumentDirectoryPath}/${downloadedTrack.path!.split('/').pop()}`,
artwork: imagePath,
duration: downloadedTrack.duration,
sessionId: getValidSessionId(getTrackExtraPayload(downloadedTrack).sessionId),
sourceType: 'download',
}
}
function buildTranscodedTrack(
api: Api,
item: BaseItemDto,
+40
View File
@@ -0,0 +1,40 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { TrackItem } from 'react-native-nitro-player'
import getTrackDto from './track-extra-payload'
import { SlimifiedBaseItemDto } from '@/src/types/JellifyTrack'
export function slimifyDto(dto: BaseItemDto): SlimifiedBaseItemDto {
return {
Id: dto.Id,
Name: dto.Name,
AlbumId: dto.AlbumId,
ArtistItems: dto.ArtistItems,
ImageBlurHashes: dto.ImageBlurHashes,
NormalizationGain: dto.NormalizationGain,
RunTimeTicks: dto.RunTimeTicks,
OfficialRating: dto.OfficialRating,
CustomRating: dto.CustomRating,
ProductionYear: dto.ProductionYear,
ImageTags: dto.ImageTags,
Type: dto.Type,
}
}
export default function mapTrackToSlimifiedDto(track: TrackItem): SlimifiedBaseItemDto {
const dto = getTrackDto(track)!
return {
Id: dto.Id,
Name: dto.Name,
AlbumId: dto.AlbumId,
ArtistItems: dto.ArtistItems,
ImageBlurHashes: dto.ImageBlurHashes,
NormalizationGain: dto.NormalizationGain,
RunTimeTicks: dto.RunTimeTicks,
OfficialRating: dto.OfficialRating,
CustomRating: dto.CustomRating,
ProductionYear: dto.ProductionYear,
ImageTags: dto.ImageTags,
Type: dto.Type,
}
}
@@ -3,24 +3,23 @@
* This module provides helper functions to safely access and type the extraPayload field.
*/
import { TrackExtraPayload, getTrackExtraPayload } from '../types/JellifyTrack'
import { TrackExtraPayload } from '../../types/JellifyTrack'
import {
NameGuidPair,
BaseItemDto,
MediaSourceInfo,
} from '@jellyfin/sdk/lib/generated-client/models'
import { SourceType } from '../types/JellifyTrack'
import { TrackItem } from 'react-native-nitro-player'
export default function getTrackDto(track: TrackItem | undefined): BaseItemDto | undefined {
const item = JSON.parse(getTrackExtraPayload(track)?.item ?? '{}') as BaseItemDto
const item = JSON.parse((track?.extraPayload as TrackExtraPayload)?.item ?? '{}') as BaseItemDto
console.debug(item)
return item
}
export function getTrackMediaSourceInfo(track: TrackItem | undefined): MediaSourceInfo | undefined {
const mediaSourceInfo = JSON.parse(
getTrackExtraPayload(track)?.mediaSourceInfo ?? '{}',
(track?.extraPayload as TrackExtraPayload)?.mediaSourceInfo ?? '{}',
) as MediaSourceInfo
return mediaSourceInfo
}
@@ -32,7 +31,7 @@ export function getTrackMediaSourceInfo(track: TrackItem | undefined): MediaSour
* @returns Array of artist items, or undefined if not available
*/
export function getTrackArtists(track: TrackItem | undefined): NameGuidPair[] | undefined {
const item = JSON.parse(getTrackExtraPayload(track)?.item ?? '{}') as BaseItemDto
const item = JSON.parse((track?.extraPayload as TrackExtraPayload)?.item ?? '{}') as BaseItemDto
return (item?.ArtistItems ?? item?.ArtistItems) || undefined
}
@@ -43,7 +42,7 @@ export function getTrackArtists(track: TrackItem | undefined): NameGuidPair[] |
* @returns The album ID, or undefined if not available
*/
export function getTrackAlbumId(track: TrackItem | undefined): string | undefined {
const item = JSON.parse(getTrackExtraPayload(track)?.item ?? '{}') as BaseItemDto
const item = JSON.parse((track?.extraPayload as TrackExtraPayload)?.item ?? '{}') as BaseItemDto
return item?.AlbumId ?? undefined
}
@@ -54,24 +53,13 @@ export function getTrackAlbumId(track: TrackItem | undefined): string | undefine
* @returns Object with album Id and Album name, or undefined if not available
*/
export function getTrackAlbumInfo(track: TrackItem | undefined): NameGuidPair {
const item = JSON.parse(getTrackExtraPayload(track)?.item ?? '{}') as BaseItemDto
const item = JSON.parse((track?.extraPayload as TrackExtraPayload)?.item ?? '{}') as BaseItemDto
return {
Id: item.AlbumId!,
Name: item.Album,
}
}
/**
* Get the source type from a track's extra payload.
*
* @param track The track to get source type from
* @returns The source type ('stream' or 'download'), or undefined if not available
*/
export function getTrackSourceType(track: TrackItem | undefined): SourceType | undefined {
const payload = getTrackExtraPayload(track)
return payload?.sourceType
}
/**
* Get the official rating from a track's extra payload.
*
@@ -79,7 +67,7 @@ export function getTrackSourceType(track: TrackItem | undefined): SourceType | u
* @returns The official rating (e.g. "G", "PG", "M"), or undefined if not available
*/
export function getTrackOfficialRating(track: TrackItem | undefined): string | undefined {
const item = JSON.parse(getTrackExtraPayload(track)?.item ?? '{}') as BaseItemDto
const item = JSON.parse((track?.extraPayload as TrackExtraPayload)?.item ?? '{}') as BaseItemDto
return item?.OfficialRating ?? undefined
}
@@ -90,7 +78,7 @@ export function getTrackOfficialRating(track: TrackItem | undefined): string | u
* @returns The custom rating, or undefined if not available
*/
export function getTrackCustomRating(track: TrackItem | undefined): string | undefined {
const item = JSON.parse(getTrackExtraPayload(track)?.item ?? '{}') as BaseItemDto
const item = JSON.parse((track?.extraPayload as TrackExtraPayload)?.item ?? '{}') as BaseItemDto
return item?.CustomRating ?? undefined
}
@@ -112,5 +100,5 @@ export function getTrackRating(track: TrackItem | undefined): string | undefined
* @returns The properly typed extra payload, or undefined if track is undefined
*/
export function getTypedExtraPayload(track: TrackItem | undefined): TrackExtraPayload | undefined {
return getTrackExtraPayload(track)
return track?.extraPayload as TrackExtraPayload | undefined
}
+4 -8
View File
@@ -1,8 +1,7 @@
import { ValueType } from 'react-native-nitro-modules'
import { TrackItem } from 'react-native-nitro-player'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
export function isExplicit(nowPlaying: TrackItem | undefined) {
if (!nowPlaying) return false
export function isExplicit(item: BaseItemDto | undefined) {
if (!item) return false
const ADULT_RATINGS = new Set([
'R',
'NC-17',
@@ -49,8 +48,5 @@ export function isExplicit(nowPlaying: TrackItem | undefined) {
return false
}
return isExplicitByRating(
(nowPlaying?.extraPayload?.officialRating as string) ||
(nowPlaying?.extraPayload?.customRating as string),
)
return isExplicitByRating((item?.OfficialRating as string) || (item?.CustomRating as string))
}