mirror of
https://github.com/anultravioletaurora/Jellify.git
synced 2026-05-02 11:39:33 -05:00
Merge branch 'main' into skalthoff/issue588
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'))"
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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's Jellyfin server for streaming music</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string></string>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
|
||||
+6
-6
@@ -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
@@ -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 |
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
|
||||
@@ -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,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'
|
||||
/**
|
||||
|
||||
@@ -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,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,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,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'
|
||||
|
||||
|
||||
@@ -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,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,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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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,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,
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+2
@@ -4,6 +4,8 @@ export type SettingsStackParamList = {
|
||||
Settings: undefined
|
||||
SignOut: undefined
|
||||
LibrarySelection: undefined
|
||||
StorageManagement: undefined
|
||||
StorageSelectionReview: undefined
|
||||
|
||||
Account: undefined
|
||||
Server: undefined
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
@@ -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]}`
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user