mirror of
https://github.com/Jellify-Music/App.git
synced 2026-04-21 09:08:56 -05:00
starting migration to nitro player for downloads
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'>>()
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
@@ -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 +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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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!),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,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 />}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user