Merge branch 'main' into skalthoff/issue588

This commit is contained in:
Violet Caulfield
2025-11-20 16:36:04 -06:00
committed by GitHub
69 changed files with 1670 additions and 461 deletions
-6
View File
@@ -88,12 +88,6 @@ jobs:
with:
java-version: '17'
distribution: 'zulu'
- name: 💎 Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.0'
bundler-cache: true
- name: ⬇️ Download Android Artifacts
uses: actions/download-artifact@v4
+8
View File
@@ -142,6 +142,10 @@ jobs:
echo "\"dsn\": \"${{ secrets.GLITCHTIP_DSN }}\"" >> glitchtip.json
echo "}" >> glitchtip.json
- name: 📝 Output Glitchip secrets to .env
run: |
echo "GLITCHTIP_DSN=${{ secrets.GLITCHTIP_DSN }}" >> .env
- name: ✅ Validate Config Files
run: |
node -e "JSON.parse(require('fs').readFileSync('telemetrydeck.json'))"
@@ -214,6 +218,10 @@ jobs:
echo "\"dsn\": \"${{ secrets.GLITCHTIP_DSN }}\"" >> glitchtip.json
echo "}" >> glitchtip.json
- name: 📝 Output Glitchip secrets to .env
run: |
echo "GLITCHTIP_DSN=${{ secrets.GLITCHTIP_DSN }}" >> .env
- name: ✅ Validate Config Files
run: |
node -e "JSON.parse(require('fs').readFileSync('telemetrydeck.json'))"
+3 -7
View File
@@ -103,13 +103,9 @@ Install via [Altstore](https://altstore.io) or your favorite sideloading utility
**Artists**
<p align="center">
<img src="screenshots/library_artists.png" alt="Library Artists" width="275" height="600">
</p>
**Downloaded Tracks**
<p align="center">
<img src="screenshots/library_downloaded_tracks.PNG" alt="Library Tracks" width="275" height="600">
<img src="screenshots/library_artists.png" alt="Library Artists" width="275" height="600" />
<img src="screenshots/library_albums.PNG" alt="Library Albums" width="275" height="600" />
<img src="screenshots/library_downloaded_tracks.PNG" alt="Library Tracks" width="275" height="600" />
</p>
**Artist View**
+2 -2
View File
@@ -91,8 +91,8 @@ android {
applicationId "com.cosmonautical.jellify"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 143
versionName "0.20.6"
versionCode 149
versionName "0.20.12"
}
signingConfigs {
debug {
+9 -8
View File
@@ -63,6 +63,7 @@
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = Jellify/LaunchScreen.storyboard; sourceTree = "<group>"; };
8798FC37A1454014A7B318F9 /* Figtree-SemiBold.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Figtree-SemiBold.otf"; path = "../assets/fonts/Figtree-SemiBold.otf"; sourceTree = "<group>"; };
8B91428F7F524687A96EE362 /* Figtree-LightItalic.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Figtree-LightItalic.otf"; path = "../assets/fonts/Figtree-LightItalic.otf"; sourceTree = "<group>"; };
B226FC42CAD40AAFAD287380 /* Pods-Jellify.devrelease.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Jellify.devrelease.xcconfig"; path = "Target Support Files/Pods-Jellify/Pods-Jellify.devrelease.xcconfig"; sourceTree = "<group>"; };
C5258FBB23272277847FE07E /* libPods-Jellify.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Jellify.a"; sourceTree = BUILT_PRODUCTS_DIR; };
CF605E5D2DF95BAB00858968 /* Figtree-Black.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Figtree-Black.otf"; sourceTree = "<group>"; };
CF605E5E2DF95BAB00858968 /* Figtree-BlackItalic.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Figtree-BlackItalic.otf"; sourceTree = "<group>"; };
@@ -221,7 +222,7 @@
children = (
1EFD74F540EE131CCCC762FE /* Pods-Jellify.debug.xcconfig */,
E53A46F6214019C12F016ACB /* Pods-Jellify.release.xcconfig */,
7980EBA21635C96A124E1463 /* Pods-Jellify.devrelease.xcconfig */,
B226FC42CAD40AAFAD287380 /* Pods-Jellify.devrelease.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
@@ -542,7 +543,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 253;
CURRENT_PROJECT_VERSION = 259;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_BITCODE = NO;
@@ -553,7 +554,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.20.6;
MARKETING_VERSION = 0.20.12;
NEW_SETTING = "";
OTHER_LDFLAGS = (
"$(inherited)",
@@ -584,7 +585,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 253;
CURRENT_PROJECT_VERSION = 259;
DEVELOPMENT_TEAM = WAH9CZ8BPG;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -594,7 +595,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.20.6;
MARKETING_VERSION = 0.20.12;
NEW_SETTING = "";
OTHER_LDFLAGS = (
"$(inherited)",
@@ -811,7 +812,7 @@
};
CFDEVREL001 /* DevRelease */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7980EBA21635C96A124E1463 /* Pods-Jellify.devrelease.xcconfig */;
baseConfigurationReference = B226FC42CAD40AAFAD287380 /* Pods-Jellify.devrelease.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
@@ -820,7 +821,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 253;
CURRENT_PROJECT_VERSION = 259;
DEVELOPMENT_TEAM = WAH9CZ8BPG;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -831,7 +832,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.20.6;
MARKETING_VERSION = 0.20.12;
NEW_SETTING = "";
OTHER_LDFLAGS = (
"$(inherited)",
+8 -1
View File
@@ -58,6 +58,13 @@
<key>NSIncludesSubdomains</key>
<true/>
</dict>
<key>100.64.0.0/10</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
<key>NSBonjourServices</key>
@@ -66,7 +73,7 @@
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>${PRODUCT_NAME} uses the local network to connect to one's Jellyfin server for streaming music</string>
<string>${PRODUCT_NAME} uses the local network to connect to one&apos;s Jellyfin server for streaming music</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string></string>
<key>RCTNewArchEnabled</key>
+6 -6
View File
@@ -12,7 +12,7 @@ PODS:
- hermes-engine (0.82.1):
- hermes-engine/Pre-built (= 0.82.1)
- hermes-engine/Pre-built (0.82.1)
- NitroModules (0.31.3):
- NitroModules (0.31.8):
- boost
- DoubleConversion
- fast_float
@@ -2118,7 +2118,7 @@ PODS:
- SocketRocket
- SwiftAudioEx (= 1.1.0)
- Yoga
- react-native-vector-icons-material-design-icons (12.3.0)
- react-native-vector-icons-material-design-icons (12.4.0)
- React-NativeModulesApple (0.82.1):
- boost
- DoubleConversion
@@ -2720,7 +2720,7 @@ PODS:
- React
- RNFS (2.20.0):
- React-Core
- RNGestureHandler (2.28.0):
- RNGestureHandler (2.29.1):
- boost
- DoubleConversion
- fast_float
@@ -3384,7 +3384,7 @@ SPEC CHECKSUMS:
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
google-cast-sdk: 1fb6724e94cc5ff23b359176e0cf6360586bb97a
hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5
NitroModules: ca848159e82a7e9ae956ffe26c34a11631e6176b
NitroModules: 54a650fd5533accfe10e85ce037bf06f1e70640c
NitroOta: b4f7cdbe660e8f07f80f5eb9f169d70f698ea284
NitroOtaBundleManager: 5e7c0f8c3f76cc06f9fe07a63879fe35496c27c7
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
@@ -3433,7 +3433,7 @@ SPEC CHECKSUMS:
react-native-pager-view: a0516effb17ca5120ac2113bfd21b91130ad5748
react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460
react-native-track-player: 89d8e641c83a89bea5dee43c381be743282553e9
react-native-vector-icons-material-design-icons: c502df5b988ce85d6c7d2b7ee909818315760b82
react-native-vector-icons-material-design-icons: 76cd460b3540b80527b4a80fb7f867f7deedb498
React-NativeModulesApple: 46690a0fe94ec28fc6fc686ec797b911d251ded0
React-oscompat: 95875e81f5d4b3c7b2c888d5bd2c9d83450d8bdb
React-perflogger: 2e229bf33e42c094fd64516d89ec1187a2b79b5b
@@ -3471,7 +3471,7 @@ SPEC CHECKSUMS:
RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
RNDnsLookup: db4a89381b80ec1a5153088518d2c4f8e51f2521
RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
RNGestureHandler: f1dd7f92a0faa2868a919ab53bb9d66eb4ebfcf5
RNGestureHandler: e1cf8ef3f11045536eed6bd4f132b003ef5f9a5f
RNReactNativeHapticFeedback: be4f1b4bf0398c30b59b76ed92ecb0a2ff3a69c6
RNReanimated: 732e7d1662f8cc0e533fa32791800de5b5934726
RNScreens: d821082c6dd1cb397cc0c98b026eeafaa68be479
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "jellify",
"version": "0.20.6",
"version": "0.20.12",
"private": true,
"scripts": {
"init-android": "yarn install --network-concurrency 1",
@@ -42,11 +42,11 @@
"@react-native-community/cli": "20.0.0",
"@react-native-community/netinfo": "^11.4.1",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-vector-icons/material-design-icons": "^12.3.0",
"@react-navigation/bottom-tabs": "7.7.1",
"@react-navigation/material-top-tabs": "7.4.1",
"@react-navigation/native": "7.1.19",
"@react-navigation/native-stack": "7.6.1",
"@react-native-vector-icons/material-design-icons": "12.4.0",
"@react-navigation/bottom-tabs": "7.8.5",
"@react-navigation/material-top-tabs": "7.4.3",
"@react-navigation/native": "7.1.20",
"@react-navigation/native-stack": "7.6.3",
"@sentry/react-native": "7.6.0",
"@shopify/flash-list": "2.2.0",
"@tamagui/config": "1.137.1",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 588 KiB

After

Width:  |  Height:  |  Size: 846 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 KiB

After

Width:  |  Height:  |  Size: 643 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 554 KiB

After

Width:  |  Height:  |  Size: 497 KiB

+1 -33
View File
@@ -11,36 +11,4 @@ fi
JELLYFIN_URL="$1"
USERNAME="$2"
attempt=1
max_attempts=3
success=false
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt of $max_attempts..."
if node scripts/maestro-android.js "$JELLYFIN_URL" "$USERNAME"; then
echo "Tests passed on attempt $attempt"
success=true
break
else
echo "Tests failed on attempt $attempt"
if [ $attempt -lt $max_attempts ]; then
echo "Cleaning up and retrying..."
rm -rf *.mp4 || true
pkill -f maestro || true
sleep 5
fi
attempt=$((attempt + 1))
fi
done
if [ "$success" = false ]; then
echo "All $max_attempts attempts failed"
exit 1
fi
echo "Tests completed successfully!"
node scripts/maestro-android.js "$JELLYFIN_URL" "$USERNAME"
+129 -48
View File
@@ -11,12 +11,25 @@ import {
import { queryClient } from '../../../constants/query-client'
import { AUDIO_CACHE_QUERY } from '../../queries/download/constants'
type DownloadedFileInfo = {
uri: string
path: string
fileName: string
size: number
}
export type DeleteDownloadsResult = {
deletedCount: number
freedBytes: number
failedCount: number
}
export async function downloadJellyfinFile(
url: string,
name: string,
songName: string,
setDownloadProgress: JellifyDownloadProgressState,
) {
): Promise<DownloadedFileInfo> {
try {
// Fetch the file
const headRes = await axios.head(url)
@@ -65,7 +78,14 @@ export async function downloadJellyfinFile(
const result = await RNFS.downloadFile(options).promise
console.log('Download complete:', result)
return `file://${downloadDest}`
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
@@ -118,42 +138,42 @@ export const saveAudio = async (
try {
console.debug('Downloading audio')
const downloadtrack = await downloadJellyfinFile(
const downloadedTrackFile = await downloadJellyfinFile(
track.url,
track.item.Id as string,
track.title as string,
setDownloadProgress,
)
const dowloadalbum = await downloadJellyfinFile(
track.artwork as string,
track.item.Id as string,
track.title as string,
setDownloadProgress,
)
console.log('downloadtrack', downloadtrack)
if (downloadtrack) {
track.url = downloadtrack
track.artwork = dowloadalbum
let downloadedArtworkFile: DownloadedFileInfo | undefined
if (track.artwork) {
downloadedArtworkFile = await downloadJellyfinFile(
track.artwork as string,
track.item.Id as string,
track.title as string,
setDownloadProgress,
)
}
console.log('downloadtrack', downloadedTrackFile)
track.url = downloadedTrackFile.uri
if (downloadedArtworkFile) track.artwork = downloadedArtworkFile.uri
const index = existingArray.findIndex((t) => t.item.Id === track.item.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] = {
...track,
savedAt: new Date().toISOString(),
isAutoDownloaded,
path: downloadtrack,
}
existingArray[index] = downloadEntry
} else {
// Add new
existingArray.push({
...track,
savedAt: new Date().toISOString(),
isAutoDownloaded,
path: downloadtrack,
})
existingArray.push(downloadEntry)
}
} catch (error) {
return false
@@ -164,17 +184,8 @@ export const saveAudio = async (
}
export const deleteAudio = async (itemId: string | undefined | null) => {
const downloads = getAudioCache()
const download = downloads.filter((download) => download.item.Id === itemId)
if (download.length === 1) {
RNFS.unlink(`${RNFS.DocumentDirectoryPath}/${download[0].item.Id}`)
setAudioCache([
...downloads.slice(0, downloads.indexOf(download[0])),
...downloads.slice(downloads.indexOf(download[0]) + 1, downloads.length - 1),
])
}
if (!itemId) return
await deleteDownloadsByIds([itemId])
}
const setAudioCache = (downloads: JellifyDownload[]) => {
@@ -194,8 +205,88 @@ export const getAudioCache = (): JellifyDownload[] => {
return existingArray
}
export const deleteAudioCache = async () => {
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.item.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.item.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.item.Id))
mmkv.delete(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE)
return result
}
export const purneAudioCache = async () => {
@@ -220,17 +311,7 @@ export const purneAudioCache = async () => {
// Remove the oldest `excess` files
const itemsToDelete = autoDownloads.slice(0, excess)
for (const item of itemsToDelete) {
// Delete audio file
if (item.url && (await RNFS.exists(item.url))) {
await RNFS.unlink(item.url).catch(() => {})
}
// Delete artwork
if (item.artwork && (await RNFS.exists(item.artwork))) {
await RNFS.unlink(item.artwork).catch(() => {})
}
// Remove from the existingArray
await deleteDownloadAssets(item)
existingArray = existingArray.filter((i) => i.item.Id !== item.item.Id)
}
+2 -1
View File
@@ -7,7 +7,7 @@ import { fetchAlbums } from './utils/album'
import { RefObject, useCallback, useRef } from 'react'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import flattenInfiniteQueryPages from '../../../utils/query-selectors'
import { ApiLimits } from '../query.config'
import { ApiLimits, MaxPages } from '../../../configs/query.config'
import { fetchRecentlyAdded } from '../recents/utils'
import { queryClient } from '../../../constants/query-client'
import { useApi, useJellifyLibrary, useJellifyUser } from '../../../stores'
@@ -45,6 +45,7 @@ const useAlbums: () => [
),
initialPageParam: 0,
select: selectAlbums,
maxPages: MaxPages.Library,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
},
+1 -1
View File
@@ -10,7 +10,7 @@ import { Api } from '@jellyfin/sdk'
import { fetchItem, fetchItems } from '../../item'
import { JellifyUser } from '../../../../types/JellifyUser'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { ApiLimits } from '../../query.config'
import { ApiLimits } from '../../../../configs/query.config'
export function fetchAlbums(
api: Api | undefined,
user: JellifyUser | undefined,
+2 -1
View File
@@ -8,7 +8,7 @@ import {
} from '@tanstack/react-query'
import { isUndefined } from 'lodash'
import { fetchArtistAlbums, fetchArtistFeaturedOn, fetchArtists } from './utils/artist'
import { ApiLimits } from '../query.config'
import { ApiLimits, MaxPages } from '../../../configs/query.config'
import { RefObject, useCallback, useRef } from 'react'
import { useLibrarySortAndFilterContext } from '../../../providers/Library'
import flattenInfiniteQueryPages from '../../../utils/query-selectors'
@@ -68,6 +68,7 @@ export const useAlbumArtists: () => [
[sortDescending ? SortOrder.Descending : SortOrder.Ascending],
),
select: selectArtists,
maxPages: MaxPages.Library,
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
+1 -1
View File
@@ -9,7 +9,7 @@ import {
} from '@jellyfin/sdk/lib/generated-client/models'
import { getArtistsApi, getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { JellifyUser } from '../../../../types/JellifyUser'
import { ApiLimits } from '../../query.config'
import { ApiLimits } from '../../../../configs/query.config'
export function fetchArtists(
api: Api | undefined,
@@ -1,4 +1,5 @@
import RNFS from 'react-native-fs'
import DeviceInfo from 'react-native-device-info'
type JellifyStorage = {
totalStorage: number
@@ -9,10 +10,11 @@ type JellifyStorage = {
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: totalStorage.freeSpace,
freeSpace: freeDiskStorage,
storageInUseByJellify: storageInUse.size,
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import { FrequentlyPlayedArtistsQueryKey, FrequentlyPlayedTracksQueryKey } from './keys'
import { fetchFrequentlyPlayed, fetchFrequentlyPlayedArtists } from './utils/frequents'
import { ApiLimits } from '../query.config'
import { ApiLimits } from '../../../configs/query.config'
import { isUndefined } from 'lodash'
import { useApi, useJellifyLibrary, useJellifyUser } from '../../../stores'
+1 -1
View File
@@ -9,7 +9,7 @@ import { Api } from '@jellyfin/sdk'
import { isEmpty, isNull, isUndefined } from 'lodash'
import { JellifyLibrary } from '../../../../types/JellifyLibrary'
import { fetchItem } from '../../item'
import { ApiLimits } from '../../query.config'
import { ApiLimits } from '../../../../configs/query.config'
import { JellifyUser } from '@/src/types/JellifyUser'
import { queryClient } from '../../../../constants/query-client'
import { InfiniteData } from '@tanstack/react-query'
+1 -1
View File
@@ -1,7 +1,7 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getInstantMixApi } from '@jellyfin/sdk/lib/utils/api'
import { isUndefined } from 'lodash'
import QueryConfig from './query.config'
import QueryConfig from '../../configs/query.config'
import { Api } from '@jellyfin/sdk'
import { JellifyUser } from '../../types/JellifyUser'
/**
+1 -1
View File
@@ -10,7 +10,7 @@ import { groupBy, isEmpty, isEqual, isUndefined } from 'lodash'
import { SectionList } from 'react-native'
import { Api } from '@jellyfin/sdk/lib/api'
import { JellifyLibrary } from '../../types/JellifyLibrary'
import QueryConfig from './query.config'
import QueryConfig from '../../configs/query.config'
import { JellifyUser } from '../../types/JellifyUser'
/**
+1 -1
View File
@@ -1,7 +1,7 @@
import { UserPlaylistsQueryKey } from './keys'
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { fetchUserPlaylists, fetchPublicPlaylists } from './utils'
import { ApiLimits } from '../query.config'
import { ApiLimits } from '../../../configs/query.config'
import { useApi, useJellifyLibrary, useJellifyUser } from '../../../stores'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { QueryKeys } from '../../../enums/query-keys'
+9 -2
View File
@@ -9,7 +9,7 @@ import { JellifyUser } from '../../../../types/JellifyUser'
import { Api } from '@jellyfin/sdk'
import { isUndefined } from 'lodash'
import { JellifyLibrary } from '../../../../types/JellifyLibrary'
import QueryConfig from '../../query.config'
import QueryConfig from '../../../../configs/query.config'
/**
* Returns the user's playlists from the Jellyfin server
@@ -48,6 +48,7 @@ export async function fetchUserPlaylists(
ItemFields.CanDelete,
ItemFields.Genres,
ItemFields.ChildCount,
ItemFields.ItemCounts,
],
sortBy: [ItemSortBy.SortName],
sortOrder: [SortOrder.Ascending],
@@ -85,7 +86,13 @@ export async function fetchPublicPlaylists(
sortOrder: [SortOrder.Ascending],
startIndex: page * QueryConfig.limits.library,
limit: QueryConfig.limits.library,
fields: ['Path', 'CanDelete', 'Genres'],
fields: [
ItemFields.Path,
ItemFields.CanDelete,
ItemFields.Genres,
ItemFields.ChildCount,
ItemFields.ItemCounts,
],
})
.then((response) => {
console.log(response)
+1 -1
View File
@@ -1,7 +1,7 @@
import { RecentlyPlayedArtistsQueryKey, RecentlyPlayedTracksQueryKey } from './keys'
import { useInfiniteQuery } from '@tanstack/react-query'
import { fetchRecentlyPlayed, fetchRecentlyPlayedArtists } from './utils'
import { ApiLimits } from '../query.config'
import { ApiLimits } from '../../../configs/query.config'
import { isUndefined } from 'lodash'
import { useApi, useJellifyUser, useJellifyLibrary } from '../../../stores'
+1 -1
View File
@@ -6,7 +6,7 @@ import {
SortOrder,
} from '@jellyfin/sdk/lib/generated-client/models'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api'
import QueryConfig, { ApiLimits } from '../../query.config'
import QueryConfig, { ApiLimits } from '../../../../configs/query.config'
import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api'
import { Api } from '@jellyfin/sdk'
import { isUndefined } from 'lodash'
+1 -1
View File
@@ -1,7 +1,7 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { isEmpty, isUndefined, trim } from 'lodash'
import QueryConfig from './query.config'
import QueryConfig from '../../configs/query.config'
import { Api } from '@jellyfin/sdk'
import { JellifyUser } from '../../types/JellifyUser'
/**
+1 -1
View File
@@ -1,6 +1,6 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api'
import { ApiLimits } from './query.config'
import { ApiLimits } from '../../configs/query.config'
import { Api } from '@jellyfin/sdk'
import { isUndefined } from 'lodash'
import { JellifyUser } from '../../types/JellifyUser'
+1 -1
View File
@@ -10,7 +10,7 @@ import {
} from '@jellyfin/sdk/lib/generated-client'
import { RefObject, useCallback, useRef } from 'react'
import flattenInfiniteQueryPages from '../../../utils/query-selectors'
import { ApiLimits } from '../query.config'
import { ApiLimits } from '../../../configs/query.config'
import { useAllDownloadedTracks } from '../download'
import { queryClient } from '../../../constants/query-client'
import UserDataQueryKey from '../user-data/keys'
+1 -1
View File
@@ -9,7 +9,7 @@ import {
} from '@jellyfin/sdk/lib/generated-client/models'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { isUndefined } from 'lodash'
import { ApiLimits } from '../../query.config'
import { ApiLimits } from '../../../../configs/query.config'
import { JellifyUser } from '../../../../types/JellifyUser'
export default function fetchTracks(
+2 -6
View File
@@ -112,12 +112,8 @@ export function Album(): React.JSX.Element {
)}
ListFooterComponent={AlbumTrackListFooter}
ListEmptyComponent={() => (
<YStack>
{isPending ? (
<Spinner size='large' color={'$background'} />
) : (
<Text>No tracks found</Text>
)}
<YStack flex={1} alignContent='center'>
{isPending ? <Spinner color={'$primary'} /> : <Text>No tracks found</Text>}
</YStack>
)}
onScrollBeginDrag={handleScrollBeginDrag}
+47 -31
View File
@@ -1,6 +1,6 @@
import { ActivityIndicator, RefreshControl } from 'react-native'
import { Separator, useTheme, XStack, YStack } from 'tamagui'
import React, { RefObject, useCallback, useEffect, useRef } from 'react'
import React, { RefObject, useCallback, useEffect, useMemo, useRef } from 'react'
import { Text } from '../Global/helpers/text'
import { FlashList, FlashListRef } from '@shopify/flash-list'
import { UseInfiniteQueryResult } from '@tanstack/react-query'
@@ -12,6 +12,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import AZScroller, { useAlphabetSelector } from '../Global/components/alphabetical-selector'
import { isString } from 'lodash'
import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header'
import { useLibrarySortAndFilterContext } from '../../providers/Library'
interface AlbumsProps {
albumsInfiniteQuery: UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error>
@@ -26,6 +27,10 @@ export default function Albums({
}: AlbumsProps): React.JSX.Element {
const theme = useTheme()
const albums = albumsInfiniteQuery.data ?? []
const { isFavorites } = useLibrarySortAndFilterContext()
const navigation = useNavigation<NativeStackNavigationProp<LibraryStackParamList>>()
const sectionListRef = useRef<FlashListRef<string | number | BaseItemDto>>(null)
@@ -44,6 +49,17 @@ export default function Albums({
const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } =
useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase()))
const refreshControl = useMemo(
() => (
<RefreshControl
refreshing={albumsInfiniteQuery.isFetching && !isAlphabetSelectorPending}
onRefresh={albumsInfiniteQuery.refetch}
tintColor={theme.primary.val}
/>
),
[albumsInfiniteQuery.isFetching, isAlphabetSelectorPending, albumsInfiniteQuery.refetch],
)
const ItemSeparatorComponent = useCallback(
({ leadingItem, trailingItem }: { leadingItem: unknown; trailingItem: unknown }) =>
typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : (
@@ -52,6 +68,26 @@ export default function Albums({
[],
)
const keyExtractor = useCallback(
(item: BaseItemDto | string | number) =>
typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!,
[],
)
const renderItem = useCallback(
({ index, item: album }: { index: number; item: BaseItemDto | string | number }) =>
typeof album === 'string' ? (
<FlashListStickyHeader text={album.toUpperCase()} />
) : typeof album === 'number' ? null : typeof album === 'object' ? (
<ItemRow item={album} navigation={navigation} />
) : null,
[navigation],
)
const onEndReached = useCallback(() => {
if (albumsInfiniteQuery.hasNextPage) albumsInfiniteQuery.fetchNextPage()
}, [albumsInfiniteQuery.hasNextPage, albumsInfiniteQuery.fetchNextPage])
// Effect for handling the pending alphabet selector letter
useEffect(() => {
if (isString(pendingLetterRef.current) && albumsInfiniteQuery.data) {
@@ -93,22 +129,10 @@ export default function Albums({
<XStack flex={1}>
<FlashList
ref={sectionListRef}
contentInsetAdjustmentBehavior='automatic'
data={albumsInfiniteQuery.data ?? []}
keyExtractor={(item) =>
typeof item === 'string'
? item
: typeof item === 'number'
? item.toString()
: item.Id!
}
renderItem={({ index, item: album }) =>
typeof album === 'string' ? (
<FlashListStickyHeader text={album.toUpperCase()} />
) : typeof album === 'number' ? null : typeof album === 'object' ? (
<ItemRow item={album} navigation={navigation} />
) : null
}
extraData={isFavorites}
data={albums}
keyExtractor={keyExtractor}
renderItem={renderItem}
ListEmptyComponent={
<YStack flex={1} justify='center' alignItems='center'>
<Text marginVertical='auto' color={'$borderColor'}>
@@ -116,22 +140,14 @@ export default function Albums({
</Text>
</YStack>
}
onEndReached={() => {
if (albumsInfiniteQuery.hasNextPage) albumsInfiniteQuery.fetchNextPage()
}}
ListFooterComponent={
albumsInfiniteQuery.isFetchingNextPage ? <ActivityIndicator /> : null
}
onEndReached={onEndReached}
ItemSeparatorComponent={ItemSeparatorComponent}
refreshControl={
<RefreshControl
refreshing={albumsInfiniteQuery.isFetching && !isAlphabetSelectorPending}
onRefresh={albumsInfiniteQuery.refetch}
tintColor={theme.primary.val}
/>
}
refreshControl={refreshControl}
stickyHeaderConfig={{
// When this is true the flashlist likes to flicker
useNativeDriver: false,
}}
stickyHeaderIndices={stickyHeaderIndices}
removeClippedSubviews
/>
{showAlphabeticalSelector && albumPageParams && (
+28 -21
View File
@@ -1,5 +1,5 @@
import React, { RefObject, useCallback, useEffect, useMemo, useRef } from 'react'
import { Separator, useTheme, XStack, YStack } from 'tamagui'
import { getTokenValue, Separator, useTheme, XStack, YStack } from 'tamagui'
import { Text } from '../Global/helpers/text'
import { RefreshControl } from 'react-native'
import ItemRow from '../Global/components/item-row'
@@ -65,6 +65,26 @@ export default function Artists({
[],
)
const KeyExtractor = useCallback(
(item: BaseItemDto | string | number, index: number) =>
typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!,
[],
)
const renderItem = useCallback(
({ index, item: artist }: { index: number; item: BaseItemDto | number | string }) =>
typeof artist === 'string' ? (
// Don't render the letter if we don't have any artists that start with it
// If the index is the last index, or the next index is not an object, then don't render the letter
index - 1 === artists.length || typeof artists[index + 1] !== 'object' ? null : (
<FlashListStickyHeader text={artist.toUpperCase()} />
)
) : typeof artist === 'number' ? null : typeof artist === 'object' ? (
<ItemRow circular item={artist} navigation={navigation} />
) : null,
[navigation],
)
// Effect for handling the pending alphabet selector letter
useEffect(() => {
if (isString(pendingLetterRef.current) && artists) {
@@ -105,16 +125,10 @@ export default function Artists({
return (
<XStack flex={1}>
<FlashList
ref={sectionListRef}
contentInsetAdjustmentBehavior='automatic'
ref={sectionListRef}
extraData={isFavorites}
keyExtractor={(item) =>
typeof item === 'string'
? item
: typeof item === 'number'
? item.toString()
: item.Id!
}
keyExtractor={KeyExtractor}
ItemSeparatorComponent={ItemSeparatorComponent}
ListEmptyComponent={
<YStack flex={1} justify='center' alignItems='center'>
@@ -131,19 +145,12 @@ export default function Artists({
tintColor={theme.primary.val}
/>
}
renderItem={({ index, item: artist }) =>
typeof artist === 'string' ? (
// Don't render the letter if we don't have any artists that start with it
// If the index is the last index, or the next index is not an object, then don't render the letter
index - 1 === artists.length ||
typeof artists[index + 1] !== 'object' ? null : (
<FlashListStickyHeader text={artist.toUpperCase()} />
)
) : typeof artist === 'number' ? null : typeof artist === 'object' ? (
<ItemRow circular item={artist} navigation={navigation} />
) : null
}
renderItem={renderItem}
stickyHeaderIndices={stickyHeaderIndices}
stickyHeaderConfig={{
// When this is true the flashlist likes to flicker
useNativeDriver: false,
}}
onStartReached={() => {
if (artistsInfiniteQuery.hasPreviousPage)
artistsInfiniteQuery.fetchPreviousPage()
+1 -1
View File
@@ -108,7 +108,7 @@ export default function ItemContext({
useEffect(() => trigger('impactLight'), [item?.Id])
return (
<YGroup unstyled marginBottom={'$8'}>
<YGroup scrollable={Platform.OS === 'android'} marginBottom={'$8'}>
<FavoriteContextMenuRow item={item} />
{renderAddToQueueRow && <AddToQueueMenuRow tracks={itemTracks} />}
+39 -25
View File
@@ -1,7 +1,6 @@
import { CommonActions, StackActions, TabActions } from '@react-navigation/native'
import navigationRef from '../../../../navigation'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { InteractionManager } from 'react-native'
export function goToAlbumFromContextSheet(album: BaseItemDto | undefined) {
if (!navigationRef.isReady() || !album) return
@@ -9,21 +8,30 @@ export function goToAlbumFromContextSheet(album: BaseItemDto | undefined) {
// Pop Context Sheet
navigationRef.dispatch(StackActions.pop())
let route = navigationRef.current?.getCurrentRoute()
const state = navigationRef.getRootState()
const tabsRoute = state.routes.find((r) => r.name === 'Tabs')
// If we've popped into the player, pop that as well
if (route?.name.includes('Player')) {
navigationRef.dispatch(StackActions.pop())
if (tabsRoute && tabsRoute.state && typeof tabsRoute.state.index === 'number') {
const tabsState = tabsRoute.state
const activeTabIndex = tabsState.index
const activeTabName = tabsState.routes[activeTabIndex!]?.name
route = navigationRef.current?.getCurrentRoute()
}
if (route?.name.includes('Settings')) {
navigationRef.dispatch(TabActions.jumpTo('LibraryTab'))
requestAnimationFrame(() => {
// If we are in Settings, we want to jump to Library
if (activeTabName === 'SettingsTab') {
navigationRef.dispatch(TabActions.jumpTo('LibraryTab'))
// We need to wait for the tab switch to happen before navigating
// Using requestAnimationFrame as a simple heuristic, though interaction manager might be better
requestAnimationFrame(() => {
navigationRef.dispatch(CommonActions.navigate('Album', { album }))
})
} else {
// For Home, Library, Search, Discover - they all have 'Album' in their stack
navigationRef.dispatch(CommonActions.navigate('Album', { album }))
})
} else navigationRef.dispatch(CommonActions.navigate('Album', { album }))
}
} else {
// Fallback if we can't find Tabs state (unlikely if logged in)
navigationRef.dispatch(CommonActions.navigate('Album', { album }))
}
}
export function goToArtistFromContextSheet(artist: BaseItemDto | undefined) {
@@ -32,19 +40,25 @@ export function goToArtistFromContextSheet(artist: BaseItemDto | undefined) {
// Pop Context Sheet
navigationRef.dispatch(StackActions.pop())
let route = navigationRef.current?.getCurrentRoute()
const state = navigationRef.getRootState()
const tabsRoute = state.routes.find((r) => r.name === 'Tabs')
// If we've popped into the player, pop that as well
if (route?.name.includes('Player')) {
navigationRef.dispatch(StackActions.pop())
if (tabsRoute && tabsRoute.state && typeof tabsRoute.state.index === 'number') {
const tabsState = tabsRoute.state
const activeTabIndex = tabsState.index
const activeTabName = tabsState.routes[activeTabIndex!]?.name
route = navigationRef.current?.getCurrentRoute()
}
if (route?.name.includes('Settings')) {
navigationRef.dispatch(TabActions.jumpTo('LibraryTab'))
requestAnimationFrame(() => {
// If we are in Settings, we want to jump to Library
if (activeTabName === 'SettingsTab') {
navigationRef.dispatch(TabActions.jumpTo('LibraryTab'))
requestAnimationFrame(() => {
navigationRef.dispatch(CommonActions.navigate('Artist', { artist }))
})
} else {
// For Home, Library, Search, Discover - they all have 'Artist' in their stack
navigationRef.dispatch(CommonActions.navigate('Artist', { artist }))
})
} else navigationRef.dispatch(CommonActions.navigate('Artist', { artist }))
}
} else {
navigationRef.dispatch(CommonActions.navigate('Artist', { artist }))
}
}
+3 -1
View File
@@ -12,6 +12,8 @@ export default function Index(): React.JSX.Element {
const { refreshing, refresh, publicPlaylists, suggestedArtistsInfiniteQuery } =
useDiscoverContext()
const publicPlaylistsLength = (publicPlaylists ?? []).length
return (
<ScrollView
contentContainerStyle={{
@@ -34,7 +36,7 @@ export default function Index(): React.JSX.Element {
<RecentlyAdded />
</View>
{publicPlaylists && (
{publicPlaylistsLength > 0 && (
<View testID='discover-public-playlists'>
<PublicPlaylists />
</View>
@@ -139,7 +139,7 @@ export default function AZScroller({
const animatedOverlayStyle = useAnimatedStyle(() => ({
opacity: overlayOpacity.value,
transform: [{ scale: overlayOpacity.value }],
top: gesturePositionY.get(),
top: gesturePositionY.get() + 20,
}))
const handleLetterLayout = (event: LayoutChangeEvent) => {
+1 -1
View File
@@ -31,7 +31,7 @@ export default function ItemImage({
}: ItemImageProps): React.JSX.Element {
const api = useApi()
const imageUrl = useMemo(() => getItemImageUrl(api, item, type), [api, item.Id, type])
const imageUrl = getItemImageUrl(api, item, type)
return api ? (
<Image
+56 -15
View File
@@ -1,5 +1,5 @@
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'
import { XStack, YStack } from 'tamagui'
import { XStack, YStack, getToken } from 'tamagui'
import { Text } from '../helpers/text'
import Icon from './icon'
import { QueuingType } from '../../../enums/queuing-type'
@@ -14,7 +14,8 @@ import { useNetworkStatus } from '../../../stores/network'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import useItemContext from '../../../hooks/use-item-context'
import { RouteProp, useRoute } from '@react-navigation/native'
import { useCallback } from 'react'
import React, { useCallback, useState } from 'react'
import { LayoutChangeEvent } from 'react-native'
import Animated, { useAnimatedStyle } from 'react-native-reanimated'
import { useSwipeableRowContext } from './swipeable-row-context'
import SwipeableRow from './SwipeableRow'
@@ -23,6 +24,7 @@ import { buildSwipeConfig } from '../helpers/swipe-actions'
import { useIsFavorite } from '../../../api/queries/user-data'
import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favorite'
import { useApi } from '../../../stores'
import { useHideRunTimesSetting } from '../../../stores/settings/app'
interface ItemRowProps {
item: BaseItemDto
@@ -50,6 +52,8 @@ export default function ItemRow({
onPress,
queueName,
}: ItemRowProps): React.JSX.Element {
const [artworkAreaWidth, setArtworkAreaWidth] = useState(0)
const api = useApi()
const [networkStatus] = useNetworkStatus()
@@ -60,6 +64,7 @@ export default function ItemRow({
const addToQueue = useAddToQueue()
const { mutate: addFavorite } = useAddFavorite()
const { mutate: removeFavorite } = useRemoveFavorite()
const [hideRunTimes] = useHideRunTimesSetting()
const warmContext = useItemContext()
const { data: isFavorite } = useIsFavorite(item)
@@ -113,10 +118,13 @@ export default function ItemRow({
}
}, [loadNewQueue, item, navigation])
const renderRunTime = item.Type === BaseItemKind.Audio
const renderRunTime = item.Type === BaseItemKind.Audio && !hideRunTimes
const isAudio = item.Type === 'Audio'
const playlistTrackCount =
item.Type === 'Playlist' ? (item.SongCount ?? item.ChildCount ?? 0) : undefined
const leftSettings = useSwipeSettingsStore((s) => s.left)
const rightSettings = useSwipeSettingsStore((s) => s.right)
@@ -168,17 +176,26 @@ export default function ItemRow({
pressStyle={{ opacity: 0.5 }}
paddingVertical={'$2'}
paddingRight={'$2'}
paddingLeft={'$1'}
backgroundColor={'$background'}
borderRadius={'$2'}
>
<HideableArtwork item={item} circular={circular} />
<StickyText item={item} />
<HideableArtwork
item={item}
circular={circular}
onLayout={(e) => setArtworkAreaWidth(e.nativeEvent.layout.width)}
/>
<SlidingTextArea leftGapWidth={artworkAreaWidth}>
<ItemRowDetails item={item} />
</SlidingTextArea>
<XStack justifyContent='flex-end' alignItems='center' flex={2}>
{renderRunTime ? (
<RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
) : ['Playlist'].includes(item.Type ?? '') ? (
<Text
color={'$borderColor'}
>{`${item.ChildCount ?? 0} ${item.ChildCount === 1 ? 'Track' : 'Tracks'}`}</Text>
) : item.Type === 'Playlist' ? (
<Text color={'$borderColor'}>
{`${playlistTrackCount ?? 0} ${playlistTrackCount === 1 ? 'Track' : 'Tracks'}`}
</Text>
) : null}
<FavoriteIcon item={item} />
@@ -238,9 +255,11 @@ function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element {
function HideableArtwork({
item,
circular,
onLayout,
}: {
item: BaseItemDto
circular?: boolean
onLayout?: (event: LayoutChangeEvent) => void
}): React.JSX.Element {
const { tx } = useSwipeableRowContext()
// Hide artwork as soon as swiping starts (any non-zero tx)
@@ -248,7 +267,7 @@ function HideableArtwork({
opacity: tx.value === 0 ? 1 : 0,
}))
return (
<Animated.View style={style}>
<Animated.View style={style} onLayout={onLayout}>
<YStack marginHorizontal={'$3'} justifyContent='center'>
<ItemImage
item={item}
@@ -261,12 +280,34 @@ function HideableArtwork({
)
}
// Text/details remain visible. No counter-translation needed now that underlays are width-bound.
function StickyText({ item }: { item: BaseItemDto }): React.JSX.Element {
const style = useAnimatedStyle(() => ({}))
function SlidingTextArea({
leftGapWidth,
children,
}: {
leftGapWidth: number
children: React.ReactNode
}): React.JSX.Element {
const { tx, rightWidth } = useSwipeableRowContext()
const tokenValue = getToken('$2', 'space')
const spacingValue = typeof tokenValue === 'number' ? tokenValue : parseFloat(`${tokenValue}`)
const quickActionBuffer = Number.isFinite(spacingValue) ? spacingValue : 8
const style = useAnimatedStyle(() => {
const t = tx.value
let offset = 0
if (t > 0 && leftGapWidth > 0) {
offset = -Math.min(t, leftGapWidth)
} else if (t < 0) {
const rightSpace = Math.max(0, rightWidth)
const compensate = Math.min(-t, rightSpace)
const progress = rightSpace > 0 ? compensate / rightSpace : 1
offset = compensate * 0.7 + quickActionBuffer * progress
}
return { transform: [{ translateX: offset }] }
})
const paddingRightValue = Number.isFinite(spacingValue) ? spacingValue : 8
return (
<Animated.View style={[style, { flex: 5 }]}>
<ItemRowDetails item={item} />
<Animated.View style={[{ flex: 5, paddingRight: paddingRightValue }, style]}>
{children}
</Animated.View>
)
}
@@ -114,7 +114,12 @@ export default function LibrarySelector({
return (
<SafeAreaView style={{ flex: 1 }}>
<YStack flex={1} justifyContent='center' paddingHorizontal={'$4'}>
<YStack
flex={1}
justifyContent='center'
paddingHorizontal={'$4'}
marginBottom={isOnboarding ? '$20' : 'unset'}
>
<YStack flex={1} alignItems='center' justifyContent='flex-end'>
<H2 textAlign='center' marginBottom={'$2'}>
{title}
+2 -1
View File
@@ -1,8 +1,9 @@
import { GestureResponderEvent } from 'react-native'
import { Button as TamaguiButton, ButtonProps as TamaguiButtonProps } from 'tamagui'
interface ButtonProps extends TamaguiButtonProps {
children?: Element | string | undefined
onPress?: () => void | undefined
onPress?: ((event: GestureResponderEvent) => void) | undefined
disabled?: boolean | undefined
danger?: boolean | undefined
}
+5 -32
View File
@@ -1,6 +1,6 @@
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'
import PlaylistsTab from './components/playlists-tab'
import { getToken, useTheme } from 'tamagui'
import { getToken, getTokenValue, useTheme } from 'tamagui'
import Icon from '../Global/components/icon'
import TracksTab from './components/tracks-tab'
import ArtistsTab from './components/artists-tab'
@@ -21,9 +21,9 @@ export default function LibraryScreen({
<LibraryTabsNavigator.Navigator
tabBar={(props) => <LibraryTabBar {...props} />}
screenOptions={{
tabBarShowIcon: true,
tabBarItemStyle: {
height: getToken('$12') + getToken('$6'),
tabBarIndicatorStyle: {
borderColor: theme.background.val,
borderBottomWidth: getTokenValue('$2'),
},
tabBarActiveTintColor: theme.background.val,
tabBarInactiveTintColor: theme.background50.val,
@@ -31,6 +31,7 @@ export default function LibraryScreen({
backgroundColor: theme.primary.val,
},
tabBarLabelStyle: {
fontSize: 16,
fontFamily: 'Figtree-Bold',
},
tabBarPressOpacity: 0.5,
@@ -41,13 +42,6 @@ export default function LibraryScreen({
name='Artists'
component={ArtistsTab}
options={{
tabBarIcon: ({ focused, color }) => (
<Icon
name='microphone-variant'
color={focused ? '$background' : '$background50'}
small
/>
),
tabBarButtonTestID: 'library-artists-tab-button',
}}
/>
@@ -56,13 +50,6 @@ export default function LibraryScreen({
name='Albums'
component={AlbumsTab}
options={{
tabBarIcon: ({ focused, color }) => (
<Icon
name={`music-box-multiple${!focused ? '-outline' : ''}`}
color={focused ? '$background' : '$background50'}
small
/>
),
tabBarButtonTestID: 'library-albums-tab-button',
}}
/>
@@ -71,13 +58,6 @@ export default function LibraryScreen({
name='Tracks'
component={TracksTab}
options={{
tabBarIcon: ({ focused, color }) => (
<Icon
name='music-clef-treble'
color={focused ? '$background' : '$background50'}
small
/>
),
tabBarButtonTestID: 'library-tracks-tab-button',
}}
/>
@@ -86,13 +66,6 @@ export default function LibraryScreen({
name='Playlists'
component={PlaylistsTab}
options={{
tabBarIcon: ({ focused, color }) => (
<Icon
name='playlist-music'
color={focused ? '$background' : '$background50'}
small
/>
),
tabBarButtonTestID: 'library-playlists-tab-button',
}}
/>
+2 -2
View File
@@ -28,9 +28,9 @@ function LibraryTabBar(props: MaterialTopTabBarProps) {
borderColor={'$borderColor'}
alignContent={'flex-start'}
justifyContent='flex-start'
paddingHorizontal={'$2'}
paddingHorizontal={'$1'}
paddingVertical={'$2'}
gap={'$4'}
gap={'$2'}
maxWidth={'80%'}
>
{props.state.routes[props.state.index].name === 'Playlists' ? (
+45 -16
View File
@@ -1,22 +1,23 @@
import { XStack, YStack, Spacer, useTheme } from 'tamagui'
import { Text } from '../../Global/helpers/text'
import React, { useMemo } from 'react'
import React, { useCallback, useMemo, useRef } from 'react'
import ItemImage from '../../Global/components/image'
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
import { Platform } from 'react-native'
import Animated, {
FadeIn,
FadeOut,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import { LayoutChangeEvent, Platform, View } from 'react-native'
import MaterialDesignIcons from '@react-native-vector-icons/material-design-icons'
import navigationRef from '../../../../navigation'
import { useCurrentTrack, useQueueRef } from '../../../stores/player/queue'
export default function PlayerHeader(): React.JSX.Element {
const nowPlaying = useCurrentTrack()
const queueRef = useQueueRef()
const theme = useTheme()
const artworkMaxHeight = Platform.OS === 'android' ? '65%' : '70%'
// If the Queue is a BaseItemDto, display the name of it
const playingFrom = useMemo(
() =>
@@ -29,7 +30,7 @@ export default function PlayerHeader(): React.JSX.Element {
)
return (
<YStack flexGrow={1} justifyContent='flex-start' maxHeight={'80%'}>
<YStack flexGrow={1} justifyContent='flex-start'>
<XStack
alignContent='flex-start'
flexShrink={1}
@@ -53,21 +54,49 @@ export default function PlayerHeader(): React.JSX.Element {
<Spacer width={22} />
</XStack>
<YStack
flexGrow={1}
justifyContent='center'
paddingHorizontal={'$2'}
maxHeight={artworkMaxHeight}
marginVertical={'auto'}
>
<PlayerArtwork />
</YStack>
)
}
function PlayerArtwork(): React.JSX.Element {
const nowPlaying = useCurrentTrack()
const artworkMaxHeight = '65%'
const artworkMaxWidth = useSharedValue<number>(300)
const artworkContainerRef = useRef<View>(null)
const animatedStyle = useAnimatedStyle(() => ({
width: artworkMaxWidth.get(),
}))
const handleLayout = useCallback((event: LayoutChangeEvent) => {
artworkMaxWidth.set(event.nativeEvent.layout.height)
}, [])
return (
<YStack
ref={artworkContainerRef}
flex={1}
alignItems='center'
justifyContent='center'
paddingHorizontal={'$2'}
maxHeight={artworkMaxHeight}
marginVertical={'auto'}
onLayout={handleLayout}
>
{nowPlaying && (
<Animated.View
entering={FadeIn}
exiting={FadeOut}
key={`${nowPlaying!.item.AlbumId}-item-image`}
style={{ flex: 1, ...animatedStyle }}
>
<ItemImage item={nowPlaying!.item} testID='player-image-test-id' />
</Animated.View>
</YStack>
)}
</YStack>
)
}
+3 -19
View File
@@ -3,7 +3,6 @@ import { getToken, XStack, YStack } from 'tamagui'
import { TextTickerConfig } from '../component.config'
import { Text } from '../../Global/helpers/text'
import React, { useCallback, useMemo } from 'react'
import ItemImage from '../../Global/components/image'
import { useQuery } from '@tanstack/react-query'
import { fetchItem } from '../../../api/queries/item'
import FavoriteButton from '../../Global/components/favorite-button'
@@ -12,8 +11,8 @@ import navigationRef from '../../../../navigation'
import Icon from '../../Global/components/icon'
import { getItemName } from '../../../utils/text'
import { CommonActions } from '@react-navigation/native'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import Animated, { useSharedValue, withDelay, withSpring } from 'react-native-reanimated'
import { Gesture } from 'react-native-gesture-handler'
import { useSharedValue, withDelay, withSpring } from 'react-native-reanimated'
import type { SharedValue } from 'react-native-reanimated'
import { runOnJS } from 'react-native-worklets'
import { usePrevious, useSkip } from '../../../providers/Player/hooks/mutations'
@@ -89,13 +88,6 @@ export default function SongInfo({ swipeX }: SongInfoProps = {}): React.JSX.Elem
}, [nowPlaying?.item.ArtistItems])
// Memoize navigation handlers
const handleAlbumPress = useCallback(() => {
if (album) {
navigationRef.goBack() // Dismiss player modal
navigationRef.dispatch(CommonActions.navigate('Album', { album }))
}
}, [album])
const handleArtistPress = useCallback(() => {
if (artistItems) {
if (artistItems.length > 1) {
@@ -113,15 +105,7 @@ export default function SongInfo({ swipeX }: SongInfoProps = {}): React.JSX.Elem
return (
<XStack>
<GestureDetector gesture={albumGesture}>
<Animated.View>
<YStack marginRight={'$2.5'} onPress={handleAlbumPress} justifyContent='center'>
<ItemImage item={nowPlaying!.item} width={'$12'} height={'$12'} />
</YStack>
</Animated.View>
</GestureDetector>
<YStack justifyContent='flex-start' flex={1} gap={'$0.25'}>
<YStack justifyContent='flex-start' flexGrow={1} gap={'$0.25'}>
<TextTicker {...TextTickerConfig} style={{ height: getToken('$9') }}>
<Text bold fontSize={'$6'}>
{trackTitle}
+14 -16
View File
@@ -172,24 +172,22 @@ export default function PlayerScreen(): React.JSX.Element {
/>
</GestureDetector>
<Animated.View style={{ flex: 1 }}>
<YStack
justifyContent='center'
flex={1}
marginHorizontal={'$5'}
{...mainContainerStyle}
>
{/* flexGrow 1 */}
<PlayerHeader />
<YStack
justifyContent='center'
flex={1}
marginHorizontal={'$5'}
{...mainContainerStyle}
>
{/* flexGrow 1 */}
<PlayerHeader />
<YStack justifyContent='flex-start' gap={'$5'} flexShrink={1}>
<SongInfo />
<Scrubber />
<Controls />
<Footer />
</YStack>
<YStack justifyContent='flex-start' gap={'$4'} flexShrink={1}>
<SongInfo />
<Scrubber />
<Controls />
<Footer />
</YStack>
</Animated.View>
</YStack>
</ZStack>
)}
+6 -58
View File
@@ -17,9 +17,9 @@ export default function Settings(): React.JSX.Element {
return (
<SettingsTabsNavigator.Navigator
screenOptions={{
tabBarShowIcon: true,
tabBarItemStyle: {
height: getToken('$12') + getToken('$6'),
tabBarIndicatorStyle: {
borderColor: theme.background.val,
borderBottomWidth: getTokenValue('$2'),
},
tabBarActiveTintColor: theme.background.val,
tabBarInactiveTintColor: theme.background50.val,
@@ -29,7 +29,6 @@ export default function Settings(): React.JSX.Element {
tabBarLabelStyle: {
fontFamily: 'Figtree-Bold',
},
tabBarPressOpacity: 0.5,
lazy: true, // Enable lazy loading to prevent all tabs from mounting simultaneously
}}
@@ -40,13 +39,6 @@ export default function Settings(): React.JSX.Element {
component={PreferencesTab}
options={{
title: 'App',
tabBarIcon: ({ focused, color }: { focused: boolean; color: string }) => (
<Icon
name={`jellyfish${!focused ? '-outline' : ''}`}
color={focused ? '$background' : '$background50'}
small
/>
),
}}
/>
@@ -55,58 +47,14 @@ export default function Settings(): React.JSX.Element {
component={PlaybackTab}
options={{
title: 'Player',
tabBarIcon: ({ focused, color }: { focused: boolean; color: string }) => (
<Icon
name='cassette'
color={focused ? '$background' : '$background50'}
small
/>
),
}}
/>
<SettingsTabsNavigator.Screen
name='Usage'
component={StorageTab}
options={{
title: 'Usage',
tabBarIcon: ({ focused, color }: { focused: boolean; color: string }) => (
<Icon
name='harddisk'
color={focused ? '$background' : '$background50'}
small
/>
),
}}
/>
<SettingsTabsNavigator.Screen name='Usage' component={StorageTab} />
<SettingsTabsNavigator.Screen
name='User'
component={AccountTab}
options={{
tabBarIcon: ({ focused, color }: { focused: boolean; color: string }) => (
<Icon
name='account-music'
color={focused ? '$background' : '$background50'}
small
/>
),
}}
/>
<SettingsTabsNavigator.Screen name='User' component={AccountTab} />
<SettingsTabsNavigator.Screen
name='About'
component={InfoTab}
options={{
tabBarIcon: ({ focused, color }: { focused: boolean; color: string }) => (
<Icon
name={`information${!focused ? '-outline' : ''}`}
color={focused ? '$background' : '$background50'}
small
/>
),
}}
/>
<SettingsTabsNavigator.Screen name='About' component={InfoTab} />
{/*
<SettingsTabsNavigator.Screen
name='Labs'
@@ -1,7 +1,6 @@
import { RadioGroup, YStack, XStack, Paragraph, SizableText } from 'tamagui'
import { YStack, XStack, Paragraph, SizableText } from 'tamagui'
import { SwitchWithLabel } from '../../Global/helpers/switch-with-label'
import SettingsListGroup from './settings-list-group'
import { RadioGroupItemWithLabel } from '../../Global/helpers/radio-group-item-with-label'
import {
ThemeSetting,
useHideRunTimesSetting,
@@ -14,6 +13,35 @@ import { useMemo } from 'react'
import Button from '../../Global/helpers/button'
import Icon from '../../Global/components/icon'
type ThemeOptionConfig = {
value: ThemeSetting
label: string
icon: string
}
const THEME_OPTIONS: ThemeOptionConfig[] = [
{
value: 'system',
label: 'Match Device',
icon: 'theme-light-dark',
},
{
value: 'light',
label: 'Light',
icon: 'white-balance-sunny',
},
{
value: 'dark',
label: 'Dark',
icon: 'weather-night',
},
{
value: 'oled',
label: 'OLED Black',
icon: 'invert-colors',
},
]
export default function PreferencesTab(): React.JSX.Element {
const [sendMetrics, setSendMetrics] = useSendMetricsSetting()
const [reducedHaptics, setReducedHaptics] = useReducedHapticsSetting()
@@ -83,19 +111,14 @@ export default function PreferencesTab(): React.JSX.Element {
iconColor: `${themeSetting === 'system' ? '$borderColor' : '$primary'}`,
children: (
<YStack gap='$2' paddingVertical='$2'>
<RadioGroup
value={themeSetting}
onValueChange={(value) => setThemeSetting(value as ThemeSetting)}
>
<RadioGroupItemWithLabel size='$3' value='system' label='System' />
<RadioGroupItemWithLabel size='$3' value='light' label='Light' />
<RadioGroupItemWithLabel size='$3' value='dark' label='Dark' />
<RadioGroupItemWithLabel
size='$3'
value='oled'
label='OLED (True Black)'
{THEME_OPTIONS.map((option) => (
<ThemeOptionCard
key={option.value}
option={option}
isSelected={themeSetting === option.value}
onPress={() => setThemeSetting(option.value)}
/>
</RadioGroup>
))}
</YStack>
),
},
@@ -111,12 +134,13 @@ export default function PreferencesTab(): React.JSX.Element {
menu.
</Paragraph>
<XStack
alignItems='center'
alignItems='flex-start'
justifyContent='space-between'
gap={'$3'}
paddingTop={'$2'}
flexWrap='wrap'
>
<YStack gap={'$2'} flex={1}>
<YStack gap={'$2'} flex={1} flexBasis='48%' minWidth={240}>
<SizableText size={'$3'}>Swipe Left</SizableText>
<XStack gap={'$2'} flexWrap='wrap'>
<ActionChip
@@ -142,7 +166,7 @@ export default function PreferencesTab(): React.JSX.Element {
/>
</XStack>
</YStack>
<YStack gap={'$2'} flex={1}>
<YStack gap={'$2'} flex={1} flexBasis='48%' minWidth={240}>
<SizableText size={'$3'}>Swipe Right</SizableText>
<XStack gap={'$2'} flexWrap='wrap'>
<ActionChip
@@ -218,3 +242,39 @@ export default function PreferencesTab(): React.JSX.Element {
/>
)
}
function ThemeOptionCard({
option,
isSelected,
onPress,
}: {
option: ThemeOptionConfig
isSelected: boolean
onPress: () => void
}) {
return (
<YStack
onPress={onPress}
pressStyle={{ scale: 0.97 }}
animation='quick'
borderWidth={'$1'}
borderColor={isSelected ? '$primary' : '$borderColor'}
backgroundColor={isSelected ? '$background25' : '$background'}
borderRadius={'$9'}
padding='$3'
gap='$2'
hitSlop={8}
accessibilityRole='button'
accessibilityLabel={`${option.label} theme option`}
accessibilityState={{ selected: isSelected }}
>
<XStack alignItems='center' gap='$2'>
<Icon small name={option.icon} color={isSelected ? '$primary' : '$borderColor'} />
<SizableText size={'$4'} flex={1} fontWeight='600'>
{option.label}
</SizableText>
{isSelected && <Icon small name='check-circle-outline' color={'$primary'} />}
</XStack>
</YStack>
)
}
@@ -8,15 +8,16 @@ import {
useAutoDownload,
useDownloadQuality,
} from '../../../stores/settings/usage'
import { useNetworkContext } from '../../../providers/Network'
import { useMemo } from 'react'
import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { SettingsStackParamList } from '../../../screens/Settings/types'
export default function StorageTab(): React.JSX.Element {
const [autoDownload, setAutoDownload] = useAutoDownload()
const [downloadQuality, setDownloadQuality] = useDownloadQuality()
const { data: downloadedTracks } = useAllDownloadedTracks()
const { pendingDownloads } = useNetworkContext()
const navigation =
useNavigation<NativeStackNavigationProp<SettingsStackParamList, 'Settings'>>()
return (
<SettingsListGroup
@@ -25,9 +26,10 @@ export default function StorageTab(): React.JSX.Element {
title: 'Downloaded Tracks',
subTitle: `${downloadedTracks?.length ?? '0'} ${
downloadedTracks?.length === 1 ? 'song' : 'songs'
} in your pocket`,
} stored offline · tap to manage`,
iconName: 'harddisk',
iconColor: '$primary',
onPress: () => navigation.navigate('StorageManagement'),
},
{
title: 'Automatically Cache Tracks',
+9 -8
View File
@@ -7,7 +7,6 @@ import Icon from '../Global/components/icon'
import { useNetworkContext } from '../../providers/Network'
import { getToken, View } from 'tamagui'
import { Text } from '../Global/helpers/text'
import { useAllDownloadedTracks } from '../../api/queries/download'
// 🔹 Single Download Item with animated progress bar
function DownloadItem({
@@ -46,8 +45,6 @@ export default function StorageBar(): React.JSX.Element {
const { activeDownloads: activeDownloadsArray } = useNetworkContext()
const { data: downloadedTracks } = useAllDownloadedTracks()
const usageShared = useSharedValue(0)
const percentUsed = used / total
@@ -73,11 +70,15 @@ export default function StorageBar(): React.JSX.Element {
}
const deleteAllDownloads = async () => {
for (const file of downloadedTracks ?? []) {
await RNFS.unlink(file.url).catch(() => {})
}
Alert.alert('Deleted', 'All downloads removed.')
deleteAudioCache()
const result = await deleteAudioCache()
Alert.alert(
'Downloads removed',
`Deleted ${result.deletedCount} ${result.deletedCount === 1 ? 'item' : 'items'} and freed ${(
result.freedBytes /
1024 /
1024
).toFixed(2)} MB`,
)
refreshStats()
}
+4
View File
@@ -168,6 +168,10 @@ export default function Tracks({
</Text>
</YStack>
}
stickyHeaderConfig={{
// When this is true the flashlist likes to flicker
useNativeDriver: false,
}}
/>
{showAlphabeticalSelector && trackPageParams && (
+10 -7
View File
@@ -10,15 +10,16 @@ import {
useTelemetryDeck,
} from '@typedigital/telemetrydeck-react'
import telemetryDeckConfig from '../../telemetrydeck.json'
import glitchtipConfig from '../../glitchtip.json'
import * as Sentry from '@sentry/react-native'
import { getToken, Theme, useTheme } from 'tamagui'
import Toast from 'react-native-toast-message'
import JellifyToastConfig from '../configs/toast.config'
import { useColorScheme } from 'react-native'
import { CarPlayProvider } from '../providers/CarPlay'
import { StorageProvider } from '../providers/Storage'
import { useSelectPlayerEngine } from '../stores/player/engine'
import { useSendMetricsSetting, useThemeSetting } from '../stores/settings/app'
import { GLITCHTIP_DSN } from '../configs/config'
/**
* The main component for the Jellify app. Children are wrapped in the {@link JellifyProvider}
* @returns The {@link Jellify} component
@@ -53,8 +54,8 @@ function JellifyLoggingWrapper({ children }: { children: React.ReactNode }): Rea
const telemetrydeck = createTelemetryDeck(telemetryDeckConfig)
// only initialize Sentry when we actually have a valid DSN and are sending metrics
if (sendMetrics && glitchtipConfig.dsn) {
Sentry.init({ ...glitchtipConfig, enableNative: !__DEV__ })
if (sendMetrics && GLITCHTIP_DSN) {
Sentry.init({ dsn: GLITCHTIP_DSN, enableNative: !__DEV__ })
}
return <TelemetryDeckProvider telemetryDeck={telemetrydeck}>{children}</TelemetryDeckProvider>
@@ -77,10 +78,12 @@ function App(): React.JSX.Element {
return (
<NetworkContextProvider>
<CarPlayProvider />
<PlayerProvider />
<Root />
<Toast topOffset={getToken('$12')} config={JellifyToastConfig(theme)} />
<StorageProvider>
<CarPlayProvider />
<PlayerProvider />
<Root />
<Toast topOffset={getToken('$12')} config={JellifyToastConfig(theme)} />
</StorageProvider>
</NetworkContextProvider>
)
}
+2 -1
View File
@@ -2,8 +2,9 @@ import Config from 'react-native-config'
const OTA_UPDATE_ENABLED = Config.OTA_UPDATE_ENABLED === 'true'
const IS_MAESTRO_BUILD = Config.IS_MAESTRO_BUILD === 'true'
const GLITCHTIP_DSN = Config.GLITCHTIP_DSN
export { OTA_UPDATE_ENABLED, IS_MAESTRO_BUILD }
export { OTA_UPDATE_ENABLED, IS_MAESTRO_BUILD, GLITCHTIP_DSN }
export const MONOCHROME_ICON_URL =
'https://raw.githubusercontent.com/Jellify-Music/App/refs/heads/main/assets/monochrome-logo.svg'
@@ -1,6 +1,9 @@
import { ONE_DAY } from '../../constants/query-client'
import { ImageFormat } from '@jellyfin/sdk/lib/generated-client/models'
export enum MaxPages {
Library = 5,
}
export enum ApiLimits {
Discover = 50,
Home = 100,
+6
View File
@@ -46,6 +46,12 @@ const NetworkContextInitializer = () => {
saveAudio(file, setDownloadProgress, false).then((success) => {
setDownloading((prev) => prev.filter((f) => f.item.Id !== file.item.Id))
setDownloadProgress((prev) => {
const next = { ...prev }
delete next[file.url]
if (file.artwork) delete next[file.artwork]
return next
})
if (success) {
setCompleted((prev) => [...prev, file])
} else {
+4 -1
View File
@@ -10,6 +10,7 @@ import { getCurrentTrack } from '.'
import { JellifyDownload } from '../../../types/JellifyDownload'
import { usePlayerQueueStore } from '../../../stores/player/queue'
import { getAudioCache } from '../../../api/mutations/download/offlineModeUtils'
import { isUndefined } from 'lodash'
type LoadQueueResult = {
finalStartIndex: number
@@ -69,6 +70,8 @@ export async function loadQueue({
console.debug(`Final start index is ${finalStartIndex}`)
await TrackPlayer.stop()
/**
* Keep the requested track as the currently playing track so there
* isn't any flickering in the miniplayer
@@ -107,7 +110,7 @@ export const playNextInQueue = async ({ api, deviceProfile, tracks }: AddToQueue
// If we're already at the end of the queue, add the track to the end
if (currentIndex === currentQueue.length - 1) await TrackPlayer.add(tracksToPlayNext)
// Else as long as we have an active index, we'll add the track(s) after that
else if (currentIndex) await TrackPlayer.add(tracksToPlayNext, currentIndex + 1)
else if (!isUndefined(currentIndex)) await TrackPlayer.add(tracksToPlayNext, currentIndex + 1)
// Get the active queue, put it in Zustand
const updatedQueue = (await TrackPlayer.getQueue()) as JellifyTrack[]
+259
View File
@@ -0,0 +1,259 @@
import React, {
PropsWithChildren,
createContext,
useCallback,
useContext,
useMemo,
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 { useNetworkContext } from '../Network'
export type StorageSummary = {
totalSpace: number
freeSpace: number
usedByDownloads: number
usedPercentage: number
downloadCount: number
autoDownloadCount: number
manualDownloadCount: number
artworkBytes: number
audioBytes: number
}
export type CleanupSuggestion = {
id: string
title: string
description: string
itemIds: string[]
freedBytes: number
count: number
}
export type StorageSelectionState = Record<string, boolean>
interface StorageContextValue {
downloads: JellifyDownload[] | undefined
summary: StorageSummary | undefined
suggestions: CleanupSuggestion[]
selection: StorageSelectionState
toggleSelection: (itemId: string) => void
clearSelection: () => void
deleteSelection: () => Promise<DeleteDownloadsResult | undefined>
deleteDownloads: (itemIds: string[]) => Promise<DeleteDownloadsResult | undefined>
isDeleting: boolean
refresh: () => Promise<void>
refreshing: boolean
activeDownloadsCount: number
activeDownloads: JellifyDownloadProgress | undefined
}
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) => {
if (!download) return 0
return (download.fileSizeBytes ?? 0) + (download.artworkSizeBytes ?? 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 { activeDownloads } = useNetworkContext()
const [selection, setSelection] = useState<StorageSelectionState>({})
const [isDeleting, setIsDeleting] = useState(false)
const [isManuallyRefreshing, setIsManuallyRefreshing] = useState(false)
const activeDownloadsCount = useMemo(
() => Object.keys(activeDownloads ?? {}).length,
[activeDownloads],
)
const summary = useMemo<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
return {
totalSpace: storageInfo.totalStorage,
freeSpace: storageInfo.freeSpace,
usedByDownloads,
usedPercentage:
storageInfo.totalStorage > 0 ? usedByDownloads / storageInfo.totalStorage : 0,
downloadCount: downloads.length,
autoDownloadCount: downloads.filter((download) => download.isAutoDownloaded).length,
manualDownloadCount: downloads.filter((download) => !download.isAutoDownloaded).length,
artworkBytes,
audioBytes,
}
}, [downloads, storageInfo])
const suggestions = useMemo<CleanupSuggestion[]>(() => {
if (!downloads || downloads.length === 0) return []
const now = Date.now()
const staleDownloads = downloads.filter((download) => {
const savedAt = new Date(download.savedAt).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,
)
const list: CleanupSuggestion[] = []
if (staleDownloads.length)
list.push({
id: 'stale-downloads',
title: 'Unused in 30+ days',
description: 'Remove tracks you have not touched recently.',
itemIds: staleDownloads.map((download) => download.item.Id as string),
freedBytes: staleDownloads.reduce(
(acc, download) => acc + sumDownloadBytes(download),
0,
),
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.item.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.item.Id as string),
freedBytes: largeDownloads.reduce(
(acc, download) => acc + sumDownloadBytes(download),
0,
),
count: largeDownloads.length,
})
return list
}, [downloads])
const toggleSelection = useCallback((itemId: string) => {
setSelection((prev) => ({
...prev,
[itemId]: !prev[itemId],
}))
}, [])
const clearSelection = useCallback(() => setSelection({}), [])
const deleteDownloads = useCallback(
async (itemIds: string[]): Promise<DeleteDownloadsResult | undefined> => {
if (!itemIds.length) return undefined
setIsDeleting(true)
try {
const result = await deleteDownloadsByIds(itemIds)
await Promise.all([refetchDownloads(), refetchStorageInfo()])
setSelection((prev) => {
const updated = { ...prev }
itemIds.forEach((id) => delete updated[id])
return updated
})
return result
} finally {
setIsDeleting(false)
}
},
[refetchDownloads, refetchStorageInfo],
)
const deleteSelection = useCallback(async () => {
const idsToDelete = Object.entries(selection)
.filter(([, isSelected]) => isSelected)
.map(([id]) => id)
return deleteDownloads(idsToDelete)
}, [selection, deleteDownloads])
const refresh = useCallback(async () => {
setIsManuallyRefreshing(true)
try {
await Promise.all([refetchDownloads(), refetchStorageInfo()])
} finally {
setIsManuallyRefreshing(false)
}
}, [refetchDownloads, refetchStorageInfo])
const refreshing = isFetchingDownloads || isFetchingStorage || isManuallyRefreshing
const value = useMemo<StorageContextValue>(
() => ({
downloads,
summary,
suggestions,
selection,
toggleSelection,
clearSelection,
deleteSelection,
deleteDownloads,
isDeleting,
refresh,
refreshing,
activeDownloadsCount,
activeDownloads,
}),
[
downloads,
summary,
suggestions,
selection,
toggleSelection,
clearSelection,
deleteSelection,
deleteDownloads,
isDeleting,
refresh,
refreshing,
activeDownloadsCount,
],
)
return <StorageContext.Provider value={value}>{children}</StorageContext.Provider>
}
export const useStorageContext = () => {
const context = useContext(StorageContext)
if (!context) throw new Error('StorageContext must be used within a StorageProvider')
return context
}
+21
View File
@@ -3,6 +3,8 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'
import Settings from '../../components/Settings/component'
import SignOutModal from './sign-out-modal'
import LibrarySelectionScreen from './library-selection'
import StorageManagementScreen from './storage-management'
import StorageSelectionModal from './storage-selection-modal'
import { SettingsStackParamList } from './types'
export const SettingsStack = createNativeStackNavigator<SettingsStackParamList>()
@@ -39,6 +41,25 @@ export default function SettingsScreen(): React.JSX.Element {
headerShown: false,
}}
/>
<SettingsStack.Screen
name='StorageManagement'
component={StorageManagementScreen}
options={{
title: 'Storage Management',
animation: 'slide_from_right',
headerShown: true,
}}
/>
<SettingsStack.Screen
name='StorageSelectionReview'
component={StorageSelectionModal}
options={{
presentation: 'formSheet',
sheetAllowedDetents: 'fitToContents',
sheetGrabberVisible: true,
headerShown: false,
}}
/>
</SettingsStack.Navigator>
)
}
+6 -1
View File
@@ -7,10 +7,15 @@ import { useResetQueue } from '../../providers/Player/hooks/mutations'
import navigationRef from '../../../navigation'
import { useClearAllDownloads } from '../../api/mutations/download'
import { useJellifyServer } from '../../stores'
import { StackActions, useNavigation } from '@react-navigation/native'
import { RootStackParamList } from '../types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
export default function SignOutModal({ navigation }: SignOutModalProps): React.JSX.Element {
const [server] = useJellifyServer()
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
const { mutate: resetQueue } = useResetQueue()
const clearDownloads = useClearAllDownloads()
@@ -39,7 +44,7 @@ export default function SignOutModal({ navigation }: SignOutModalProps): React.J
borderColor={'$danger'}
onPress={() => {
navigation.goBack()
navigationRef.navigate('Login', { screen: 'ServerAddress' }, { pop: true })
rootNavigation.navigate('Login', { screen: 'ServerAddress' })
clearDownloads()
resetQueue()
@@ -0,0 +1,581 @@
import React, { useCallback, useMemo, useState } from 'react'
import { FlashList, ListRenderItem } from '@shopify/flash-list'
import { useFocusEffect, useNavigation } from '@react-navigation/native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Pressable, Alert } from 'react-native'
import { Card, Paragraph, Separator, SizableText, Spinner, XStack, YStack, Image } from 'tamagui'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useStorageContext, CleanupSuggestion } from '../../../providers/Storage'
import Icon from '../../../components/Global/components/icon'
import Button from '../../../components/Global/helpers/button'
import { formatBytes } from '../../../utils/format-bytes'
import { JellifyDownload, JellifyDownloadProgress } from '../../../types/JellifyDownload'
import { SettingsStackParamList } from '../types'
import { useDeletionToast } from './useDeletionToast'
const getDownloadSize = (download: JellifyDownload) =>
(download.fileSizeBytes ?? 0) + (download.artworkSizeBytes ?? 0)
const formatSavedAt = (timestamp: string) => {
const parsedDate = new Date(timestamp)
if (Number.isNaN(parsedDate.getTime())) return 'Unknown save date'
return parsedDate.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
})
}
export default function StorageManagementScreen(): React.JSX.Element {
const {
downloads,
summary,
suggestions,
selection,
toggleSelection,
clearSelection,
deleteDownloads,
refresh,
refreshing,
activeDownloadsCount,
activeDownloads,
} = useStorageContext()
const [applyingSuggestionId, setApplyingSuggestionId] = useState<string | null>(null)
const insets = useSafeAreaInsets()
const navigation = useNavigation<NativeStackNavigationProp<SettingsStackParamList>>()
const showDeletionToast = useDeletionToast()
useFocusEffect(
useCallback(() => {
void refresh()
}, [refresh]),
)
const sortedDownloads = useMemo(() => {
if (!downloads) return []
return [...downloads].sort(
(a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime(),
)
}, [downloads])
const selectedIds = useMemo(
() =>
Object.entries(selection)
.filter(([, isSelected]) => isSelected)
.map(([id]) => id),
[selection],
)
const selectedBytes = useMemo(() => {
if (!selectedIds.length || !downloads) return 0
const selectedSet = new Set(selectedIds)
return downloads.reduce((total, download) => {
return selectedSet.has(download.item.Id as string)
? total + getDownloadSize(download)
: total
}, 0)
}, [downloads, selectedIds])
const handleApplySuggestion = useCallback(
async (suggestion: CleanupSuggestion) => {
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)
} finally {
setApplyingSuggestionId(null)
}
},
[deleteDownloads, showDeletionToast],
)
const handleDeleteSingle = useCallback(
async (download: JellifyDownload) => {
const result = await deleteDownloads([download.item.Id as string])
if (result?.deletedCount)
showDeletionToast(`Removed ${download.title ?? 'track'}`, result.freedBytes)
},
[deleteDownloads, showDeletionToast],
)
const handleDeleteAll = useCallback(() => {
Alert.alert(
'Delete all downloads?',
'This will remove all downloaded music from your device. This action cannot be undone.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete All',
style: 'destructive',
onPress: async () => {
if (!downloads) return
const allIds = downloads.map((d) => d.item.Id as string)
const result = await deleteDownloads(allIds)
if (result?.deletedCount)
showDeletionToast(
`Removed ${result.deletedCount} downloads`,
result.freedBytes,
)
},
},
],
)
}, [downloads, deleteDownloads, showDeletionToast])
const handleDeleteSelection = useCallback(() => {
Alert.alert(
'Delete selected items?',
`Are you sure you want to delete ${selectedIds.length} items?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
const result = await deleteDownloads(selectedIds)
if (result?.deletedCount) {
showDeletionToast(
`Removed ${result.deletedCount} downloads`,
result.freedBytes,
)
clearSelection()
}
},
},
],
)
}, [selectedIds, deleteDownloads, showDeletionToast, clearSelection])
const renderDownloadItem: ListRenderItem<JellifyDownload> = useCallback(
({ item }) => (
<DownloadRow
download={item}
isSelected={Boolean(selection[item.item.Id as string])}
onToggle={() => toggleSelection(item.item.Id as string)}
onDelete={() => {
void handleDeleteSingle(item)
}}
/>
),
[selection, toggleSelection, handleDeleteSingle],
)
const topPadding = 16
return (
<YStack flex={1} backgroundColor={'$background'}>
<FlashList
data={sortedDownloads}
keyExtractor={(item) =>
item.item.Id ?? item.url ?? item.title ?? Math.random().toString()
}
contentContainerStyle={{
paddingBottom: insets.bottom + 48,
paddingHorizontal: 16,
paddingTop: topPadding,
}}
ItemSeparatorComponent={Separator}
ListHeaderComponent={
<YStack gap='$4'>
<XStack justifyContent='space-between' alignItems='center'>
{selectedIds.length > 0 && (
<Card
paddingHorizontal='$3'
paddingVertical='$2'
borderRadius='$4'
backgroundColor='$backgroundFocus'
>
<Paragraph fontWeight='600'>
{selectedIds.length} selected
</Paragraph>
</Card>
)}
</XStack>
<StorageSummaryCard
summary={summary}
refreshing={refreshing}
onRefresh={() => {
void refresh()
}}
activeDownloadsCount={activeDownloadsCount}
activeDownloads={activeDownloads}
onDeleteAll={handleDeleteAll}
/>
<CleanupSuggestionsRow
suggestions={suggestions}
onApply={(suggestion) => {
void handleApplySuggestion(suggestion)
}}
busySuggestionId={applyingSuggestionId}
/>
<DownloadsSectionHeading count={downloads?.length ?? 0} />
{selectedIds.length > 0 && (
<SelectionReviewBanner
selectedCount={selectedIds.length}
selectedBytes={selectedBytes}
onDelete={handleDeleteSelection}
onClear={clearSelection}
/>
)}
</YStack>
}
ListEmptyComponent={
<EmptyState
refreshing={refreshing}
onRefresh={() => {
void refresh()
}}
/>
}
renderItem={renderDownloadItem}
/>
</YStack>
)
}
const StorageSummaryCard = ({
summary,
refreshing,
onRefresh,
activeDownloadsCount,
activeDownloads,
onDeleteAll,
}: {
summary: ReturnType<typeof useStorageContext>['summary']
refreshing: boolean
onRefresh: () => void
activeDownloadsCount: number
activeDownloads: JellifyDownloadProgress | undefined
onDeleteAll: () => void
}) => {
return (
<Card
backgroundColor={'$backgroundFocus'}
padding='$4'
borderRadius='$6'
borderWidth={1}
borderColor={'$borderColor'}
>
<XStack justifyContent='space-between' alignItems='center' marginBottom='$3'>
<SizableText size='$5' fontWeight='600'>
Storage overview
</SizableText>
<XStack gap='$2'>
<Button
size='$2'
circular
backgroundColor='transparent'
hitSlop={10}
icon={() =>
refreshing ? (
<Spinner size='small' color='$color' />
) : (
<Icon name='refresh' color='$color' />
)
}
onPress={onRefresh}
accessibilityLabel='Refresh storage overview'
/>
<Button
size='$2'
backgroundColor='$danger'
borderColor='$danger'
borderWidth={1}
color='white'
onPress={onDeleteAll}
icon={() => <Icon name='delete-outline' color='$color' small />}
>
Delete All
</Button>
</XStack>
</XStack>
{summary ? (
<YStack gap='$4'>
<YStack gap='$1'>
<SizableText size='$8' fontWeight='700'>
{formatBytes(summary.usedByDownloads)}
</SizableText>
<Paragraph color='$borderColor'>
Used by offline music · {formatBytes(summary.freeSpace)} free on device
</Paragraph>
</YStack>
<YStack gap='$2'>
<ProgressBar progress={summary.usedPercentage} />
<Paragraph color='$borderColor'>
{summary.downloadCount} downloads · {summary.manualDownloadCount} manual
· {summary.autoDownloadCount} auto
</Paragraph>
</YStack>
<StatGrid summary={summary} />
</YStack>
) : (
<YStack gap='$2'>
<Spinner />
<Paragraph color='$borderColor'>Calculating storage usage</Paragraph>
</YStack>
)}
</Card>
)
}
const ProgressBar = ({ progress }: { progress: number }) => (
<YStack height={10} borderRadius={999} backgroundColor={'$backgroundHover'}>
<YStack
height={10}
borderRadius={999}
backgroundColor={'$primary'}
width={`${Math.min(1, Math.max(0, progress)) * 100}%`}
/>
</YStack>
)
const CleanupSuggestionsRow = ({
suggestions,
onApply,
busySuggestionId,
}: {
suggestions: CleanupSuggestion[]
onApply: (suggestion: CleanupSuggestion) => void
busySuggestionId: string | null
}) => {
if (!suggestions.length) return null
return (
<YStack gap='$3'>
<SizableText size='$5' fontWeight='600'>
Cleanup ideas
</SizableText>
<XStack gap='$3' flexWrap='wrap'>
{suggestions.map((suggestion) => (
<Card
key={suggestion.id}
padding='$3'
borderRadius='$4'
backgroundColor={'$backgroundFocus'}
borderWidth={1}
borderColor={'$borderColor'}
flexGrow={1}
flexBasis='48%'
>
<YStack gap='$2'>
<SizableText size='$4' fontWeight='600'>
{suggestion.title}
</SizableText>
<Paragraph color='$borderColor'>
{suggestion.count} items · {formatBytes(suggestion.freedBytes)}
</Paragraph>
<Paragraph color='$borderColor'>{suggestion.description}</Paragraph>
<Button
size='$3'
width='100%'
backgroundColor='$primary'
borderColor='$primary'
borderWidth={1}
color='$background'
disabled={busySuggestionId === suggestion.id}
icon={() =>
busySuggestionId === suggestion.id ? (
<Spinner size='small' color='$background' />
) : (
<Icon name='broom' color='$background' />
)
}
onPress={() => onApply(suggestion)}
>
Free {formatBytes(suggestion.freedBytes)}
</Button>
</YStack>
</Card>
))}
</XStack>
</YStack>
)
}
const DownloadRow = ({
download,
isSelected,
onToggle,
onDelete,
}: {
download: JellifyDownload
isSelected: boolean
onToggle: () => void
onDelete: () => void
}) => (
<Pressable onPress={onToggle} accessibilityRole='button'>
<XStack padding='$3' alignItems='center' gap='$3' borderRadius='$4'>
<Icon
name={isSelected ? 'check-circle-outline' : 'circle-outline'}
color={isSelected ? '$primary' : '$borderColor'}
/>
{download.artwork ? (
<Image
source={{ uri: download.artwork, width: 50, height: 50 }}
width={50}
height={50}
borderRadius='$2'
/>
) : (
<YStack
width={50}
height={50}
borderRadius='$2'
backgroundColor='$backgroundHover'
alignItems='center'
justifyContent='center'
>
<Icon name='music-note' color='$color' />
</YStack>
)}
<YStack flex={1} gap='$1'>
<SizableText size='$4' fontWeight='600'>
{download.title ?? download.item.SortName ?? 'Unknown track'}
</SizableText>
<Paragraph color='$borderColor'>
{download.album ?? 'Unknown album'} · {formatBytes(getDownloadSize(download))}
</Paragraph>
<Paragraph color='$borderColor'>Saved {formatSavedAt(download.savedAt)}</Paragraph>
</YStack>
<Button
size='$3'
circular
backgroundColor='transparent'
hitSlop={10}
icon={() => <Icon name='delete-outline' color='$danger' />}
onPress={(event) => {
event.stopPropagation()
onDelete()
}}
accessibilityLabel='Delete download'
/>
</XStack>
</Pressable>
)
const EmptyState = ({ refreshing, onRefresh }: { refreshing: boolean; onRefresh: () => void }) => (
<YStack padding='$6' alignItems='center' gap='$3'>
<SizableText size='$6' fontWeight='600'>
No offline music yet
</SizableText>
<Paragraph color='$borderColor' textAlign='center'>
Downloaded tracks will show up here so you can reclaim storage any time.
</Paragraph>
<Button
borderColor='$borderColor'
borderWidth={1}
backgroundColor='$background'
onPress={onRefresh}
icon={() =>
refreshing ? (
<Spinner size='small' color='$borderColor' />
) : (
<Icon name='refresh' color='$borderColor' />
)
}
>
Refresh
</Button>
</YStack>
)
const SelectionReviewBanner = ({
selectedCount,
selectedBytes,
onDelete,
onClear,
}: {
selectedCount: number
selectedBytes: number
onDelete: () => void
onClear: () => void
}) => (
<Card
borderRadius='$6'
borderWidth={1}
borderColor='$borderColor'
backgroundColor='$backgroundFocus'
padding='$3'
>
<YStack gap='$3'>
<XStack justifyContent='space-between' alignItems='center'>
<YStack>
<SizableText size='$5' fontWeight='600'>
Ready to clean up?
</SizableText>
<Paragraph color='$borderColor'>
{selectedCount} {selectedCount === 1 ? 'track' : 'tracks'} ·{' '}
{formatBytes(selectedBytes)}
</Paragraph>
</YStack>
<Button
size='$2'
borderColor='$borderColor'
borderWidth={1}
backgroundColor='$background'
onPress={onClear}
>
Clear
</Button>
</XStack>
<Button
size='$3'
backgroundColor='$danger'
borderColor='$danger'
borderWidth={1}
color='white'
icon={() => <Icon name='delete-outline' color='$color' />}
onPress={onDelete}
>
Delete ({formatBytes(selectedBytes)})
</Button>
</YStack>
</Card>
)
const DownloadsSectionHeading = ({ count }: { count: number }) => (
<XStack alignItems='center' justifyContent='space-between'>
<SizableText size='$5' fontWeight='600'>
Offline library
</SizableText>
<Paragraph color='$borderColor'>
{count} {count === 1 ? 'item' : 'items'} cached
</Paragraph>
</XStack>
)
const StatGrid = ({
summary,
}: {
summary: NonNullable<ReturnType<typeof useStorageContext>['summary']>
}) => (
<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>
)
const StatChip = ({ label, value }: { label: string; value: string }) => (
<YStack
flexGrow={1}
flexBasis='30%'
minWidth={110}
borderWidth={1}
borderColor='$borderColor'
borderRadius='$4'
padding='$3'
backgroundColor={'$background'}
>
<SizableText size='$6' fontWeight='700'>
{value}
</SizableText>
<Paragraph color='$borderColor'>{label}</Paragraph>
</YStack>
)
@@ -0,0 +1,13 @@
import { useCallback } from 'react'
import Toast from 'react-native-toast-message'
import { formatBytes } from '../../../utils/format-bytes'
export const useDeletionToast = () =>
useCallback((message: string, freedBytes: number) => {
Toast.show({
type: 'success',
text1: message,
text2: `Freed ${formatBytes(freedBytes)}`,
})
}, [])
@@ -0,0 +1,147 @@
import React, { useCallback, useMemo } from 'react'
import { ScrollView } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { NativeStackScreenProps } from '@react-navigation/native-stack'
import { Button, Card, Paragraph, Separator, SizableText, Spinner, XStack, YStack } from 'tamagui'
import Icon from '../../components/Global/components/icon'
import { SettingsStackParamList } from './types'
import { useStorageContext } from '../../providers/Storage'
import { formatBytes } from '../../utils/format-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 parsedDate = new Date(timestamp)
if (Number.isNaN(parsedDate.getTime())) return 'Unknown save date'
return parsedDate.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
})
}
export default function StorageSelectionModal({
navigation,
}: NativeStackScreenProps<SettingsStackParamList, 'StorageSelectionReview'>): React.JSX.Element {
const { downloads, selection, deleteSelection, clearSelection, isDeleting } =
useStorageContext()
const showDeletionToast = useDeletionToast()
const { bottom } = useSafeAreaInsets()
const selectedDownloads = useMemo(
() => downloads?.filter((download) => selection[download.item.Id as string]) ?? [],
[downloads, selection],
)
const selectedBytes = useMemo(
() => selectedDownloads.reduce((total, download) => total + getDownloadSize(download), 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])
const handleClose = useCallback(() => {
navigation.goBack()
}, [navigation])
const hasSelection = selectedDownloads.length > 0
return (
<YStack
flex={1}
backgroundColor='$background'
padding='$4'
paddingBottom={bottom + 16}
gap='$4'
>
<XStack justifyContent='space-between' alignItems='center'>
<Button
variant='outlined'
size='$2'
icon={<Icon name='chevron-left' color='$color' />}
onPress={handleClose}
>
Close
</Button>
<SizableText size='$6' fontWeight='700'>
Review selection
</SizableText>
<Button
variant='outlined'
size='$2'
icon={<Icon name='broom' color='$color' />}
onPress={clearSelection}
disabled={!hasSelection}
>
Clear
</Button>
</XStack>
{hasSelection ? (
<YStack gap='$4' flex={1}>
<Card borderWidth={1} borderColor='$borderColor' borderRadius='$6' padding='$4'>
<SizableText size='$7' fontWeight='700'>
{formatBytes(selectedBytes)}
</SizableText>
<Paragraph color='$borderColor'>
{selectedDownloads.length}{' '}
{selectedDownloads.length === 1 ? 'track' : 'tracks'} ready to remove
</Paragraph>
</Card>
<Card borderWidth={1} borderColor='$borderColor' borderRadius='$6' flex={1}>
<ScrollView>
{selectedDownloads.map((download, index) => (
<YStack key={download.item.Id as string}>
<YStack padding='$3' gap='$1'>
<SizableText fontWeight='600'>
{download.title ??
download.item.SortName ??
'Unknown track'}
</SizableText>
<Paragraph color='$borderColor'>
{download.album ?? 'Unknown album'} ·{' '}
{formatBytes(getDownloadSize(download))}
</Paragraph>
<Paragraph color='$borderColor'>
Saved {formatSavedAt(download.savedAt)}
</Paragraph>
</YStack>
{index < selectedDownloads.length - 1 && <Separator />}
</YStack>
))}
</ScrollView>
</Card>
<Button
icon={isDeleting ? <Spinner /> : <Icon name='trash' color='$danger' />}
onPress={handleDelete}
disabled={isDeleting}
backgroundColor='$danger'
color='white'
>
Delete downloads
</Button>
</YStack>
) : (
<Card borderWidth={1} borderColor='$borderColor' borderRadius='$6' padding='$4'>
<SizableText size='$5' fontWeight='600'>
No tracks selected
</SizableText>
<Paragraph color='$borderColor'>
Select some downloads to clean up storage.
</Paragraph>
</Card>
)}
</YStack>
)
}
+2
View File
@@ -4,6 +4,8 @@ export type SettingsStackParamList = {
Settings: undefined
SignOut: undefined
LibrarySelection: undefined
StorageManagement: undefined
StorageSelectionReview: undefined
Account: undefined
Server: undefined
+4
View File
@@ -3,6 +3,10 @@ import JellifyTrack from './JellifyTrack'
export type JellifyDownload = JellifyTrack & {
savedAt: string
isAutoDownloaded: boolean
fileSizeBytes?: number
artworkSizeBytes?: number
playCount?: number
lastPlayedAt?: string
/**
* Path to the downloaded file
+1
View File
@@ -2,6 +2,7 @@ declare module 'react-native-config' {
export interface NativeConfig {
OTA_UPDATE_ENABLED?: string
IS_MAESTRO_BUILD?: string
GLITCHTIP_DSN?: string
}
export const Config: NativeConfig
+8
View File
@@ -0,0 +1,8 @@
export const formatBytes = (bytes: number, decimals = 1): string => {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] as const
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)
const value = bytes / Math.pow(k, i)
return `${value.toFixed(value >= 10 || decimals === 0 ? 0 : decimals)} ${sizes[i]}`
}
+6 -1
View File
@@ -12,7 +12,6 @@ import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import { AudioApi } from '@jellyfin/sdk/lib/generated-client/api'
import { JellifyDownload } from '../types/JellifyDownload'
import { Api } from '@jellyfin/sdk/lib/api'
import RNFS from 'react-native-fs'
import { AudioQuality } from '../types/AudioQuality'
import { queryClient } from '../constants/query-client'
import { isUndefined } from 'lodash'
@@ -22,6 +21,7 @@ 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'
/**
* Gets quality-specific parameters for transcoding
@@ -136,6 +136,11 @@ export function mapDtoToTrack(
} as JellifyTrack
}
function ensureFileUri(path?: string): string | undefined {
if (!path) return undefined
return path.startsWith('file://') ? path : `file://${path}`
}
function buildDownloadedTrack(downloadedTrack: JellifyDownload): TrackMediaInfo {
return {
type: TrackType.Default,
+37 -37
View File
@@ -1922,21 +1922,21 @@
resolved "https://registry.yarnpkg.com/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz#7064533a573e3539ec912f59c1f457371bf49dd9"
integrity sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ==
"@react-native-vector-icons/common@^12.3.0":
version "12.3.0"
resolved "https://registry.yarnpkg.com/@react-native-vector-icons/common/-/common-12.3.0.tgz#8a3cb778fd197e971da0fa69a35fa4fc8b030f1a"
integrity sha512-5GMBcLBkA0MuciweYcrSyvi9fYGanfVnE2J+pwHx1QiaVgTaoCm4rylJgSS77MVI5qUiGh7aJpqq5afSz2U4bw==
"@react-native-vector-icons/common@^12.4.0":
version "12.4.0"
resolved "https://registry.yarnpkg.com/@react-native-vector-icons/common/-/common-12.4.0.tgz#60fc6f4d9a0e7e7b06f7c3a996e320c72c588a15"
integrity sha512-t9W0q+AW7WH1Oj5aEg7wGNXDLZJb5sIVkAWo5qtad3PcbBADqoCdikRK/ToLK+xlB0TxjcuL0T74ogudMkYGeA==
dependencies:
find-up "^7.0.0"
picocolors "^1.1.1"
plist "^3.1.0"
"@react-native-vector-icons/material-design-icons@^12.3.0":
version "12.3.0"
resolved "https://registry.yarnpkg.com/@react-native-vector-icons/material-design-icons/-/material-design-icons-12.3.0.tgz#2cad45c0a12afb0d05f7620eb8b81b4470f15f42"
integrity sha512-0fut9zjUJtGWwjGQ0lbirmPnjMkou9vkBY3d3ZsaHqXCBgV3fGeOWuRZ17eDpsGy/9BTRtBRI85RYdFGhdcB4Q==
"@react-native-vector-icons/material-design-icons@12.4.0":
version "12.4.0"
resolved "https://registry.yarnpkg.com/@react-native-vector-icons/material-design-icons/-/material-design-icons-12.4.0.tgz#e4377ce3214fc97e536190920fa48638930ca31a"
integrity sha512-4ewAiHdOCujqprUJYFnBcUJduNddAc+w3Plnl1NhJksAyOaHzCNBg01JgVtkysxPho6++OOMge3FhwyBT8Wtcg==
dependencies:
"@react-native-vector-icons/common" "^12.3.0"
"@react-native-vector-icons/common" "^12.4.0"
"@react-native/assets-registry@0.82.1":
version "0.82.1"
@@ -2135,19 +2135,19 @@
invariant "^2.2.4"
nullthrows "^1.1.1"
"@react-navigation/bottom-tabs@7.7.1":
version "7.7.1"
resolved "https://registry.yarnpkg.com/@react-navigation/bottom-tabs/-/bottom-tabs-7.7.1.tgz#64f996c1b5cfaacd1173c8cb5b5499a1db761cc7"
integrity sha512-BU4k7To+idoQNsoXZwf71kOgkg7IWCsr5ZYFdqnQi/MjgNEpu46KenofQNw80cT0o7luNupNL2/WgjJnT/tQ2g==
"@react-navigation/bottom-tabs@7.8.5":
version "7.8.5"
resolved "https://registry.yarnpkg.com/@react-navigation/bottom-tabs/-/bottom-tabs-7.8.5.tgz#def0fddd299a665b45a4604d8ae95702860610f4"
integrity sha512-Zm9UOTfEtBLL7Wm+JBc0v/lh72cen9a8WVN5KSCEN7EtiQIPXbQUZg1ktEzme600HhxvaNZzzSz0X+w2E5nG5w==
dependencies:
"@react-navigation/elements" "^2.8.0"
"@react-navigation/elements" "^2.8.2"
color "^4.2.3"
sf-symbols-typescript "^2.1.0"
"@react-navigation/core@^7.13.0":
version "7.13.0"
resolved "https://registry.yarnpkg.com/@react-navigation/core/-/core-7.13.0.tgz#71577637cc32626e208fa994fc13e77394b17932"
integrity sha512-Fc/SO23HnlGnkou/z8JQUzwEMvhxuUhr4rdPTIZp/c8q1atq3k632Nfh8fEiGtk+MP1wtIvXdN2a5hBIWpLq3g==
"@react-navigation/core@^7.13.1":
version "7.13.1"
resolved "https://registry.yarnpkg.com/@react-navigation/core/-/core-7.13.1.tgz#ad013f2adcd8604e947dc8c6e52e8fbfc97a6582"
integrity sha512-aPf1vjQhMytPC9CmJu28hT5eTaBJuqIf9T6IRICtap5HHgFLrsYizLZrg3D0H2AoPyOoijMPWzwf7VCBzfGvrg==
dependencies:
"@react-navigation/routers" "^7.5.1"
escape-string-regexp "^4.0.0"
@@ -2158,40 +2158,40 @@
use-latest-callback "^0.2.4"
use-sync-external-store "^1.5.0"
"@react-navigation/elements@^2.8.0":
version "2.8.0"
resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-2.8.0.tgz#da804a5aed08ba1bcc3c311b70aec99edfaef8ca"
integrity sha512-uvSOkYOF7wfgkt57cl+6fZ2vQgTiYYyJleZzuWthPKHK4nDq2M4sc9SSzgK9GS9UCJFRBErNtl3S+/ErtrwREw==
"@react-navigation/elements@^2.8.2":
version "2.8.2"
resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-2.8.2.tgz#7aa74ac3870303bc86e12c09c3e821efdc63bf03"
integrity sha512-K5NWIMar81oAoRAgLwrWcLpXzY2K5yG3gNU/56uyC12u+i5SyIVAv+ygP36UXvrNLzDigg8OdRSdEBb8ePqQtA==
dependencies:
color "^4.2.3"
use-latest-callback "^0.2.4"
use-sync-external-store "^1.5.0"
"@react-navigation/material-top-tabs@7.4.1":
version "7.4.1"
resolved "https://registry.yarnpkg.com/@react-navigation/material-top-tabs/-/material-top-tabs-7.4.1.tgz#5287a79c4efb1cbf19cf2fff1d85221b069c1e38"
integrity sha512-dox0p78P+dScyRBsCUrSITjG/iXeT/QAj+AS2viBfE2Odr+CmnYbNYawMyAQO/GzYB5ImqP4lgNvP+qBrCypMA==
"@react-navigation/material-top-tabs@7.4.3":
version "7.4.3"
resolved "https://registry.yarnpkg.com/@react-navigation/material-top-tabs/-/material-top-tabs-7.4.3.tgz#e9284e2851a91f349afb7365b779e9de7ac2ca61"
integrity sha512-CI0jJmal0grOvCRIe2aTYUowEDnLh44J+xCwZkbRnCsMNie1sqCfbGFxuOOb/OiMuPRdvi8uCrlARNgTUMsLOg==
dependencies:
"@react-navigation/elements" "^2.8.0"
"@react-navigation/elements" "^2.8.2"
color "^4.2.3"
react-native-tab-view "^4.2.0"
"@react-navigation/native-stack@7.6.1":
version "7.6.1"
resolved "https://registry.yarnpkg.com/@react-navigation/native-stack/-/native-stack-7.6.1.tgz#6854982eb0abc322e711689de6fdae62d0a710c5"
integrity sha512-JbYhLzZD6dHv23bGYusToaOlsdEdMgL/QtKEhwV9fEKgFNoDvkZlak8rTPJUrOlC56QwMOPe1vLG83udlNeVYQ==
"@react-navigation/native-stack@7.6.3":
version "7.6.3"
resolved "https://registry.yarnpkg.com/@react-navigation/native-stack/-/native-stack-7.6.3.tgz#ed39844ebd9e58099de267de4f06f9cbc426120b"
integrity sha512-F0f0+3K1mVWiQEZbyZen0LAl7Gc4qpbWM4Tpva5aCqBAECZyn7/uLbVhSXtC/EwzMqQ+ojPLtceFQhXhJqfqfg==
dependencies:
"@react-navigation/elements" "^2.8.0"
"@react-navigation/elements" "^2.8.2"
color "^4.2.3"
sf-symbols-typescript "^2.1.0"
warn-once "^0.1.1"
"@react-navigation/native@7.1.19":
version "7.1.19"
resolved "https://registry.yarnpkg.com/@react-navigation/native/-/native-7.1.19.tgz#f147e412a9f4a5c5eed85b4bde25171d0ce0297b"
integrity sha512-fM7q8di4Q8sp2WUhiUWOe7bEDRyRhbzsKQOd5N2k+lHeCx3UncsRYuw4Q/KN0EovM3wWKqMMmhy/YWuEO04kgw==
"@react-navigation/native@7.1.20":
version "7.1.20"
resolved "https://registry.yarnpkg.com/@react-navigation/native/-/native-7.1.20.tgz#60e83c62482e1203f72e9ad68c84c7bb81cb98c3"
integrity sha512-15luFq+35M2IOMHgbTJ0XDkPY7gm3YlR3yQKTuOTOHs+EeAUX71DlUuqcWMRqB0tt+OT6HimDQR7OboTB0N30g==
dependencies:
"@react-navigation/core" "^7.13.0"
"@react-navigation/core" "^7.13.1"
escape-string-regexp "^4.0.0"
fast-deep-equal "^3.1.3"
nanoid "^3.3.11"