Merge branch 'main' of https://github.com/Jellify-Music/App into feature/playlist-user-operations

This commit is contained in:
arijohn723
2026-03-03 17:25:47 -06:00
53 changed files with 1295 additions and 471 deletions
+2 -1
View File
@@ -1,2 +1,3 @@
OTA_UPDATE_ENABLED=true
IS_MAESTRO_BUILD = false
IS_MAESTRO_BUILD = false
GLITCHTIP_DSN= ""
+2
View File
@@ -31,6 +31,7 @@ import { CarPlay } from 'react-native-carplay'
import { useAutoStore } from './src/stores/auto'
import { registerAutoService } from './src/player'
import QueryPersistenceConfig from './src/configs/query-persistence.config'
import { ReducedMotionConfig, ReduceMotion } from 'react-native-reanimated'
LogBox.ignoreAllLogs()
@@ -135,6 +136,7 @@ function Container({ playerIsReady }: { playerIsReady: boolean }): React.JSX.Ele
theme={getJellifyNavTheme(colorPreset, resolvedMode)}
>
<GestureHandlerRootView>
<ReducedMotionConfig mode={ReduceMotion.System} />
<TamaguiProvider config={jellifyConfig}>
{playerIsReady && <Jellify />}
</TamaguiProvider>
+6 -9
View File
@@ -188,16 +188,15 @@ Install via [Altstore](https://altstore.io) or your favorite sideloading utility
### Roadmap
#### 1.1.0 (Socket To Me Baby) - March '26
- Android Auto/CarPlay Support
- Websocket Support (Server online status)
- Home Screen Updates
- Discover Screen Updates
- Artist Screen Redesign
- Library Redesign
- Gapless Playback
- WebSocket Support (Server online status)
- Library Enhancements
- Quick Connect Support
- Allow Self-Signed Certificates
#### 1.2.0 (We Made a Language For Us Two...) - June '26
- Android Auto/CarPlay Support
- EQ Controls
- Collaborative Playlists
- App Customization Options
- Desktop Support (Experimental)
@@ -207,12 +206,10 @@ Install via [Altstore](https://altstore.io) or your favorite sideloading utility
- Tablet Support
#### 2.0.0 - December '26
- Gapless Playback
- Seerr (formerly Jellyseerr) Integration
- JellyJam
- EQ Controls
#### 3.0.0 - TBD
#### 3.0.0 - December '27
- Watch Support
- tvOS (Apple and Android)
+2 -2
View File
@@ -88,8 +88,8 @@ android {
applicationId "com.cosmonautical.jellify"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 174
versionName "1.0.15"
versionCode 177
versionName "1.0.18"
}
signingConfigs {
+82 -80
View File
@@ -5,23 +5,23 @@
"": {
"name": "jellify",
"dependencies": {
"@jellify-music/react-native-reanimated-slider": "0.3.4",
"@jellify-music/react-native-reanimated-slider": "0.4.0",
"@jellyfin/sdk": "0.13.0",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/cli": "20.0.0",
"@react-native-community/netinfo": "11.5.1",
"@react-native-community/cli": "20.1.1",
"@react-native-community/netinfo": "11.5.2",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-vector-icons/material-design-icons": "12.4.0",
"@react-navigation/bottom-tabs": "7.12.0",
"@react-navigation/material-top-tabs": "7.4.13",
"@react-navigation/native": "7.1.28",
"@react-navigation/native-stack": "7.12.0",
"@sentry/react-native": "7.8.0",
"@shopify/flash-list": "2.2.1",
"@sentry/react-native": "7.12.0",
"@shopify/flash-list": "2.2.2",
"@tamagui/config": "1.144.3",
"@tanstack/query-async-storage-persister": "5.90.12",
"@tanstack/react-query": "5.90.12",
"@tanstack/react-query-persist-client": "5.90.12",
"@tanstack/query-async-storage-persister": "5.90.20",
"@tanstack/react-query": "5.90.20",
"@tanstack/react-query-persist-client": "5.90.20",
"@testing-library/react-native": "13.3.3",
"@typedigital/telemetrydeck-react": "0.4.1",
"lodash": "^4.17.21",
@@ -30,7 +30,7 @@
"react-freeze": "^1.0.4",
"react-native": "0.83.1",
"react-native-background-actions": "^4.0.1",
"react-native-blob-util": "^0.22.2",
"react-native-blob-util": "0.24.6",
"react-native-blurhash": "^2.1.3",
"react-native-carplay": "^2.4.1-beta.0",
"react-native-device-info": "15.0.1",
@@ -39,24 +39,24 @@
"react-native-google-cast": "^4.9.1",
"react-native-haptic-feedback": "^2.3.3",
"react-native-linear-gradient": "^2.8.3",
"react-native-mmkv": "^4.1.1",
"react-native-mmkv": "4.1.2",
"react-native-nitro-fetch": "0.1.7",
"react-native-nitro-modules": "0.33.2",
"react-native-nitro-ota": "^0.10.0",
"react-native-nitro-modules": "0.33.7",
"react-native-nitro-ota": "^0.11.0",
"react-native-pager-view": "8.0.0",
"react-native-reanimated": "4.1.6",
"react-native-safe-area-context": "5.6.2",
"react-native-screens": "4.21.0",
"react-native-screens": "4.23.0",
"react-native-sortables": "1.9.4",
"react-native-superconfig": "^0.6.0",
"react-native-superconfig": "^0.10.0",
"react-native-text-ticker": "^1.15.0",
"react-native-toast-message": "^2.3.3",
"react-native-track-player": "5.0.0-alpha0",
"react-native-turbo-image": "1.24.1",
"react-native-url-polyfill": "^2.0.0",
"react-native-url-polyfill": "3.0.0",
"react-native-uuid": "^2.0.3",
"react-native-worklets": "^0.7.1",
"react-native-worklets-core": "^1.6.2",
"react-native-worklets": "0.7.2",
"react-native-worklets-core": "1.6.2",
"ruby": "^0.6.1",
"scheduler": "^0.26.0",
"tamagui": "1.144.3",
@@ -68,15 +68,15 @@
"@babel/runtime": "7.28.6",
"@eslint/eslintrc": "3.3.3",
"@eslint/js": "9.39.2",
"@react-native-community/cli-platform-android": "20.0.0",
"@react-native-community/cli-platform-ios": "20.0.0",
"@react-native-community/cli-platform-android": "20.1.1",
"@react-native-community/cli-platform-ios": "20.1.1",
"@react-native/babel-preset": "0.83.1",
"@react-native/eslint-config": "0.83.1",
"@react-native/metro-config": "0.83.1",
"@react-native/typescript-config": "0.83.1",
"@types/jest": "^30.0.0",
"@types/lodash": "^4.17.21",
"@types/node": "25.2.0",
"@types/node": "25.2.2",
"@types/react": "19.2.0",
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "19.1.0",
@@ -85,14 +85,14 @@
"eslint": "9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-prettier": "5.5.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-native": "^5.0.0",
"globals": "17.3.0",
"husky": "^9.1.7",
"jest": "30.2.0",
"jscodeshift": "^17.3.0",
"lint-staged": "^16.1.5",
"lint-staged": "16.2.7",
"patch-package": "8.0.1",
"prettier": "3.8.1",
"react-dom": "19.2.0",
@@ -408,6 +408,10 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="],
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.1", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@isaacs/ttlcache": ["@isaacs/ttlcache@1.4.1", "", {}, "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA=="],
@@ -416,7 +420,7 @@
"@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="],
"@jellify-music/react-native-reanimated-slider": ["@jellify-music/react-native-reanimated-slider@0.3.4", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-B8ncisTQlKMDobwYWnTuvr8kvcx/NHW6yMjW7U+GzXCvKxxI4+8YVtHg1NYGgktWFcjNMBwELdZ7Iw6loUTnUA=="],
"@jellify-music/react-native-reanimated-slider": ["@jellify-music/react-native-reanimated-slider@0.4.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">= 2.30.0", "react-native-reanimated": "~4.1.0", "react-native-worklets": ">= 0.5.1" } }, "sha512-kOWIjezMfW8wHApKAvu7LrkvoLJk1OzmZWjPc0fMYdeSTwxaokQhljGRcu2NbAwZsJ/tIK7vahKTSeSzkNIhfg=="],
"@jellyfin/sdk": ["@jellyfin/sdk@0.13.0", "", { "peerDependencies": { "axios": "^1.12.0" } }, "sha512-oiBAOXH6s+dKdReSsYgNktBDzbxtg4JVWhEzIxZSxKcWMdSKmBtK41MhXRO7IWAC40DguKUm3nU/Z493qPAlWA=="],
@@ -498,31 +502,31 @@
"@react-native-async-storage/async-storage": ["@react-native-async-storage/async-storage@2.2.0", "", { "dependencies": { "merge-options": "^3.0.4" }, "peerDependencies": { "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw=="],
"@react-native-community/cli": ["@react-native-community/cli@20.0.0", "", { "dependencies": { "@react-native-community/cli-clean": "20.0.0", "@react-native-community/cli-config": "20.0.0", "@react-native-community/cli-doctor": "20.0.0", "@react-native-community/cli-server-api": "20.0.0", "@react-native-community/cli-tools": "20.0.0", "@react-native-community/cli-types": "20.0.0", "chalk": "^4.1.2", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-/cMnGl5V1rqnbElY1Fvga1vfw0d3bnqiJLx2+2oh7l9ulnXfVRWb5tU2kgBqiMxuDOKA+DQoifC9q/tvkj5K2w=="],
"@react-native-community/cli": ["@react-native-community/cli@20.1.1", "", { "dependencies": { "@react-native-community/cli-clean": "20.1.1", "@react-native-community/cli-config": "20.1.1", "@react-native-community/cli-doctor": "20.1.1", "@react-native-community/cli-server-api": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "@react-native-community/cli-types": "20.1.1", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-aLPUx43+WSeTOaUepR2FBD5a1V0OAZ1QB2DOlRlW4fOEjtBXgv40eM/ho8g3WCvAOKfPvTvx4fZdcuovTyV81Q=="],
"@react-native-community/cli-clean": ["@react-native-community/cli-clean@20.0.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.0.0", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-glob": "^3.3.2" } }, "sha512-YmdNRcT+Dp8lC7CfxSDIfPMbVPEXVFzBH62VZNbYGxjyakqAvoQUFTYPgM2AyFusAr4wDFbDOsEv88gCDwR3ig=="],
"@react-native-community/cli-clean": ["@react-native-community/cli-clean@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-6nGQ08w2+EcDwTFC4JFiW/wI2pLwzMrk9thz4um7tKRNW8sADX0IyCsfM2F4rHS720C0UNKYBZE9nAsfp8Vkcw=="],
"@react-native-community/cli-config": ["@react-native-community/cli-config@20.0.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.0.0", "chalk": "^4.1.2", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", "joi": "^17.2.1" } }, "sha512-5Ky9ceYuDqG62VIIpbOmkg8Lybj2fUjf/5wK4UO107uRqejBgNgKsbGnIZgEhREcaSEOkujWrroJ9gweueLfBg=="],
"@react-native-community/cli-config": ["@react-native-community/cli-config@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", "joi": "^17.2.1", "picocolors": "^1.1.1" } }, "sha512-ajs2i56MANie/v0bMQ1BmRcrOb6MEvLT2rh/I1CA62NXGqF1Rxv6QwsN84LrADMXHRg8QiEMAIADkyDeQHt7Kg=="],
"@react-native-community/cli-config-android": ["@react-native-community/cli-config-android@20.0.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.0.0", "chalk": "^4.1.2", "fast-glob": "^3.3.2", "fast-xml-parser": "^4.4.1" } }, "sha512-asv60qYCnL1v0QFWcG9r1zckeFlKG+14GGNyPXY72Eea7RX5Cxdx8Pb6fIPKroWH1HEWjYH9KKHksMSnf9FMKw=="],
"@react-native-community/cli-config-android": ["@react-native-community/cli-config-android@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "fast-glob": "^3.3.2", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-1iUV2rPAyoWPo8EceAFC2vZTF+pEd9YqS87c0aqpbGOFE0gs1rHEB+auVR8CdjzftR4U9sq6m2jrdst0rvpIkg=="],
"@react-native-community/cli-config-apple": ["@react-native-community/cli-config-apple@20.0.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.0.0", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-glob": "^3.3.2" } }, "sha512-PS1gNOdpeQ6w7dVu1zi++E+ix2D0ZkGC2SQP6Y/Qp002wG4se56esLXItYiiLrJkhH21P28fXdmYvTEkjSm9/Q=="],
"@react-native-community/cli-config-apple": ["@react-native-community/cli-config-apple@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-doepJgLJVqeJb5tNoP9hyFIcoZ1OMGO7QN/YMuCCIjbThUQe/J87XdwPol3Qrjr58KRt9xeBVz+kHeW5mtSutw=="],
"@react-native-community/cli-doctor": ["@react-native-community/cli-doctor@20.0.0", "", { "dependencies": { "@react-native-community/cli-config": "20.0.0", "@react-native-community/cli-platform-android": "20.0.0", "@react-native-community/cli-platform-apple": "20.0.0", "@react-native-community/cli-platform-ios": "20.0.0", "@react-native-community/cli-tools": "20.0.0", "chalk": "^4.1.2", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.13.0", "execa": "^5.0.0", "node-stream-zip": "^1.9.1", "ora": "^5.4.1", "semver": "^7.5.2", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "sha512-cPHspi59+Fy41FDVxt62ZWoicCZ1o34k8LAl64NVSY0lwPl+CEi78jipXJhtfkVqSTetloA8zexa/vSAcJy57Q=="],
"@react-native-community/cli-doctor": ["@react-native-community/cli-doctor@20.1.1", "", { "dependencies": { "@react-native-community/cli-config": "20.1.1", "@react-native-community/cli-platform-android": "20.1.1", "@react-native-community/cli-platform-apple": "20.1.1", "@react-native-community/cli-platform-ios": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.13.0", "execa": "^5.0.0", "node-stream-zip": "^1.9.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "semver": "^7.5.2", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "sha512-eFpg5wWnV7uGqvLemshpgj2trPD8cckqxBuI4nT7sxKF/YpA/e3nnnyytHxPP5EnYfWbMcqfaq8hDJoOnJinGQ=="],
"@react-native-community/cli-platform-android": ["@react-native-community/cli-platform-android@20.0.0", "", { "dependencies": { "@react-native-community/cli-config-android": "20.0.0", "@react-native-community/cli-tools": "20.0.0", "chalk": "^4.1.2", "execa": "^5.0.0", "logkitty": "^0.7.1" } }, "sha512-th3ji1GRcV6ACelgC0wJtt9daDZ+63/52KTwL39xXGoqczFjml4qERK90/ppcXU0Ilgq55ANF8Pr+UotQ2AB/A=="],
"@react-native-community/cli-platform-android": ["@react-native-community/cli-platform-android@20.1.1", "", { "dependencies": { "@react-native-community/cli-config-android": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "logkitty": "^0.7.1", "picocolors": "^1.1.1" } }, "sha512-KPheizJQI0tVvBLy9owzpo+A9qDsDAa87e7a8xNaHnwqGpExnIzFPrbdvrltiZjstU2eB/+/UgNQxYIEd4Oc+g=="],
"@react-native-community/cli-platform-apple": ["@react-native-community/cli-platform-apple@20.0.0", "", { "dependencies": { "@react-native-community/cli-config-apple": "20.0.0", "@react-native-community/cli-tools": "20.0.0", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-xml-parser": "^4.4.1" } }, "sha512-rZZCnAjUHN1XBgiWTAMwEKpbVTO4IHBSecdd1VxJFeTZ7WjmstqA6L/HXcnueBgxrzTCRqvkRIyEQXxC1OfhGw=="],
"@react-native-community/cli-platform-apple": ["@react-native-community/cli-platform-apple@20.1.1", "", { "dependencies": { "@react-native-community/cli-config-apple": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-mQEjOzRFCcQTrCt73Q/+5WWTfUg6U2vLZv5rPuFiNrLbrwRqxVH3OLaXg5gilJkDTJC80z8iOSsdd8MRxONOig=="],
"@react-native-community/cli-platform-ios": ["@react-native-community/cli-platform-ios@20.0.0", "", { "dependencies": { "@react-native-community/cli-platform-apple": "20.0.0" } }, "sha512-Z35M+4gUJgtS4WqgpKU9/XYur70nmj3Q65c9USyTq6v/7YJ4VmBkmhC9BticPs6wuQ9Jcv0NyVCY0Wmh6kMMYw=="],
"@react-native-community/cli-platform-ios": ["@react-native-community/cli-platform-ios@20.1.1", "", { "dependencies": { "@react-native-community/cli-platform-apple": "20.1.1" } }, "sha512-6vr10/oSjKkZO/BBgfFJNQTC/0CDF4WrN8iW9ss+Kt6ZL2QrBXLYz7fobrrboOlHwqqs5EyQadlEaNii7gKRJg=="],
"@react-native-community/cli-server-api": ["@react-native-community/cli-server-api@20.0.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.0.0", "body-parser": "^1.20.3", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", "nocache": "^3.0.1", "open": "^6.2.0", "pretty-format": "^29.7.0", "serve-static": "^1.13.1", "ws": "^6.2.3" } }, "sha512-Ves21bXtjUK3tQbtqw/NdzpMW1vR2HvYCkUQ/MXKrJcPjgJnXQpSnTqHXz6ZdBlMbbwLJXOhSPiYzxb5/v4CDg=="],
"@react-native-community/cli-server-api": ["@react-native-community/cli-server-api@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "body-parser": "^1.20.3", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", "nocache": "^3.0.1", "open": "^6.2.0", "pretty-format": "^29.7.0", "serve-static": "^1.13.1", "strict-url-sanitise": "0.0.1", "ws": "^6.2.3" } }, "sha512-phHfiCa4WqfKfaoV2vGVR3ZrYQDQTpI1k+C+i6rXAxFGxPuy8IgFFVOSL543qjKPpHBVwLcA+/xAJCVpdyCtVQ=="],
"@react-native-community/cli-tools": ["@react-native-community/cli-tools@20.0.0", "", { "dependencies": { "@vscode/sudo-prompt": "^9.0.0", "appdirsjs": "^1.2.4", "chalk": "^4.1.2", "execa": "^5.0.0", "find-up": "^5.0.0", "launch-editor": "^2.9.1", "mime": "^2.4.1", "ora": "^5.4.1", "prompts": "^2.4.2", "semver": "^7.5.2" } }, "sha512-akSZGxr1IajJ8n0YCwQoA3DI0HttJ0WB7M3nVpb0lOM+rJpsBN7WG5Ft+8ozb6HyIPX+O+lLeYazxn5VNG/Xhw=="],
"@react-native-community/cli-tools": ["@react-native-community/cli-tools@20.1.1", "", { "dependencies": { "@vscode/sudo-prompt": "^9.0.0", "appdirsjs": "^1.2.4", "execa": "^5.0.0", "find-up": "^5.0.0", "launch-editor": "^2.9.1", "mime": "^2.4.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" } }, "sha512-j+zX/H2X+6ZGneIDj56tZ1Hbnip5nSfnq7yGlMyF/zm3U1hKp3G1jN5v0YEfnz/zEmjr7zruh4Y06KmZrF1lrA=="],
"@react-native-community/cli-types": ["@react-native-community/cli-types@20.0.0", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-7J4hzGWOPTBV1d30Pf2NidV+bfCWpjfCOiGO3HUhz1fH4MvBM0FbbBmE9LE5NnMz7M8XSRSi68ZGYQXgLBB2Qw=="],
"@react-native-community/cli-types": ["@react-native-community/cli-types@20.1.1", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-Tp+s27I/RDONrGvWVj4IzEmga2HhJhXi8ZlZTfycMMyAcv4LG/CTPira+BUZs8nzLAJNrlJ79pVVPJPqQAe+aw=="],
"@react-native-community/netinfo": ["@react-native-community/netinfo@11.5.1", "", { "peerDependencies": { "react": "*", "react-native": ">=0.59" } }, "sha512-ZiLvVoNW6fIOZqU99Jdm+dfwv2T76AHifKtEDydPQOkzKg/xLIkSrOEMUigkmlNQu4MeCgFJlrapscbRzU+bKg=="],
"@react-native-community/netinfo": ["@react-native-community/netinfo@11.5.2", "", { "peerDependencies": { "react": "*", "react-native": ">=0.59" } }, "sha512-/g0m65BtX9HU+bPiCH2517bOHpEIUsGrWFXDzi1a5nNKn5KujQgm04WhL7/OSXWKHyrT8VVtUoJA0XKRxueBpQ=="],
"@react-native-masked-view/masked-view": ["@react-native-masked-view/masked-view@0.3.2", "", { "peerDependencies": { "react": ">=16", "react-native": ">=0.57" } }, "sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ=="],
@@ -582,17 +586,17 @@
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
"@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.30.0", "", { "dependencies": { "@sentry/core": "10.30.0" } }, "sha512-dVsHTUbvgaLNetWAQC6yJFnmgD0xUbVgCkmzNB7S28wIP570GcZ4cxFGPOkXbPx6dEBUfoOREeXzLqjJLtJPfg=="],
"@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.38.0", "", { "dependencies": { "@sentry/core": "10.38.0" } }, "sha512-UOJtYmdcxHCcV0NPfXFff/a95iXl/E0EhuQ1y0uE0BuZDMupWSF5t2BgC4HaE5Aw3RTjDF3XkSHWoIF6ohy7eA=="],
"@sentry-internal/feedback": ["@sentry-internal/feedback@10.30.0", "", { "dependencies": { "@sentry/core": "10.30.0" } }, "sha512-+bnQZ6SNF265nTXrRlXTmq5Ila1fRfraDOAahlOT/VM4j6zqCvNZzmeDD9J6IbxiAdhlp/YOkrG3zbr5vgYo0A=="],
"@sentry-internal/feedback": ["@sentry-internal/feedback@10.38.0", "", { "dependencies": { "@sentry/core": "10.38.0" } }, "sha512-JXneg9zRftyfy1Fyfc39bBlF/Qd8g4UDublFFkVvdc1S6JQPlK+P6q22DKz3Pc8w3ySby+xlIq/eTu9Pzqi4KA=="],
"@sentry-internal/replay": ["@sentry-internal/replay@10.30.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.30.0", "@sentry/core": "10.30.0" } }, "sha512-Pj/fMIZQkXzIw6YWpxKWUE5+GXffKq6CgXwHszVB39al1wYz1gTIrTqJqt31IBLIihfCy8XxYddglR2EW0BVIQ=="],
"@sentry-internal/replay": ["@sentry-internal/replay@10.38.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.38.0", "@sentry/core": "10.38.0" } }, "sha512-YWIkL6/dnaiQyFiZXJ/nN+NXGv/15z45ia86bE/TMq01CubX/DUOilgsFz0pk2v/pg3tp/U2MskLO9Hz0cnqeg=="],
"@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.30.0", "", { "dependencies": { "@sentry-internal/replay": "10.30.0", "@sentry/core": "10.30.0" } }, "sha512-RIlIz+XQ4DUWaN60CjfmicJq2O2JRtDKM5lw0wB++M5ha0TBh6rv+Ojf6BDgiV3LOQ7lZvCM57xhmNUtrGmelg=="],
"@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.38.0", "", { "dependencies": { "@sentry-internal/replay": "10.38.0", "@sentry/core": "10.38.0" } }, "sha512-OXWM9jEqNYh4VTvrMu7v+z1anz+QKQ/fZXIZdsO7JTT2lGNZe58UUMeoq386M+Saxen8F9SUH7yTORy/8KI5qw=="],
"@sentry/babel-plugin-component-annotate": ["@sentry/babel-plugin-component-annotate@4.6.1", "", {}, "sha512-aSIk0vgBqv7PhX6/Eov+vlI4puCE0bRXzUG5HdCsHBpAfeMkI8Hva6kSOusnzKqs8bf04hU7s3Sf0XxGTj/1AA=="],
"@sentry/babel-plugin-component-annotate": ["@sentry/babel-plugin-component-annotate@4.9.0", "", {}, "sha512-TJ7sVoa2Bf36lpJjBAzpNDC5Hg+evjsQnqUPeDx9Nz/YFw0u9rK1cwvi95gVWpx7PJSDCkljIv3aw0m4RatHpQ=="],
"@sentry/browser": ["@sentry/browser@10.30.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.30.0", "@sentry-internal/feedback": "10.30.0", "@sentry-internal/replay": "10.30.0", "@sentry-internal/replay-canvas": "10.30.0", "@sentry/core": "10.30.0" } }, "sha512-7M/IJUMLo0iCMLNxDV/OHTPI0WKyluxhCcxXJn7nrCcolu8A1aq9R8XjKxm0oTCO8ht5pz8bhGXUnYJj4eoEBA=="],
"@sentry/browser": ["@sentry/browser@10.38.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.38.0", "@sentry-internal/feedback": "10.38.0", "@sentry-internal/replay": "10.38.0", "@sentry-internal/replay-canvas": "10.38.0", "@sentry/core": "10.38.0" } }, "sha512-3phzp1YX4wcQr9mocGWKbjv0jwtuoDBv7+Y6Yfrys/kwyaL84mDLjjQhRf4gL5SX7JdYkhBp4WaiNlR0UC4kTA=="],
"@sentry/cli": ["@sentry/cli@2.58.4", "", { "dependencies": { "https-proxy-agent": "^5.0.0", "node-fetch": "^2.6.7", "progress": "^2.0.3", "proxy-from-env": "^1.1.0", "which": "^2.0.2" }, "optionalDependencies": { "@sentry/cli-darwin": "2.58.4", "@sentry/cli-linux-arm": "2.58.4", "@sentry/cli-linux-arm64": "2.58.4", "@sentry/cli-linux-i686": "2.58.4", "@sentry/cli-linux-x64": "2.58.4", "@sentry/cli-win32-arm64": "2.58.4", "@sentry/cli-win32-i686": "2.58.4", "@sentry/cli-win32-x64": "2.58.4" }, "bin": { "sentry-cli": "bin/sentry-cli" } }, "sha512-ArDrpuS8JtDYEvwGleVE+FgR+qHaOp77IgdGSacz6SZy6Lv90uX0Nu4UrHCQJz8/xwIcNxSqnN22lq0dH4IqTg=="],
@@ -612,15 +616,15 @@
"@sentry/cli-win32-x64": ["@sentry/cli-win32-x64@2.58.4", "", { "os": "win32", "cpu": "x64" }, "sha512-cSzN4PjM1RsCZ4pxMjI0VI7yNCkxiJ5jmWncyiwHXGiXrV1eXYdQ3n1LhUYLZ91CafyprR0OhDcE+RVZ26Qb5w=="],
"@sentry/core": ["@sentry/core@10.30.0", "", {}, "sha512-IfNuqIoGVO9pwphwbOptAEJJI1SCAfewS5LBU1iL7hjPBHYAnE8tCVzyZN+pooEkQQ47Q4rGanaG1xY8mjTT1A=="],
"@sentry/core": ["@sentry/core@10.38.0", "", {}, "sha512-1pubWDZE5y5HZEPMAZERP4fVl2NH3Ihp1A+vMoVkb3Qc66Diqj1WierAnStlZP7tCx0TBa0dK85GTW/ZFYyB9g=="],
"@sentry/react": ["@sentry/react@10.30.0", "", { "dependencies": { "@sentry/browser": "10.30.0", "@sentry/core": "10.30.0", "hoist-non-react-statics": "^3.3.2" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-3co0QwAU9VrCVBWgpRf/4G19MwzR+DM0sDe9tgN7P3pv/tMlEHhnPFv88nPfuSa2W8uVCpHehvV+GnUPF4V7Ag=="],
"@sentry/react": ["@sentry/react@10.38.0", "", { "dependencies": { "@sentry/browser": "10.38.0", "@sentry/core": "10.38.0" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-3UiKo6QsqTyPGUt0XWRY9KLaxc/cs6Kz4vlldBSOXEL6qPDL/EfpwNJT61osRo81VFWu8pKu7ZY2bvLPryrnBQ=="],
"@sentry/react-native": ["@sentry/react-native@7.8.0", "", { "dependencies": { "@sentry/babel-plugin-component-annotate": "4.6.1", "@sentry/browser": "10.30.0", "@sentry/cli": "2.58.4", "@sentry/core": "10.30.0", "@sentry/react": "10.30.0", "@sentry/types": "10.30.0" }, "peerDependencies": { "expo": ">=49.0.0", "react": ">=17.0.0", "react-native": ">=0.65.0" }, "optionalPeers": ["expo"], "bin": { "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js" } }, "sha512-0YMD0ObuGPbJVfHCBaYTfmRtS7tUd64W2GMNPA3b6rGlVlMQlL7bfdkCUouVBZzFDJYLV8ik1PzJKPWunKHCvw=="],
"@sentry/react-native": ["@sentry/react-native@7.12.0", "", { "dependencies": { "@sentry/babel-plugin-component-annotate": "4.9.0", "@sentry/browser": "10.38.0", "@sentry/cli": "2.58.4", "@sentry/core": "10.38.0", "@sentry/react": "10.38.0", "@sentry/types": "10.38.0" }, "peerDependencies": { "expo": ">=49.0.0", "react": ">=17.0.0", "react-native": ">=0.65.0" }, "optionalPeers": ["expo"], "bin": { "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js" } }, "sha512-40vrz+wkwCbKJ1fvJYBvPZWzoBvP3zKyNyfoS2LTcrY0yuoeTBHWfLswqwWV545vflwEeE7l4YKpLnekEmjCdQ=="],
"@sentry/types": ["@sentry/types@10.30.0", "", { "dependencies": { "@sentry/core": "10.30.0" } }, "sha512-tSyzG/JunWjbuQDDwP3DKgt8KP23ZSuNUEudMSv2jCF/956o8ksamPeidCTSVMXoEyTt5tvimWNeNvUFIFq3EA=="],
"@sentry/types": ["@sentry/types@10.38.0", "", { "dependencies": { "@sentry/core": "10.38.0" } }, "sha512-DoeyTv/TvnoVDhHgdyv/wehieAKdyjLjEMtPOqqq/AjkP02BxeC0JYUrrWKOjV0wdLq5ZP8jKcCX8GN7awZonQ=="],
"@shopify/flash-list": ["@shopify/flash-list@2.2.1", "", { "peerDependencies": { "@babel/runtime": "*", "react": "*", "react-native": "*" } }, "sha512-Squs4SneVNXl8WoCZ7zuer7SidbjXiSSafF5nAYQTtSzylchl5sc/P1swYY7snxWBZwYH9aHy7kCvuMPjiCh1g=="],
"@shopify/flash-list": ["@shopify/flash-list@2.2.2", "", { "peerDependencies": { "@babel/runtime": "*", "react": "*", "react-native": "*" } }, "sha512-YrvLBK5FCpvuX+d9QvJvjVqyi4eBUaEamkyfh9CjPdF6c+AukP0RSBh97qHyTwOEaVq21A5ukwgyWMDIbmxpmQ=="],
"@sideway/address": ["@sideway/address@4.1.5", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q=="],
@@ -830,15 +834,15 @@
"@tamagui/z-index-stack": ["@tamagui/z-index-stack@1.144.3", "", { "peerDependencies": { "react": "*" } }, "sha512-183HkUdi5uo0KjuMx8HcwRiueBZHeamVUkPlLaHBaBJ1M2mWgDVkra3wocMKZr+fnFTPhOOqvQNb3eNDnefeUA=="],
"@tanstack/query-async-storage-persister": ["@tanstack/query-async-storage-persister@5.90.12", "", { "dependencies": { "@tanstack/query-core": "5.90.10", "@tanstack/query-persist-client-core": "5.91.9" } }, "sha512-bLOs6ZLTki88if8oDQDdnxk7wgMaKMAVTRxn+WiSI0An7rj3C/7/yDTjOLhwPaoipbTiFLF0/PnbpVXIbWqQYQ=="],
"@tanstack/query-async-storage-persister": ["@tanstack/query-async-storage-persister@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.18", "@tanstack/query-persist-client-core": "5.91.17" } }, "sha512-SO7U/v/NcWTL4aJTyZmdW6i6KmN/YlEmNTEiU4Fm8cwGDTBQ2ABpaHY6A+Q/gsDdG7AeDwFvVs/wf9C4A0UKAw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.10", "", {}, "sha512-EhZVFu9rl7GfRNuJLJ3Y7wtbTnENsvzp+YpcAV7kCYiXni1v8qZh++lpw4ch4rrwC0u/EZRnBHIehzCGzwXDSQ=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.18", "", {}, "sha512-rbGx6bHgPNVzutP7BEr+53UPKohpckqlMAad+To9UxTbeaQ+kC/1SDRj+QzkwbQ7qhLT/1IKp34yS6thda6fzA=="],
"@tanstack/query-persist-client-core": ["@tanstack/query-persist-client-core@5.91.9", "", { "dependencies": { "@tanstack/query-core": "5.90.10" } }, "sha512-LliMZl/pkO/6vRf5//fO8nl4UCfM1LQsnT+N0aRYkK7bqoM3QdqHxD65EApmJRypKkqaWmiyulPG3Mi1NYuyIA=="],
"@tanstack/query-persist-client-core": ["@tanstack/query-persist-client-core@5.91.17", "", { "dependencies": { "@tanstack/query-core": "5.90.18" } }, "sha512-NfXUCUzar8Y3fw0F6lPlrnJWfg148IXvWBIr/0x2LbYAoGdAnJDU3PzVaLTafI2vKQqIphe3uAq1068+nhn+nQ=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.12", "", { "dependencies": { "@tanstack/query-core": "5.90.12" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="],
"@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.12", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.91.9" }, "peerDependencies": { "@tanstack/react-query": "^5.90.10", "react": "^18 || ^19" } }, "sha512-o51hwImpKgb85FnFljtCXcUzuLXpKONF9N6bhKfifPL3SNSj8neh1a2aHQd7sN9mbeIeNfGMGJuDpSt/Fc3GwQ=="],
"@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.20", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.91.17" }, "peerDependencies": { "@tanstack/react-query": "^5.90.18", "react": "^18 || ^19" } }, "sha512-FiKLxu7haxUAVV9hUEaUkUF6CsOM8i0ERtE6ETLQ0t5aKefY9tRByP9J1C4qf1r1m2TSWV0WwkmOsmsy1pZqsQ=="],
"@telemetrydeck/sdk": ["@telemetrydeck/sdk@2.0.4", "", {}, "sha512-x4S83AqSo6wvLJ6nRYdyJEqd9qmblUdBgsTRrjH5z++b9pnf2NMc8NpVAa48KIB1pRuP/GTGzXxVYdNoie/DVg=="],
@@ -876,7 +880,7 @@
"@types/lodash": ["@types/lodash@4.17.23", "", {}, "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA=="],
"@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
"@types/node": ["@types/node@25.2.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ=="],
"@types/react": ["@types/react@19.2.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA=="],
@@ -978,7 +982,7 @@
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
@@ -1266,7 +1270,7 @@
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
@@ -1890,7 +1894,7 @@
"react-native-background-actions": ["react-native-background-actions@4.0.1", "", { "dependencies": { "eventemitter3": "^4.0.7" }, "peerDependencies": { "react-native": ">=0.47.0" } }, "sha512-LADhnb4ag1oH5Lotq0j8K9e2cFmrafFyg2PCME88VkTjqDUgNcJonkNdMCTHN0N3fh+hwAA7nDR4Cxkj9Q8eCw=="],
"react-native-blob-util": ["react-native-blob-util@0.22.2", "", { "dependencies": { "base-64": "0.1.0", "glob": "^10.3.10" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Czx01QMg7aLsm/4F/7+eqoRAi1q/qjLY2Kao16g+n2SRnTH1+qkD8Qhx2q9okB+VNQvZKB1LbiXhktzYQV52xQ=="],
"react-native-blob-util": ["react-native-blob-util@0.24.6", "", { "dependencies": { "base-64": "0.1.0", "glob": "13.0.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-e4231VTT0bO1Yh9KGtY6jXq7pkLXgDTz+ziClEEbiGwWBuG92Z+wFRLptlzdVbibW8XtrO1XrdtiE8AOlplEXQ=="],
"react-native-blurhash": ["react-native-blurhash@2.1.3", "", { "peerDependencies": { "react": ">=16.8.1", "react-native": ">=0.60.0-rc.0 <1.0.x" } }, "sha512-tYyFketZkrrEIABJ5Z1Pt6N2dqPqtSp7w4Lce17sx1bvspdppgVu/ehtZoPRZu02fYyX92RkyCeGXtCbtUNntg=="],
@@ -1912,13 +1916,13 @@
"react-native-linear-gradient": ["react-native-linear-gradient@2.8.3", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-KflAXZcEg54PXkLyflaSZQ3PJp4uC4whM7nT/Uot9m0e/qxFV3p6uor1983D1YOBJbJN7rrWdqIjq0T42jOJyA=="],
"react-native-mmkv": ["react-native-mmkv@4.1.1", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-nYFjM27l7zVhIiyAqWEFRagGASecb13JMIlzAuOeakRRz9GMJ49hCQntUBE2aeuZRE4u4ehSqTOomB0mTF56Ew=="],
"react-native-mmkv": ["react-native-mmkv@4.1.2", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-6LHb2DQBXuo96Aues13EugmlWw/HAYuh3KoJoQNrC4JsBwn3J3KiRYAg2mCm5Je0VYq2YsmbgZG7XJwX/WFYZA=="],
"react-native-nitro-fetch": ["react-native-nitro-fetch@0.1.7", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "^0.29.2", "react-native-worklets-core": "^1.6.0" }, "optionalPeers": ["react-native-worklets-core"] }, "sha512-4kdDgo8CBaR3SaxIcvQxUVDIUFDr4UierJHigV9jr59jj9wnf7O1gUGcbmBJmE1M2EkVSe4nUU8Fq5tbSMg4kw=="],
"react-native-nitro-modules": ["react-native-nitro-modules@0.33.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ZlfOe6abODeHv/eZf8PxeSkrxIUhEKha6jaAAA9oXy7I6VPr7Ff4dUsAq3cyF3kX0L6qt2Dh9nzD2NdSsDwGpA=="],
"react-native-nitro-modules": ["react-native-nitro-modules@0.33.7", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-WepMobWe4j1Ae5GQ5RxYGBdBpJBwzP6zaOxJ7r6nhbY5iyl01DL3Gsh4gk8edzNFRuAh1rvXDAHIipq8SahxeQ=="],
"react-native-nitro-ota": ["react-native-nitro-ota@0.10.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "0.32.0" } }, "sha512-pxmdaeNdUdnYdD1M8BpbtQo4mZrtljWFg0gspuIohTJqi97JYIRq0b+SReN0sMMo0w912k4XXSGMr/IduGoMNg=="],
"react-native-nitro-ota": ["react-native-nitro-ota@0.11.0", "", { "dependencies": { "react-native-nitro-ota": "^0.10.1" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "0.32.0" } }, "sha512-jM4Z2Ntysk8dNJvUod/2NLpo1e2HrEaxb0X6EqZnSXr9Vg6nAGqnInSQUCFSvgqo4M83qWCF6nt7OxSdh1MCnA=="],
"react-native-pager-view": ["react-native-pager-view@8.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-oAwlWT1lhTkIs9HhODnjNNl/owxzn9DP1MbP+az6OTUdgbmzA16Up83sBH8NRKwrH8rNm7iuWnX1qMqiiWOLhg=="],
@@ -1926,11 +1930,11 @@
"react-native-safe-area-context": ["react-native-safe-area-context@5.6.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg=="],
"react-native-screens": ["react-native-screens@4.21.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-vUgbfKntx4LZ/1k+UU/miKohK0Ih6xoF3gYJr9QqZNOpPARksPxt4hq3HdCirvCLClieLYC9oLpGdizz/S+BGg=="],
"react-native-screens": ["react-native-screens@4.23.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-XhO3aK0UeLpBn4kLecd+J+EDeRRJlI/Ro9Fze06vo1q163VeYtzfU9QS09/VyDFMWR1qxDC1iazCArTPSFFiPw=="],
"react-native-sortables": ["react-native-sortables@1.9.4", "", { "optionalDependencies": { "react-native-haptic-feedback": ">=2.0.0" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-a6hxT+gl14HA5Sm8UiLXJqF8KMEQVa+mUJd75OnzoVsmrxUDtjAatlMdV0kI9qTQDT/ZSFLPRmdUhOR762IA4g=="],
"react-native-superconfig": ["react-native-superconfig@0.6.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-kW9SjpKmuB7F54JNzaWHX5Ncr7jM8852FcIcBvfIyR0ofACaGvqf59hLKg7i8DfSIi0f5DXOCXbvu88cr8PIVw=="],
"react-native-superconfig": ["react-native-superconfig@0.10.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-1AvFP03JqiXtd5EICBQXjVCegBCY+6v8ZNjwMCfOtgX0PFUdNDtWG/TbN+vZlAwbglaXDd3rfoEYomrx3AHEmQ=="],
"react-native-tab-view": ["react-native-tab-view@4.2.2", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-NXtrG6OchvbGjsvbySJGVocXxo4Y2vA17ph4rAaWtA2jh+AasD8OyikKBRg2SmllEfeQ+GEhcKe8kulHv8BhTg=="],
@@ -1942,7 +1946,7 @@
"react-native-turbo-image": ["react-native-turbo-image@1.24.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Sj41a3WfZJvg/J+oC9KEjjLr/EFlLQqCDzyw2HU+mrA342drIfpS9oe7AZr+arzGS8hJpaey2xL2OmEGypZ54g=="],
"react-native-url-polyfill": ["react-native-url-polyfill@2.0.0", "", { "dependencies": { "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "react-native": "*" } }, "sha512-My330Do7/DvKnEvwQc0WdcBnFPploYKp9CYlefDXzIdEaA+PAhDYllkvGeEroEzvc4Kzzj2O4yVdz8v6fjRvhA=="],
"react-native-url-polyfill": ["react-native-url-polyfill@3.0.0", "", { "dependencies": { "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "react-native": "*" } }, "sha512-aA5CiuUCUb/lbrliVCJ6lZ17/RpNJzvTO/C7gC/YmDQhTUoRD5q5HlJfwLWcxz4VgAhHwXKzhxH+wUN24tAdqg=="],
"react-native-uuid": ["react-native-uuid@2.0.3", "", {}, "sha512-f/YfIS2f5UB+gut7t/9BKGSCYbRA9/74A5R1MDp+FLYsuS+OSWoiM/D8Jko6OJB6Jcu3v6ONuddvZKHdIGpeiw=="],
@@ -2078,6 +2082,8 @@
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
"strict-url-sanitise": ["strict-url-sanitise@0.0.1", "", {}, "sha512-nuFtF539K8jZg3FjaWH/L8eocCR6gegz5RDOsaWxfdbF5Jqr2VXWxZayjTwUzsWJDC91k2EbnJXp6FuWW+Z4hg=="],
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-hash-64": ["string-hash-64@1.0.3", "", {}, "sha512-D5OKWKvDhyVWWn2x5Y9b+37NUllks34q1dCDhk/vYcso9fmhs+Tl3KR/gE4v5UNj2UA35cnX4KdVVGkG1deKqw=="],
@@ -2624,7 +2630,7 @@
"@react-native/metro-babel-transformer/@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
"@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.12", "", {}, "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg=="],
"@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"@types/babel__core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
@@ -2678,6 +2684,8 @@
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"chrome-launcher/@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"chromium-edge-launcher/@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
@@ -2696,8 +2704,6 @@
"error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
"eslint/@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
@@ -2926,8 +2932,6 @@
"pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
@@ -2948,7 +2952,7 @@
"react-native/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
"react-native-blob-util/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"react-native-blob-util/glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="],
"react-native-reanimated/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
@@ -2998,6 +3002,8 @@
"wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"write-file-atomic/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="],
@@ -3216,8 +3222,6 @@
"@react-native-community/cli-server-api/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
"@react-native-community/cli-server-api/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"@react-native-community/cli-server-api/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"@react-native-community/cli/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
@@ -3406,6 +3410,8 @@
"cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
@@ -3438,8 +3444,6 @@
"eslint-plugin-react-hooks/hermes-parser/hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"eslint/@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"find-cache-dir/pkg-dir/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
@@ -3544,8 +3548,6 @@
"jest-validate/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
"jest-validate/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"jest-validate/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"jest-worker/jest-util/@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="],
@@ -3680,10 +3682,12 @@
"pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="],
"react-native-blob-util/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"react-native-blob-util/glob/minimatch": ["minimatch@10.1.2", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.1" } }, "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw=="],
"react-native-blob-util/glob/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"react-native-blob-util/glob/path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="],
"react-native-worklets/@babel/plugin-transform-class-properties/@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.5", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.5", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ=="],
"react-native-worklets/@babel/plugin-transform-class-properties/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="],
@@ -3706,8 +3710,6 @@
"react-native/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
"react-native/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"react-native/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
@@ -3798,8 +3800,6 @@
"@jest/fake-timers/jest-message-util/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
"@jest/fake-timers/jest-message-util/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"@jest/fake-timers/jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"@jest/globals/@jest/environment/@jest/fake-timers/@sinonjs/fake-timers": ["@sinonjs/fake-timers@13.0.5", "", { "dependencies": { "@sinonjs/commons": "^3.0.1" } }, "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw=="],
@@ -4014,7 +4014,7 @@
"pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
"react-native-blob-util/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"react-native-blob-util/glob/path-scurry/lru-cache": ["lru-cache@11.2.5", "", {}, "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw=="],
"react-native-worklets/@babel/plugin-transform-class-properties/@babel/helper-create-class-features-plugin/@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.27.1", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.27.1", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA=="],
@@ -4146,6 +4146,8 @@
"jscodeshift/@babel/plugin-transform-private-methods/@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"logkitty/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
+6 -6
View File
@@ -543,7 +543,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 282;
CURRENT_PROJECT_VERSION = 285;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_BITCODE = NO;
@@ -554,7 +554,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.15;
MARKETING_VERSION = 1.0.18;
NEW_SETTING = "";
OTHER_LDFLAGS = (
"$(inherited)",
@@ -585,7 +585,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 282;
CURRENT_PROJECT_VERSION = 285;
DEVELOPMENT_TEAM = WAH9CZ8BPG;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -595,7 +595,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.15;
MARKETING_VERSION = 1.0.18;
NEW_SETTING = "";
OTHER_LDFLAGS = (
"$(inherited)",
@@ -823,7 +823,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 282;
CURRENT_PROJECT_VERSION = 285;
DEVELOPMENT_TEAM = WAH9CZ8BPG;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -834,7 +834,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.15;
MARKETING_VERSION = 1.0.18;
NEW_SETTING = "";
OTHER_LDFLAGS = (
"$(inherited)",
+24 -24
View File
@@ -46,7 +46,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- NitroMmkv (4.1.1):
- NitroMmkv (4.1.2):
- boost
- DoubleConversion
- fast_float
@@ -77,7 +77,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- NitroModules (0.33.2):
- NitroModules (0.33.7):
- boost
- DoubleConversion
- fast_float
@@ -106,7 +106,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- NitroOta (0.10.0):
- NitroOta (0.11.0):
- boost
- DoubleConversion
- fast_float
@@ -114,7 +114,7 @@ PODS:
- glog
- hermes-engine
- NitroModules
- NitroOtaBundleManager (= 0.10.0)
- NitroOtaBundleManager (= 0.11.0)
- RCT-Folly
- RCT-Folly/Fabric
- RCTRequired
@@ -138,8 +138,8 @@ PODS:
- SocketRocket
- SSZipArchive
- Yoga
- NitroOtaBundleManager (0.10.0)
- NitroSuperconfig (0.6.0):
- NitroOtaBundleManager (0.11.0)
- NitroSuperconfig (0.10.0):
- boost
- DoubleConversion
- fast_float
@@ -2056,7 +2056,7 @@ PODS:
- SocketRocket
- react-native-background-actions (4.0.1):
- React-Core
- react-native-blob-util (0.22.2):
- react-native-blob-util (0.24.6):
- boost
- DoubleConversion
- fast_float
@@ -2118,7 +2118,7 @@ PODS:
- google-cast-sdk
- PromisesObjC
- React
- react-native-netinfo (11.5.1):
- react-native-netinfo (11.5.2):
- boost
- DoubleConversion
- fast_float
@@ -3116,7 +3116,7 @@ PODS:
- RNWorklets
- SocketRocket
- Yoga
- RNScreens (4.21.0):
- RNScreens (4.23.0):
- boost
- DoubleConversion
- fast_float
@@ -3143,10 +3143,10 @@ PODS:
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- RNScreens/common (= 4.21.0)
- RNScreens/common (= 4.23.0)
- SocketRocket
- Yoga
- RNScreens/common (4.21.0):
- RNScreens/common (4.23.0):
- boost
- DoubleConversion
- fast_float
@@ -3175,7 +3175,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- RNSentry (7.8.0):
- RNSentry (7.12.0):
- boost
- DoubleConversion
- fast_float
@@ -3202,7 +3202,7 @@ PODS:
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Sentry/HybridSDK (= 8.57.3)
- Sentry/HybridSDK (= 8.58.0)
- SocketRocket
- Yoga
- RNWorklets (0.7.2):
@@ -3294,7 +3294,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- Sentry/HybridSDK (8.57.3)
- Sentry/HybridSDK (8.58.0)
- SocketRocket (0.7.1)
- SSZipArchive (2.4.3)
- SwiftAudioEx (1.1.0)
@@ -3653,11 +3653,11 @@ SPEC CHECKSUMS:
hermes-engine: 83ac7cadb2a3a158ae6d9e4192417c5232065e99
MMKVCore: f2dd4c9befea04277a55e84e7812f930537993df
NitroFetch: 1a268c80f654b3018397672d264f1e4d646076b1
NitroMmkv: 8ed7ef6f41b91785fc580c975f68d6d675214767
NitroModules: 11bba9d065af151eae51e38a6425e04c3b223ff3
NitroOta: 92d4eb528566b6babf5e4a30adbda44bfa803a9b
NitroOtaBundleManager: 8fad871db2daf6b9ee6f04a100c79605cfa81e8d
NitroSuperconfig: 54d86ee90bb78cbca09d119ea775a53ffbedb0fc
NitroMmkv: 0be91455465952f2b943f753b9ee7df028d89e5c
NitroModules: e8ec707a245a85cf1eb4289f91a9372f9590a897
NitroOta: a3136533eb2397ff8cc3e6ff07ab603f122f3d0a
NitroOtaBundleManager: 4b9c1669d0dc7d594f3f82becdd20b213bfbe567
NitroSuperconfig: 81e51ed1b659657b86eef5d78aec675d8089abdb
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
RCTDeprecation: a41bbdd9af30bf2e5715796b313e44ec43eefff1
@@ -3696,11 +3696,11 @@ SPEC CHECKSUMS:
React-Mapbuffer: 7b72a669e94662359dad4f42b5af005eb24b4e83
React-microtasksnativemodule: cdc02da075f2857803ed63f24f5f72fc40e094c0
react-native-background-actions: 48e6bad9e2a47e3b04858634c5a05ea11062f680
react-native-blob-util: e2162ce4757849682559754bca954b65dc7eeb2f
react-native-blob-util: 04189c38b188b2b76ee453be34765241c552547f
react-native-blurhash: 93b024ff78f7912d22b1cdba262f3c91d3e2002e
react-native-carplay: 8f388f6f73e5e0f73ed154ad8794371343ee20c0
react-native-google-cast: 7be68a5d0b7eeb95a5924c3ecef8d319ef6c0a44
react-native-netinfo: a666a1d5ddadd04d87de48ac941b34f4d64fdf64
react-native-netinfo: 57447b5a45c98808f8eae292cf641f3d91d13830
react-native-pager-view: d7d2aa47f54343bf55fdcee3973503dd27c2bd37
react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460
react-native-track-player: 591b4734e248c57154cde36545a985e007805c9e
@@ -3747,10 +3747,10 @@ SPEC CHECKSUMS:
RNGestureHandler: cd4be101cfa17ea6bbd438710caa02e286a84381
RNReactNativeHapticFeedback: be4f1b4bf0398c30b59b76ed92ecb0a2ff3a69c6
RNReanimated: 942d757148da78f5663d1fdf9ab71d1e75946c22
RNScreens: e66f520506371042666e38a06d5d7e9580f0d3a0
RNSentry: fdb39d5f294e492aa2f08ad80e510310dc223772
RNScreens: afaf526a9c804c3b4503f950cf3e67ed81e29ada
RNSentry: 47538022dd91abf23db1a954a09cdb8db13b1b98
RNWorklets: 01efdd402d236a13651ea5ea5437ca85a44e7afa
Sentry: c643eb180df401dd8c734c5036ddd9dd9218daa6
Sentry: d587a8fe91ca13503ecd69a1905f3e8a0fcf61be
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
SwiftAudioEx: f6aa653770f3a0d3851edaf8d834a30aee4a7646
+25 -24
View File
@@ -1,6 +1,6 @@
{
"name": "jellify",
"version": "1.0.15",
"version": "1.0.18",
"private": true,
"scripts": {
"init-android": "bun i",
@@ -34,26 +34,27 @@
"sendOTA:iOS": "bash scripts/ota-iOS.sh",
"sendOTA:PR": "bash scripts/ota-PR.sh",
"android-build": "cd android && ./gradlew generateCodegenArtifactsFromSchema && ./gradlew assembleRelease",
"postinstall": "patch-package"
"generate-config": "node ./node_modules/react-native-superconfig/scripts/generate-config.js",
"postinstall": "bun run generate-config && patch-package"
},
"dependencies": {
"@jellify-music/react-native-reanimated-slider": "0.3.4",
"@jellify-music/react-native-reanimated-slider": "0.4.0",
"@jellyfin/sdk": "0.13.0",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/cli": "20.0.0",
"@react-native-community/netinfo": "11.5.1",
"@react-native-community/cli": "20.1.1",
"@react-native-community/netinfo": "11.5.2",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-vector-icons/material-design-icons": "12.4.0",
"@react-navigation/bottom-tabs": "7.12.0",
"@react-navigation/material-top-tabs": "7.4.13",
"@react-navigation/native": "7.1.28",
"@react-navigation/native-stack": "7.12.0",
"@sentry/react-native": "7.8.0",
"@shopify/flash-list": "2.2.1",
"@sentry/react-native": "7.12.0",
"@shopify/flash-list": "2.2.2",
"@tamagui/config": "1.144.3",
"@tanstack/query-async-storage-persister": "5.90.12",
"@tanstack/react-query": "5.90.12",
"@tanstack/react-query-persist-client": "5.90.12",
"@tanstack/query-async-storage-persister": "5.90.20",
"@tanstack/react-query": "5.90.20",
"@tanstack/react-query-persist-client": "5.90.20",
"@testing-library/react-native": "13.3.3",
"@typedigital/telemetrydeck-react": "0.4.1",
"lodash": "^4.17.21",
@@ -62,7 +63,7 @@
"react-freeze": "^1.0.4",
"react-native": "0.83.1",
"react-native-background-actions": "^4.0.1",
"react-native-blob-util": "^0.22.2",
"react-native-blob-util": "0.24.6",
"react-native-blurhash": "^2.1.3",
"react-native-carplay": "^2.4.1-beta.0",
"react-native-device-info": "15.0.1",
@@ -71,24 +72,24 @@
"react-native-google-cast": "^4.9.1",
"react-native-haptic-feedback": "^2.3.3",
"react-native-linear-gradient": "^2.8.3",
"react-native-mmkv": "^4.1.1",
"react-native-mmkv": "4.1.2",
"react-native-nitro-fetch": "0.1.7",
"react-native-nitro-modules": "0.33.2",
"react-native-nitro-ota": "^0.10.0",
"react-native-nitro-modules": "0.33.7",
"react-native-nitro-ota": "^0.11.0",
"react-native-pager-view": "8.0.0",
"react-native-reanimated": "4.1.6",
"react-native-safe-area-context": "5.6.2",
"react-native-screens": "4.21.0",
"react-native-screens": "4.23.0",
"react-native-sortables": "1.9.4",
"react-native-superconfig": "^0.6.0",
"react-native-superconfig": "^0.10.0",
"react-native-text-ticker": "^1.15.0",
"react-native-toast-message": "^2.3.3",
"react-native-track-player": "5.0.0-alpha0",
"react-native-turbo-image": "1.24.1",
"react-native-url-polyfill": "^2.0.0",
"react-native-url-polyfill": "3.0.0",
"react-native-uuid": "^2.0.3",
"react-native-worklets": "^0.7.1",
"react-native-worklets-core": "^1.6.2",
"react-native-worklets": "0.7.2",
"react-native-worklets-core": "1.6.2",
"ruby": "^0.6.1",
"scheduler": "^0.26.0",
"tamagui": "1.144.3",
@@ -100,15 +101,15 @@
"@babel/runtime": "7.28.6",
"@eslint/eslintrc": "3.3.3",
"@eslint/js": "9.39.2",
"@react-native-community/cli-platform-android": "20.0.0",
"@react-native-community/cli-platform-ios": "20.0.0",
"@react-native-community/cli-platform-android": "20.1.1",
"@react-native-community/cli-platform-ios": "20.1.1",
"@react-native/babel-preset": "0.83.1",
"@react-native/eslint-config": "0.83.1",
"@react-native/metro-config": "0.83.1",
"@react-native/typescript-config": "0.83.1",
"@types/jest": "^30.0.0",
"@types/lodash": "^4.17.21",
"@types/node": "25.2.0",
"@types/node": "25.2.2",
"@types/react": "19.2.0",
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "19.1.0",
@@ -117,14 +118,14 @@
"eslint": "9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-prettier": "5.5.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-native": "^5.0.0",
"globals": "17.3.0",
"husky": "^9.1.7",
"jest": "30.2.0",
"jscodeshift": "^17.3.0",
"lint-staged": "^16.1.5",
"lint-staged": "16.2.7",
"patch-package": "8.0.1",
"prettier": "3.8.1",
"react-dom": "19.2.0",
+2 -2
View File
@@ -1,5 +1,5 @@
diff --git a/node_modules/react-native-reanimated/compatibility.json b/node_modules/react-native-reanimated/compatibility.json
index f1ba9cb..234363b 100644
index f1ba9cb..ffab7fe 100644
--- a/node_modules/react-native-reanimated/compatibility.json
+++ b/node_modules/react-native-reanimated/compatibility.json
@@ -4,7 +4,7 @@
@@ -7,7 +7,7 @@ index f1ba9cb..234363b 100644
},
"4.1.x": {
- "react-native": ["0.78", "0.79", "0.80", "0.81", "0.82"],
+ "react-native": ["0.78", "0.79", "0.80", "0.81", "0.82", "0.83"],
+ "react-native": ["0.78", "0.79", "0.80", "0.81", "0.82", "0.83", "0.84"],
"react-native-worklets": ["0.5.x", "0.6.x", "0.7.x"]
},
"4.0.x": {
+6
View File
@@ -48,6 +48,8 @@ const useAlbums: () => [
: ItemSortBy.Album
const sortDescending = librarySortDescendingState.albums ?? false
const isFavorites = filters.albums.isFavorites
const yearMin = filters.albums.yearMin
const yearMax = filters.albums.yearMax
const albumPageParams = useRef<Set<string>>(new Set<string>())
@@ -72,6 +74,8 @@ const useAlbums: () => [
library?.musicLibraryId,
librarySortBy,
sortDescending,
yearMin,
yearMax,
],
queryFn: ({ pageParam }) =>
fetchAlbums(
@@ -82,6 +86,8 @@ const useAlbums: () => [
isFavorites,
[librarySortBy ?? ItemSortBy.SortName],
[sortDescending ? SortOrder.Descending : SortOrder.Ascending],
yearMin,
yearMax,
),
initialPageParam: 0,
select: selectAlbums,
+13 -3
View File
@@ -9,9 +9,9 @@ import { JellifyLibrary } from '../../../../types/JellifyLibrary'
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 '../../../../configs/query.config'
import { nitroFetch } from '../../../utils/nitro'
import buildYearsParam from '../../../../utils/mapping/build-years-param'
export function fetchAlbums(
api: Api | undefined,
@@ -21,12 +21,16 @@ export function fetchAlbums(
isFavorite: boolean | undefined,
sortBy: ItemSortBy[] = [ItemSortBy.SortName],
sortOrder: SortOrder[] = [SortOrder.Ascending],
yearMin?: number,
yearMax?: number,
): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
if (!api) return reject('No API instance provided')
if (!user) return reject('No user provided')
if (!library) return reject('Library has not been set')
const yearsParam = buildYearsParam(yearMin, yearMax)
nitroFetch<{ Items: BaseItemDto[] }>(api, '/Items', {
ParentId: library.musicLibraryId,
IncludeItemTypes: [BaseItemKind.MusicAlbum],
@@ -38,9 +42,15 @@ export function fetchAlbums(
IsFavorite: isFavorite,
Fields: [ItemFields.SortName],
Recursive: true,
}).then((data) => {
return data.Items ? resolve(data.Items) : resolve([])
Years: yearsParam,
})
.then((data) => {
return data.Items ? resolve(data.Items) : resolve([])
})
.catch((error) => {
console.error(error)
return reject(error)
})
})
}
+19
View File
@@ -0,0 +1,19 @@
import { QueryKeys } from '../../../enums/query-keys'
import { useQuery } from '@tanstack/react-query'
import { fetchSearchResults } from './utils'
import { ONE_MINUTE } from '../../../constants/query-client'
import { useJellifyLibrary } from '../../../stores'
const useSearchResults = (searchString: string | undefined) => {
const [library] = useJellifyLibrary()
return useQuery({
queryKey: [QueryKeys.Search, library?.musicLibraryId, searchString],
queryFn: () => fetchSearchResults(library?.musicLibraryId, searchString),
staleTime: ONE_MINUTE * 10, // Cache results for 10 minutes
gcTime: ONE_MINUTE * 15, // Garbage collect after 15 minutes
enabled: !!library?.musicLibraryId && !!searchString, // Only run if we have a library ID and a search string
})
}
export default useSearchResults
@@ -1,9 +1,8 @@
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 '../../configs/query.config'
import { Api } from '@jellyfin/sdk'
import { JellifyUser } from '../../types/JellifyUser'
import QueryConfig from '../../../../configs/query.config'
import { getApi, getUser } from '../../../../stores'
/**
* Performs a search for items against the Jellyfin server, trimming whitespace
* around the search term for the best possible results.
@@ -11,12 +10,13 @@ import { JellifyUser } from '../../types/JellifyUser'
* @returns A promise of a BaseItemDto array, be it empty or not
*/
export async function fetchSearchResults(
api: Api | undefined,
user: JellifyUser | undefined,
libraryId: string | undefined,
searchString: string | undefined,
): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
const api = getApi()
const user = getUser()
if (isEmpty(searchString)) resolve([])
if (isUndefined(api)) return reject('Client instance not set')
+71 -13
View File
@@ -44,6 +44,8 @@ const useTracks: (
const isDownloaded = filters.tracks.isDownloaded ?? false
const isLibraryUnplayed = filters.tracks.isUnplayed ?? false
const libraryGenreIds = filters.tracks.genreIds
const libraryYearMin = filters.tracks.yearMin
const libraryYearMax = filters.tracks.yearMax
// Use provided values or fallback to library context
// If artistId is present, we use isFavoritesParam if provided, otherwise false (default to showing all artist tracks)
@@ -95,6 +97,8 @@ const useTracks: (
finalSortBy,
finalSortOrder,
isDownloaded ? undefined : libraryGenreIds,
libraryYearMin,
libraryYearMax,
),
queryFn: ({ pageParam }) => {
if (!isDownloaded) {
@@ -109,21 +113,33 @@ const useTracks: (
finalSortOrder,
artistId,
libraryGenreIds,
libraryYearMin,
libraryYearMax,
)
} else
return (downloadedTracks ?? [])
.map(({ item }) => item)
.sort((a, b) => {
const aName = a.Name ?? ''
const bName = b.Name ?? ''
if (aName < bName) return -1
else if (aName === bName) return 0
else return 1
})
.filter((track) => {
if (!isFavorites) return true
else return isDownloadedTrackAlsoFavorite(user, track)
} else {
let items = (downloadedTracks ?? []).map(({ item }) => item)
if (libraryYearMin != null || libraryYearMax != null) {
const min = libraryYearMin ?? 0
const max = libraryYearMax ?? new Date().getFullYear()
items = items.filter((track) => {
const y =
'ProductionYear' in track
? (track as BaseItemDto).ProductionYear
: undefined
if (y == null) return false
return y >= min && y <= max
})
}
const sortByForCompare =
finalSortBy === ItemSortBy.SortName ? ItemSortBy.Name : finalSortBy
items = items.sort((a, b) =>
compareDownloadedTracks(a, b, sortByForCompare, finalSortOrder),
)
return items.filter((track) => {
if (!isFavorites) return true
else return isDownloadedTrackAlsoFavorite(user, track)
})
}
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
@@ -147,3 +163,45 @@ function isDownloadedTrackAlsoFavorite(user: JellifyUser | undefined, track: Bas
return userData?.IsFavorite ?? false
}
function getSortValue(item: BaseItemDto, sortBy: ItemSortBy): string | number {
switch (sortBy) {
case ItemSortBy.Name:
case ItemSortBy.SortName:
return item.Name ?? item.SortName ?? ''
case ItemSortBy.Album:
return item.Album ?? ''
case ItemSortBy.Artist:
return item.AlbumArtist ?? item.Artists?.[0] ?? ''
case ItemSortBy.DateCreated:
return item.DateCreated ? new Date(item.DateCreated).getTime() : 0
case ItemSortBy.PlayCount:
return item.UserData?.PlayCount ?? 0
case ItemSortBy.PremiereDate:
return item.PremiereDate ? new Date(item.PremiereDate).getTime() : 0
case ItemSortBy.Runtime:
return item.RunTimeTicks ?? 0
default:
return item.Name ?? item.SortName ?? ''
}
}
function compareDownloadedTracks(
a: BaseItemDto,
b: BaseItemDto,
sortBy: ItemSortBy,
sortOrder: SortOrder,
): number {
const aVal = getSortValue(a, sortBy)
const bVal = getSortValue(b, sortBy)
const isDesc = sortOrder === SortOrder.Descending
let cmp: number
if (typeof aVal === 'number' && typeof bVal === 'number') {
cmp = aVal - bVal
} else {
const aStr = String(aVal)
const bStr = String(bVal)
cmp = aStr.localeCompare(bStr, undefined, { sensitivity: 'base' })
}
return isDesc ? -cmp : cmp
}
+4
View File
@@ -16,6 +16,8 @@ export const TracksQueryKey = (
sortBy?: string,
sortOrder?: string,
genreIds?: string[],
yearMin?: number,
yearMax?: number,
) => [
TrackQueryKeys.AllTracks,
library?.musicLibraryId,
@@ -27,4 +29,6 @@ export const TracksQueryKey = (
sortBy,
sortOrder,
genreIds && genreIds.length > 0 ? `genres:${genreIds.sort().join(',')}` : undefined,
yearMin,
yearMax,
]
+6
View File
@@ -12,6 +12,7 @@ import { nitroFetch } from '../../../utils/nitro'
import { isUndefined } from 'lodash'
import { ApiLimits } from '../../../../configs/query.config'
import { JellifyUser } from '../../../../types/JellifyUser'
import buildYearsParam from '../../../../utils/mapping/build-years-param'
export default function fetchTracks(
api: Api | undefined,
@@ -24,6 +25,8 @@ export default function fetchTracks(
sortOrder: SortOrder = SortOrder.Ascending,
artistId?: string,
genreIds?: string[],
yearMin?: number,
yearMax?: number,
) {
return new Promise<BaseItemDto[]>((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set')
@@ -43,6 +46,8 @@ export default function fetchTracks(
filters.push(ItemFilter.IsUnplayed)
}
const yearsParam = buildYearsParam(yearMin, yearMax)
nitroFetch<{ Items: BaseItemDto[] }>(api, '/Items', {
IncludeItemTypes: [BaseItemKind.Audio],
ParentId: library.musicLibraryId,
@@ -56,6 +61,7 @@ export default function fetchTracks(
Fields: [ItemFields.SortName],
ArtistIds: artistId ? [artistId] : undefined,
GenreIds: genreIds && genreIds.length > 0 ? genreIds : undefined,
Years: yearsParam,
})
.then((data) => {
if (data.Items) return resolve(data.Items)
+27
View File
@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query'
import { fetchLibraryYears } from './utils'
import { LibraryYearsQueryKey } from './keys'
import { getApi, getUser, useJellifyLibrary } from '../../../stores'
export function useLibraryYears(): {
years: number[]
isPending: boolean
isError: boolean
} {
const api = getApi()
const user = getUser()
const [library] = useJellifyLibrary()
const {
data: years = [],
isPending,
isError,
} = useQuery({
queryKey: LibraryYearsQueryKey(library?.musicLibraryId, user?.id),
queryFn: () => fetchLibraryYears(api, library, user?.id),
enabled: Boolean(api && library && user?.id),
staleTime: 5 * 60 * 1000,
})
return { years, isPending, isError }
}
+5
View File
@@ -0,0 +1,5 @@
export const LibraryYearsQueryKey = (libraryId: string | undefined, userId: string | undefined) => [
'LibraryYears',
libraryId,
userId,
]
+36
View File
@@ -0,0 +1,36 @@
import { Api } from '@jellyfin/sdk'
import { JellifyLibrary } from '../../../../types/JellifyLibrary'
import { nitroFetch } from '../../../utils/nitro'
import { isUndefined } from 'lodash'
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'
export type ItemsFiltersResponse = {
Genres?: string[] | null
Years?: number[] | null
Tags?: string[] | null
OfficialRatings?: string[] | null
}
/**
* Fetches available filter values (genres, years) for the music library via /Items/Filters.
* Uses MusicAlbum so years reflect album release dates in the library.
* Returns sorted ascending list of year numbers.
*/
export async function fetchLibraryYears(
api: Api | undefined,
library: JellifyLibrary | undefined,
userId: string | undefined,
): Promise<number[]> {
if (isUndefined(api)) throw new Error('Client instance not set')
if (isUndefined(library)) throw new Error('Library instance not set')
if (isUndefined(userId)) throw new Error('User id required')
const data = await nitroFetch<ItemsFiltersResponse>(api, '/Items/Filters', {
UserId: userId,
ParentId: library.musicLibraryId,
IncludeItemTypes: [BaseItemKind.MusicAlbum],
})
const years = data?.Years ?? []
return [...years].filter((y) => typeof y === 'number' && !Number.isNaN(y)).sort((a, b) => a - b)
}
+5 -1
View File
@@ -77,7 +77,11 @@ export default function Albums({
<FlashListStickyHeader text={album.toUpperCase()} />
)
) : typeof album === 'number' ? null : typeof album === 'object' ? (
<ItemRow item={album} navigation={navigation} />
<ItemRow
item={album}
navigation={navigation}
sortingByReleasedDate={sortBy === ItemSortBy.PremiereDate}
/>
) : null
const onEndReached = () => {
+7 -13
View File
@@ -7,9 +7,9 @@ import { useNavigation } from '@react-navigation/native'
import DiscoverStackParamList from '../../../screens/Discover/types'
import navigationRef from '../../../../navigation'
import { useRecentlyAddedAlbums } from '../../../api/queries/album'
import Animated, { Easing, FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
import AnimatedRow from '../../Global/helpers/animated-row'
export default function RecentlyAdded(): React.JSX.Element | null {
export default function RecentlyAdded(): React.JSX.Element {
const recentlyAddedAlbumsInfinityQuery = useRecentlyAddedAlbums()
const navigation = useNavigation<NativeStackNavigationProp<DiscoverStackParamList>>()
@@ -18,15 +18,7 @@ export default function RecentlyAdded(): React.JSX.Element | null {
recentlyAddedAlbumsInfinityQuery.data && recentlyAddedAlbumsInfinityQuery.data.length > 0
return recentlyAddedExists ? (
<Animated.View
entering={FadeIn.easing(Easing.in(Easing.ease))}
exiting={FadeOut.easing(Easing.out(Easing.ease))}
layout={LinearTransition.springify()}
testID='discover-recently-added'
style={{
flex: 1,
}}
>
<AnimatedRow testID='discover-recently-added'>
<XStack
alignItems='center'
onPress={() => {
@@ -64,6 +56,8 @@ export default function RecentlyAdded(): React.JSX.Element | null {
/>
)}
/>
</Animated.View>
) : null
</AnimatedRow>
) : (
<></>
)
}
@@ -9,9 +9,9 @@ import DiscoverStackParamList from '../../../screens/Discover/types'
import navigationRef from '../../../../navigation'
import { useJellifyServer } from '../../../stores'
import { usePublicPlaylists } from '../../../api/queries/playlist'
import Animated, { Easing, FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
import AnimatedRow from '../../Global/helpers/animated-row'
export default function PublicPlaylists(): React.JSX.Element | null {
export default function PublicPlaylists(): React.JSX.Element {
const {
data: playlists,
fetchNextPage,
@@ -29,15 +29,7 @@ export default function PublicPlaylists(): React.JSX.Element | null {
const publicPlaylistsExist = playlists && playlists.length > 0
return publicPlaylistsExist ? (
<Animated.View
entering={FadeIn.easing(Easing.in(Easing.ease))}
exiting={FadeOut.easing(Easing.out(Easing.ease))}
layout={LinearTransition.springify()}
testID='discover-public-playlists'
style={{
flex: 1,
}}
>
<AnimatedRow testID='discover-public-playlists'>
<XStack
alignItems='center'
onPress={() => {
@@ -80,6 +72,8 @@ export default function PublicPlaylists(): React.JSX.Element | null {
/>
)}
/>
</Animated.View>
) : null
</AnimatedRow>
) : (
<></>
)
}
@@ -1,6 +1,5 @@
import navigationRef from '../../../../navigation'
import { formatArtistNames } from '../../../utils/formatting/artist-names'
import Animated, { Easing, FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
import ItemCard from '../../Global/components/item-card'
import HorizontalCardList from '../../Global/components/horizontal-list'
import { XStack } from 'tamagui'
@@ -10,6 +9,7 @@ import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import DiscoverStackParamList from '../../../screens/Discover/types'
import { useDiscoverAlbums } from '../../../api/queries/suggestions'
import AnimatedRow from '../../Global/helpers/animated-row'
export default function SuggestedAlbums() {
const suggestedAlbumsInfiniteQuery = useDiscoverAlbums()
@@ -20,15 +20,7 @@ export default function SuggestedAlbums() {
suggestedAlbumsInfiniteQuery.data && suggestedAlbumsInfiniteQuery.data.length > 0
return suggestedAlbumsExist ? (
<Animated.View
entering={FadeIn.easing(Easing.in(Easing.ease))}
exiting={FadeOut.easing(Easing.out(Easing.ease))}
layout={LinearTransition.springify()}
testID='discover-suggested-albums'
style={{
flex: 1,
}}
>
<AnimatedRow testID='discover-suggested-albums'>
<XStack
alignItems='center'
onPress={() => {
@@ -64,6 +56,8 @@ export default function SuggestedAlbums() {
/>
)}
/>
</Animated.View>
) : null
</AnimatedRow>
) : (
<></>
)
}
@@ -7,10 +7,10 @@ import { useNavigation } from '@react-navigation/native'
import DiscoverStackParamList from '../../../screens/Discover/types'
import navigationRef from '../../../../navigation'
import { pickFirstGenre } from '../../../utils/formatting/genres'
import Animated, { Easing, FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
import { useDiscoverArtists } from '../../../api/queries/suggestions'
import AnimatedRow from '../../Global/helpers/animated-row'
export default function SuggestedArtists(): React.JSX.Element | null {
export default function SuggestedArtists(): React.JSX.Element {
const suggestedArtistsInfiniteQuery = useDiscoverArtists()
const navigation = useNavigation<NativeStackNavigationProp<DiscoverStackParamList>>()
@@ -19,15 +19,7 @@ export default function SuggestedArtists(): React.JSX.Element | null {
suggestedArtistsInfiniteQuery.data && suggestedArtistsInfiniteQuery.data.length > 0
return suggestedArtistsExist ? (
<Animated.View
entering={FadeIn.easing(Easing.in(Easing.ease))}
exiting={FadeOut.easing(Easing.out(Easing.ease))}
layout={LinearTransition.springify()}
testID='discover-suggested-artists'
style={{
flex: 1,
}}
>
<AnimatedRow testID='discover-suggested-artists'>
<XStack
alignItems='center'
onPress={() => {
@@ -63,6 +55,8 @@ export default function SuggestedArtists(): React.JSX.Element | null {
/>
)}
/>
</Animated.View>
) : null
</AnimatedRow>
) : (
<></>
)
}
+43 -1
View File
@@ -8,7 +8,6 @@ import { FiltersProps } from './types'
import Icon from '../Global/components/icon'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { RootStackParamList } from '../../screens/types'
import { trigger } from 'react-native-haptic-feedback'
export default function Filters({
currentTab,
@@ -27,6 +26,11 @@ export default function Filters({
const isUnplayed = currentFilters.isUnplayed ?? false
const selectedGenreIds = currentFilters.genreIds ?? []
const hasGenresSelected = selectedGenreIds.length > 0
const yearMin = currentFilters.yearMin
const yearMax = currentFilters.yearMax
const hasYearRange = yearMin != null || yearMax != null
const yearRangeLabel =
yearMin != null || yearMax != null ? `${yearMin ?? '…'} ${yearMax ?? '…'}` : null
const handleFavoritesToggle = (checked: boolean | 'indeterminate') => {
triggerHaptic('impactLight')
@@ -60,6 +64,13 @@ export default function Filters({
navigation?.navigate('GenreSelection')
}
const handleYearRangeSelect = () => {
triggerHaptic('impactLight')
navigation?.navigate('YearSelection', {
tab: currentTab === 'Tracks' || currentTab === 'Albums' ? currentTab : 'Tracks',
})
}
const handleUnplayedToggle = (checked: boolean | 'indeterminate') => {
triggerHaptic('impactLight')
if (currentTab === 'Tracks') {
@@ -144,6 +155,37 @@ export default function Filters({
</Button>
</XStack>
)}
{(isTracksTab || currentTab === 'Albums') && (
<XStack alignItems='center' justifyContent='space-between' marginTop='$4'>
<Button
variant='outlined'
size='$4'
onPress={handleYearRangeSelect}
pressStyle={{ opacity: 0.6 }}
animation='quick'
flex={1}
justifyContent='space-between'
disabled={isTracksTab && isDownloaded}
>
<Text
color={
isTracksTab && isDownloaded
? '$borderColor'
: hasYearRange
? '$primary'
: '$neutral'
}
>
{hasYearRange ? `Year range ${yearRangeLabel}` : 'Year range'}
</Text>
<Icon
name={hasYearRange ? 'filter-variant' : 'filter'}
color={hasYearRange ? '$primary' : '$borderColor'}
/>
</Button>
</XStack>
)}
</YStack>
</YStack>
)
@@ -142,15 +142,26 @@ export default function TrackRowContent({
<SlidingTextArea leftGapWidth={artworkAreaWidth} hasArtwork={!!showArtwork}>
<YStack alignItems='flex-start' justifyContent='center'>
<Text
key={`${track.Id}-name`}
bold
color={textColor}
lineBreakStrategyIOS='standard'
numberOfLines={1}
>
{trackName}
</Text>
<XStack alignItems='center'>
<Text
key={`${track.Id}-name`}
bold
color={textColor}
lineBreakStrategyIOS='standard'
numberOfLines={1}
>
{trackName}
</Text>
{!shouldShowArtists && isExplicit(track as JellifyTrack) && (
<XStack alignSelf='center' paddingLeft='$2'>
<Icon
name='alpha-e-box-outline'
color={'$borderColor'}
xxsmall
/>
</XStack>
)}
</XStack>
{shouldShowArtists && (
<XStack alignItems='center'>
@@ -37,6 +37,7 @@ export interface TrackProps {
editing?: boolean | undefined
sortingByAlbum?: boolean | undefined
sortingByReleasedDate?: boolean | undefined
sortingByPlayCount?: boolean | undefined
}
export default function Track({
@@ -54,6 +55,7 @@ export default function Track({
editing,
sortingByAlbum,
sortingByReleasedDate,
sortingByPlayCount,
}: TrackProps): React.JSX.Element {
const theme = useTheme()
const [artworkAreaWidth, setArtworkAreaWidth] = useState(0)
@@ -141,7 +143,9 @@ export default function Track({
? track.Album
: sortingByReleasedDate
? `${track.ProductionYear?.toString()}${track.Artists?.join(' • ')}`
: track.Artists?.join(' • ')) ?? ''
: sortingByPlayCount
? `${track.UserData?.PlayCount?.toString()}${track.Artists?.join(' • ')}`
: track.Artists?.join(' • ')) ?? ''
// Memoize track name
const trackName = track.Name ?? 'Untitled Track'
+14 -3
View File
@@ -38,6 +38,7 @@ interface ItemRowProps {
onLongPress?: () => void
navigation?: Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
queueName?: Queue
sortingByReleasedDate?: boolean | undefined
}
/**
@@ -58,6 +59,7 @@ function ItemRow({
onPress,
onLongPress,
queueName,
sortingByReleasedDate,
}: ItemRowProps): React.JSX.Element {
const artworkAreaWidth = useSharedValue(0)
@@ -170,7 +172,7 @@ function ItemRow({
>
<HideableArtwork item={item} circular={circular} onLayout={handleArtworkLayout} />
<SlidingTextArea leftGapWidth={artworkAreaWidth}>
<ItemRowDetails item={item} />
<ItemRowDetails item={item} sortingByReleasedDate={sortingByReleasedDate} />
</SlidingTextArea>
<XStack justifyContent='flex-end' alignItems='center' flexShrink={1}>
@@ -235,7 +237,13 @@ function ItemRow({
)
}
function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element {
function ItemRowDetails({
item,
sortingByReleasedDate,
}: {
item: BaseItemDto
sortingByReleasedDate?: boolean | undefined
}): React.JSX.Element {
const route = useRoute<RouteProp<BaseStackParamList>>()
const shouldRenderArtistName =
@@ -253,7 +261,10 @@ function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element {
{shouldRenderArtistName && (
<Text color={'$borderColor'} lineBreakStrategyIOS='standard' numberOfLines={1}>
{formatArtistName(item.AlbumArtist)}
{formatArtistName(
item.AlbumArtist,
sortingByReleasedDate ? item.ProductionYear?.toString() : undefined,
)}
</Text>
)}
@@ -0,0 +1,28 @@
import Animated, {
FadeIn,
ReduceMotion,
FadeOut,
LinearTransition,
Easing,
} from 'react-native-reanimated'
interface AnimatedRowProps {
children: React.ReactNode
testID?: string
}
export default function AnimatedRow({ children, testID }: AnimatedRowProps) {
return (
<Animated.View
testID={testID}
entering={FadeIn.easing(Easing.in(Easing.ease)).reduceMotion(ReduceMotion.System)}
exiting={FadeOut.easing(Easing.out(Easing.ease)).reduceMotion(ReduceMotion.System)}
layout={LinearTransition.springify().reduceMotion(ReduceMotion.System)}
style={{
flex: 1,
}}
>
{children}
</Animated.View>
)
}
@@ -11,7 +11,7 @@ import { RootStackParamList } from '../../../screens/types'
import { useFrequentlyPlayedArtists } from '../../../api/queries/frequents'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { pickFirstGenre } from '../../../utils/formatting/genres'
import Animated, { Easing, FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
import AnimatedRow from '../../Global/helpers/animated-row'
export default function FrequentArtists(): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<HomeStackParamList>>()
@@ -41,14 +41,7 @@ export default function FrequentArtists(): React.JSX.Element {
)
return frequentArtistsInfiniteQuery.data ? (
<Animated.View
entering={FadeIn.easing(Easing.in(Easing.ease))}
exiting={FadeOut.easing(Easing.out(Easing.ease))}
layout={LinearTransition.springify()}
style={{
flex: 1,
}}
>
<AnimatedRow testID='home-frequent-artists'>
<XStack
alignItems='center'
onPress={() => {
@@ -63,7 +56,7 @@ export default function FrequentArtists(): React.JSX.Element {
data={frequentArtistsInfiniteQuery.data.slice(0, horizontalItems) ?? []}
renderItem={renderItem}
/>
</Animated.View>
</AnimatedRow>
) : (
<></>
)
@@ -10,7 +10,7 @@ import HomeStackParamList from '../../../screens/Home/types'
import { useNavigation } from '@react-navigation/native'
import { RootStackParamList } from '../../../screens/types'
import { useFrequentlyPlayedTracks } from '../../../api/queries/frequents'
import Animated, { Easing, FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
import AnimatedRow from '../../Global/helpers/animated-row'
export default function FrequentlyPlayedTracks(): React.JSX.Element {
const tracksInfiniteQuery = useFrequentlyPlayedTracks()
@@ -23,14 +23,7 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
const { horizontalItems } = useDisplayContext()
return tracksInfiniteQuery.data ? (
<Animated.View
entering={FadeIn.easing(Easing.in(Easing.ease))}
exiting={FadeOut.easing(Easing.out(Easing.ease))}
layout={LinearTransition.springify()}
style={{
flex: 1,
}}
>
<AnimatedRow testID='home-frequent-tracks'>
<XStack
alignItems='center'
onPress={() => {
@@ -75,7 +68,7 @@ export default function FrequentlyPlayedTracks(): React.JSX.Element {
/>
)}
/>
</Animated.View>
</AnimatedRow>
) : (
<></>
)
+3 -10
View File
@@ -11,7 +11,7 @@ import HomeStackParamList from '../../../screens/Home/types'
import { useRecentArtists } from '../../../api/queries/recents'
import { pickFirstGenre } from '../../../utils/formatting/genres'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import Animated, { Easing, FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
import AnimatedRow from '../../Global/helpers/animated-row'
export default function RecentArtists(): React.JSX.Element {
const recentArtistsInfiniteQuery = useRecentArtists()
@@ -47,14 +47,7 @@ export default function RecentArtists(): React.JSX.Element {
)
return recentArtistsInfiniteQuery.data ? (
<Animated.View
entering={FadeIn.easing(Easing.in(Easing.ease))}
exiting={FadeOut.easing(Easing.out(Easing.ease))}
layout={LinearTransition.springify()}
style={{
flex: 1,
}}
>
<AnimatedRow testID='home-recent-artists'>
<XStack alignItems='center' onPress={handleHeaderPress}>
<H5 marginLeft={'$2'}>Recent Artists</H5>
<Icon name='arrow-right' />
@@ -64,7 +57,7 @@ export default function RecentArtists(): React.JSX.Element {
data={recentArtistsInfiniteQuery.data.slice(0, horizontalItems)}
renderItem={renderItem}
/>
</Animated.View>
</AnimatedRow>
) : (
<></>
)
@@ -11,8 +11,8 @@ import { useDisplayContext } from '../../../providers/Display/display-provider'
import { useNavigation } from '@react-navigation/native'
import HomeStackParamList from '../../../screens/Home/types'
import { useRecentlyPlayedTracks } from '../../../api/queries/recents'
import Animated, { Easing, FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client'
import AnimatedRow from '../../Global/helpers/animated-row'
export default function RecentlyPlayed(): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<HomeStackParamList>>()
@@ -44,14 +44,7 @@ export default function RecentlyPlayed(): React.JSX.Element {
}
return tracksInfiniteQuery.data ? (
<Animated.View
entering={FadeIn.easing(Easing.in(Easing.ease))}
exiting={FadeOut.easing(Easing.out(Easing.ease))}
layout={LinearTransition.springify()}
style={{
flex: 1,
}}
>
<AnimatedRow testID='home-recently-played'>
<XStack
alignItems='center'
onPress={() => {
@@ -88,7 +81,7 @@ export default function RecentlyPlayed(): React.JSX.Element {
/>
)}
/>
</Animated.View>
</AnimatedRow>
) : (
<></>
)
+10 -4
View File
@@ -32,7 +32,9 @@ function LibraryTabBar(props: MaterialTopTabBarProps) {
(currentFilters.isFavorites === true ||
currentFilters.isDownloaded === true ||
currentFilters.isUnplayed === true ||
(currentFilters.genreIds && currentFilters.genreIds.length > 0))
(currentFilters.genreIds && currentFilters.genreIds.length > 0) ||
currentFilters.yearMin != null ||
currentFilters.yearMax != null)
const handleShufflePress = async () => {
triggerHaptic('impactLight')
@@ -163,11 +165,15 @@ function LibraryTabBar(props: MaterialTopTabBarProps) {
isDownloaded: false,
isUnplayed: false,
genreIds: undefined,
yearMin: undefined,
yearMax: undefined,
})
} else if (currentTab === 'Albums') {
useLibraryStore
.getState()
.setAlbumsFilters({ isFavorites: undefined })
useLibraryStore.getState().setAlbumsFilters({
isFavorites: undefined,
yearMin: undefined,
yearMax: undefined,
})
} else if (currentTab === 'Artists') {
useLibraryStore
.getState()
+21 -13
View File
@@ -11,7 +11,11 @@ import {
} from '../../../hooks/player/callbacks'
import { useRepeatModeStoreValue, useShuffle } from '../../../stores/player/queue'
export default function Controls(): React.JSX.Element {
export default function Controls({
onLyricsScreen,
}: {
onLyricsScreen?: boolean
}): React.JSX.Element {
const previous = usePrevious()
const skip = useSkip()
const repeatMode = useRepeatModeStoreValue()
@@ -24,12 +28,14 @@ export default function Controls(): React.JSX.Element {
return (
<XStack alignItems='center' justifyContent='space-between'>
<Icon
small
color={shuffled ? '$primary' : '$color'}
name='shuffle'
onPress={() => toggleShuffle(shuffled)}
/>
{!onLyricsScreen && (
<Icon
small
color={shuffled ? '$primary' : '$color'}
name='shuffle'
onPress={() => toggleShuffle(shuffled)}
/>
)}
<Spacer />
@@ -54,12 +60,14 @@ export default function Controls(): React.JSX.Element {
<Spacer />
<Icon
small
color={repeatMode === RepeatMode.Off ? '$color' : '$primary'}
name={repeatMode === RepeatMode.Track ? 'repeat-once' : 'repeat'}
onPress={async () => toggleRepeatMode()}
/>
{!onLyricsScreen && (
<Icon
small
color={repeatMode === RepeatMode.Off ? '$color' : '$primary'}
name={repeatMode === RepeatMode.Track ? 'repeat-once' : 'repeat'}
onPress={async () => toggleRepeatMode()}
/>
)}
</XStack>
)
}
+312 -123
View File
@@ -6,7 +6,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'
import { useProgress } from '../../../hooks/player/queries'
import { useSeekTo } from '../../../hooks/player/callbacks'
import { UPDATE_INTERVAL } from '../../../configs/player.config'
import React, { useEffect, useMemo, useRef, useCallback } from 'react'
import React, { useEffect, useMemo, useRef, useCallback, useState } from 'react'
import Animated, {
useSharedValue,
useAnimatedStyle,
@@ -17,9 +17,13 @@ import Animated, {
SharedValue,
} from 'react-native-reanimated'
import { FlatList, ListRenderItem } from 'react-native'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { trigger } from 'react-native-haptic-feedback'
import Icon from '../../Global/components/icon'
import useRawLyrics from '../../../api/queries/lyrics'
import { useCurrentTrack } from '../../../stores/player/queue'
import Scrubber from './scrubber'
import Controls from './controls'
interface LyricLine {
Text: string
@@ -33,7 +37,7 @@ interface ParsedLyricLine {
}
const AnimatedText = Animated.createAnimatedComponent(Text)
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList<ParsedLyricLine>)
const AnimatedFlatList = Animated.FlatList<ParsedLyricLine>
// Memoized lyric line component for better performance
const LyricLineItem = React.memo(
@@ -42,17 +46,19 @@ const LyricLineItem = React.memo(
index,
currentLineIndex,
onPress,
onLayout: onItemLayout,
}: {
item: ParsedLyricLine
index: number
currentLineIndex: SharedValue<number>
onPress: (startTime: number, index: number) => void
onLayout?: (index: number, height: number) => void
}) => {
const theme = useTheme()
// Get theme-aware colors
const primaryColor = theme.color.val // Primary text color (adapts to dark/light)
const neutralColor = theme.neutral.val // Secondary text color
const neutralColor = theme.color.val + '95' // Secondary text color
const highlightColor = theme.primary.val // Highlight color (primaryDark/primaryLight)
const translucentColor = theme.translucent?.val // Theme-aware translucent background
const backgroundHighlight = translucentColor || theme.primary.val + '15' // Fallback with 15% opacity
@@ -114,49 +120,70 @@ const LyricLineItem = React.memo(
}
})
const handlePress = useCallback(() => {
onPress(item.startTime, index)
}, [item.startTime, index, onPress])
const tapGesture = useMemo(
() =>
Gesture.Tap()
.maxDistance(10)
.maxDuration(500)
.runOnJS(true)
.onEnd((_e, success) => {
if (success) {
onPress(item.startTime, index)
}
}),
[item.startTime, index, onPress],
)
const handleLayout = useCallback(
(e: { nativeEvent: { layout: { height: number } } }) => {
onItemLayout?.(index, e.nativeEvent.layout.height)
},
[index, onItemLayout],
)
return (
<Animated.View
onLayout={handleLayout}
style={[
{
paddingVertical: 12,
paddingVertical: 3,
paddingHorizontal: 20,
minHeight: 60,
justifyContent: 'center',
marginHorizontal: 16,
marginVertical: 4,
marginVertical: 0,
},
animatedStyle,
]}
>
<Animated.View
style={[
{
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 8,
},
backgroundStyle,
]}
onTouchEnd={handlePress}
>
<AnimatedText
<GestureDetector gesture={tapGesture}>
<Animated.View
style={[
{
fontSize: 18,
lineHeight: 28,
textAlign: 'center',
fontWeight: '500',
alignSelf: 'stretch',
minWidth: 0,
paddingVertical: 10,
paddingHorizontal: 10,
borderRadius: 8,
},
textColorStyle,
backgroundStyle,
]}
>
{item.text}
</AnimatedText>
</Animated.View>
<AnimatedText
style={[
{
fontSize: 18,
lineHeight: 28,
textAlign: 'left',
fontWeight: '500',
},
textColorStyle,
]}
>
{item.text}
</AnimatedText>
</Animated.View>
</GestureDetector>
</Animated.View>
)
},
@@ -169,16 +196,30 @@ export default function Lyrics({
}: {
navigation: NativeStackNavigationProp<PlayerParamList>
}): React.JSX.Element {
const theme = useTheme()
const { data: lyrics } = useRawLyrics()
const nowPlaying = useCurrentTrack()
const { height } = useWindowDimensions()
const { position } = useProgress(UPDATE_INTERVAL)
const seekTo = useSeekTo()
const flatListRef = useRef<FlatList<ParsedLyricLine>>(null)
const viewportHeightRef = useRef(height)
const isInitialMountRef = useRef(true)
const itemHeightsRef = useRef<Record<number, number>>({})
const currentLineIndex = useSharedValue(-1)
const scrollY = useSharedValue(0)
const isUserScrolling = useSharedValue(false)
const color = theme.color.val
const handleFlatListLayout = useCallback(
(e: { nativeEvent: { layout: { height: number } } }) => {
viewportHeightRef.current = e.nativeEvent.layout.height
},
[],
)
// Convert lyrics from ticks to seconds and parse
const parsedLyrics = useMemo<ParsedLyricLine[]>(() => {
if (!lyrics) return []
@@ -204,6 +245,17 @@ export default function Lyrics({
[parsedLyrics],
)
// Delay showing "No lyrics available" to avoid flash during track transitions
const [showNoLyricsMessage, setShowNoLyricsMessage] = useState(false)
useEffect(() => {
if (parsedLyrics.length > 0) {
setShowNoLyricsMessage(false)
return
}
const timer = setTimeout(() => setShowNoLyricsMessage(true), 3000)
return () => clearTimeout(timer)
}, [parsedLyrics.length])
// Track manually selected lyric for immediate feedback
const manuallySelectedIndex = useSharedValue(-1)
const manualSelectTimeout = useRef<NodeJS.Timeout | null>(null)
@@ -230,48 +282,94 @@ export default function Lyrics({
return found
}, [position, lyricStartTimes])
// Simple auto-scroll that keeps highlighted lyric in center
const scrollToCurrentLyric = useCallback(() => {
if (
currentLyricIndex >= 0 &&
currentLyricIndex < parsedLyrics.length &&
flatListRef.current &&
!isUserScrolling.value
) {
try {
// Use scrollToIndex with viewPosition 0.5 to center the lyric
flatListRef.current.scrollToIndex({
index: currentLyricIndex,
animated: true,
viewPosition: 0.5, // 0.5 = center of visible area
})
} catch (error) {
// Fallback to scrollToOffset if scrollToIndex fails
console.warn('scrollToIndex failed, using fallback')
const estimatedItemHeight = 80
const targetOffset = Math.max(
0,
currentLyricIndex * estimatedItemHeight - height * 0.4,
)
const ESTIMATED_ITEM_HEIGHT = 70
const CONTENT_PADDING_TOP = height * 0.1
flatListRef.current.scrollToOffset({
offset: targetOffset,
animated: true,
})
const pendingScrollOffsetRef = useRef<number | null>(null)
const getItemHeight = useCallback((index: number) => {
return itemHeightsRef.current[index] ?? ESTIMATED_ITEM_HEIGHT
}, [])
const getItemCenterY = useCallback(
(index: number) => {
let offset = CONTENT_PADDING_TOP
for (let i = 0; i < index; i++) {
offset += getItemHeight(i)
}
}
}, [currentLyricIndex, parsedLyrics.length, height])
return offset + getItemHeight(index) / 2
},
[CONTENT_PADDING_TOP, getItemHeight],
)
const onItemLayout = useCallback((index: number, itemHeight: number) => {
itemHeightsRef.current[index] = itemHeight
}, [])
// On mount: scroll to center current line. Otherwise: only scroll when current line is within center 75%
useEffect(() => {
// Only update if there's no manual selection active
if (manuallySelectedIndex.value === -1) {
currentLineIndex.value = withTiming(currentLyricIndex, { duration: 300 })
}
// Delay scroll to allow for smooth animation
const scrollTimeout = setTimeout(scrollToCurrentLyric, 100)
if (
currentLyricIndex < 0 ||
currentLyricIndex >= parsedLyrics.length ||
!flatListRef.current ||
isUserScrolling.value
) {
return
}
const forceScroll = isInitialMountRef.current
if (!forceScroll) {
// Center 75% check: only scroll when current line is within center 75% of viewport
const viewportHeight = viewportHeightRef.current
const currentScrollY = scrollY.value
const center75Top = currentScrollY + viewportHeight * 0.125
const center75Bottom = currentScrollY + viewportHeight * 0.875
const currentLineCenter = getItemCenterY(currentLyricIndex)
const isInCenter75 =
currentLineCenter >= center75Top && currentLineCenter <= center75Bottom
if (!isInCenter75) return
}
const viewportHeight = viewportHeightRef.current
const itemCenterY = getItemCenterY(currentLyricIndex)
const targetOffset = Math.max(0, itemCenterY - viewportHeight / 2)
const doScroll = () => {
if (!flatListRef.current) return
if (forceScroll) isInitialMountRef.current = false
pendingScrollOffsetRef.current = null
flatListRef.current.scrollToOffset({
offset: targetOffset,
animated: true,
})
}
if (forceScroll) {
pendingScrollOffsetRef.current = targetOffset
}
const scrollTimeout = setTimeout(doScroll, 300)
return () => clearTimeout(scrollTimeout)
}, [currentLyricIndex, scrollToCurrentLyric])
}, [currentLyricIndex, parsedLyrics.length, height, getItemCenterY])
// When track changes (next song), scroll to top
const prevTrackIdRef = useRef<string | undefined>(undefined)
useEffect(() => {
const trackId = nowPlaying?.item?.Id
if (prevTrackIdRef.current !== undefined && prevTrackIdRef.current !== trackId) {
if (flatListRef.current && parsedLyrics.length) {
flatListRef.current.scrollToOffset({ offset: 0, animated: false })
}
isInitialMountRef.current = true
}
prevTrackIdRef.current = trackId
itemHeightsRef.current = {}
}, [nowPlaying?.item?.Id, parsedLyrics.length])
// Reset manual selection when the actual position catches up
useEffect(() => {
@@ -319,6 +417,27 @@ export default function Lyrics({
}
}, [])
// Find lyric index for a given playback position (same logic as currentLyricIndex)
const findLyricIndexForPosition = useCallback(
(pos: number) => {
if (lyricStartTimes.length === 0) return -1
let low = 0
let high = lyricStartTimes.length - 1
let found = -1
while (low <= high) {
const mid = Math.floor((low + high) / 2)
if (pos >= lyricStartTimes[mid]) {
found = mid
low = mid + 1
} else {
high = mid - 1
}
}
return found
},
[lyricStartTimes],
)
// Scroll to specific lyric keeping it centered
const scrollToLyric = useCallback(
(lyricIndex: number) => {
@@ -333,11 +452,8 @@ export default function Lyrics({
} catch (error) {
// Fallback to scrollToOffset if scrollToIndex fails
console.warn('scrollToIndex failed, using fallback')
const estimatedItemHeight = 80
const targetOffset = Math.max(
0,
lyricIndex * estimatedItemHeight - height * 0.4,
)
const itemCenterY = getItemCenterY(lyricIndex)
const targetOffset = Math.max(0, itemCenterY - viewportHeightRef.current / 2)
flatListRef.current.scrollToOffset({
offset: targetOffset,
@@ -346,7 +462,7 @@ export default function Lyrics({
}
}
},
[parsedLyrics.length, height],
[parsedLyrics.length, height, getItemCenterY],
)
// Handle seeking to specific lyric timestamp
@@ -382,10 +498,13 @@ export default function Lyrics({
)
// Handle back navigation
const handleBackPress = useCallback(() => {
trigger('impactLight') // Haptic feedback for navigation
navigation.goBack()
}, [navigation])
const handleBackPress = useCallback(
(triggerHaptic: boolean | undefined = true) => {
if (triggerHaptic) trigger('impactLight') // Haptic feedback for navigation
navigation.goBack()
},
[navigation],
)
// Optimized render item for FlatList
const renderLyricItem: ListRenderItem<ParsedLyricLine> = useCallback(
@@ -394,46 +513,65 @@ export default function Lyrics({
<LyricLineItem
item={item}
index={index}
onLayout={onItemLayout}
currentLineIndex={currentLineIndex}
onPress={handleLyricPress}
/>
)
},
[currentLineIndex, handleLyricPress],
[currentLineIndex, handleLyricPress, onItemLayout],
)
// Removed getItemLayout to prevent crashes with dynamic content heights
const contentPaddingTop = height * 0.1
const getItemOffset = useCallback(
(index: number) => {
let offset = contentPaddingTop
for (let i = 0; i < index; i++) {
offset += getItemHeight(i)
}
return offset
},
[contentPaddingTop, getItemHeight],
)
const getItemLayout = useCallback(
(_: unknown, index: number) => ({
length: getItemHeight(index),
offset: getItemOffset(index),
index,
}),
[getItemHeight, getItemOffset],
)
const handleContentSizeChange = useCallback((_w: number, contentHeight: number) => {
const pending = pendingScrollOffsetRef.current
const viewportHeight = viewportHeightRef.current
// Content must be tall enough to scroll to target (max offset = contentHeight - viewportHeight)
if (pending !== null && flatListRef.current && contentHeight >= pending + viewportHeight) {
pendingScrollOffsetRef.current = null
isInitialMountRef.current = false
flatListRef.current.scrollToOffset({
offset: pending,
animated: true,
})
}
}, [])
const keyExtractor = useCallback(
(item: ParsedLyricLine, index: number) => `lyric-${index}-${item.startTime}`,
[],
)
if (!parsedLyrics.length) {
return (
<SafeAreaView style={{ flex: 1 }}>
<View flex={1}>
<ZStack fullscreen>
<BlurredBackground />
<YStack fullscreen justifyContent='center' alignItems='center'>
<Text fontSize={18} color='$neutral' textAlign='center'>
No lyrics available
</Text>
</YStack>
</ZStack>
</View>
</SafeAreaView>
)
}
const blockSwipeGesture = Gesture.Pan().minDistance(0)
return (
<SafeAreaView style={{ flex: 1 }}>
<SafeAreaView style={{ flex: 1 }} edges={['top']}>
<View flex={1}>
<ZStack fullscreen>
<BlurredBackground />
<YStack fullscreen>
{/* Header with back button */}
<XStack
alignItems='center'
justifyContent='space-between'
@@ -443,46 +581,97 @@ export default function Lyrics({
>
<XStack
alignItems='center'
onPress={handleBackPress}
onPress={() => handleBackPress()}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon small name='chevron-left' />
</XStack>
<YStack>
<Text
fontSize={16}
fontWeight='bold'
color={color}
textAlign='center'
>
{nowPlaying?.item?.Name}
</Text>
<Text fontSize={14} color={color} textAlign='center'>
{nowPlaying?.item?.ArtistItems?.map(
(artist) => artist.Name,
).join(', ')}
</Text>
</YStack>
<Spacer width={28} /> {/* Balance the layout */}
</XStack>
<AnimatedFlatList
ref={flatListRef}
data={parsedLyrics}
renderItem={renderLyricItem}
keyExtractor={keyExtractor}
onScroll={scrollHandler}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: height * 0.1,
paddingBottom: height * 0.5,
}}
style={{ flex: 1 }}
removeClippedSubviews={false}
maxToRenderPerBatch={15}
windowSize={15}
initialNumToRender={15}
onScrollToIndexFailed={(error) => {
console.warn('ScrollToIndex failed:', error)
// Fallback to scrollToOffset
if (flatListRef.current) {
const targetOffset = Math.max(
0,
error.index * 80 - height * 0.4,
)
flatListRef.current.scrollToOffset({
offset: targetOffset,
animated: true,
})
}
}}
/>
{parsedLyrics.length > 0 ? (
<AnimatedFlatList
ref={flatListRef}
data={parsedLyrics}
renderItem={renderLyricItem}
keyExtractor={keyExtractor}
getItemLayout={getItemLayout}
onLayout={handleFlatListLayout}
onContentSizeChange={handleContentSizeChange}
onScroll={scrollHandler}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: height * 0.1,
paddingBottom: height * 0.5 + 100, // Extra for miniplayer overlay
}}
style={{ flex: 1 }}
removeClippedSubviews={false}
maxToRenderPerBatch={15}
windowSize={15}
initialNumToRender={15}
onScrollToIndexFailed={(error) => {
console.warn('ScrollToIndex failed:', error)
// Fallback to scrollToOffset
if (flatListRef.current) {
const itemCenterY = getItemCenterY(error.index)
const targetOffset = Math.max(
0,
itemCenterY - viewportHeightRef.current / 2,
)
flatListRef.current.scrollToOffset({
offset: targetOffset,
animated: true,
})
}
}}
/>
) : (
<YStack justifyContent='center' alignItems='center' flex={1}>
{showNoLyricsMessage && (
<Text fontSize={18} color='$color' textAlign='center'>
No lyrics available
</Text>
)}
</YStack>
)}
<GestureDetector gesture={blockSwipeGesture}>
<YStack
justifyContent='flex-start'
gap={'$3'}
flexShrink={1}
padding='$5'
paddingBottom='$7'
>
<Scrubber
onSeekComplete={(position) => {
const index = findLyricIndexForPosition(position)
if (index >= 0) {
currentLineIndex.value = withTiming(index, {
duration: 200,
})
scrollToLyric(index)
}
}}
/>
<Controls onLyricsScreen />
</YStack>
</GestureDetector>
</YStack>
</ZStack>
</View>
+12 -3
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { getTokenValue, Spacer, Text, useTheme, XStack, YStack } from 'tamagui'
import { useSeekTo } from '../../../hooks/player/callbacks'
import {
@@ -15,7 +15,11 @@ import { runOnJS } from 'react-native-worklets'
import Slider from '@jellify-music/react-native-reanimated-slider'
import { triggerHaptic } from '../../../hooks/use-haptic-feedback'
export default function Scrubber(): React.JSX.Element {
interface ScrubberProps {
onSeekComplete?: (position: number) => void
}
export default function Scrubber({ onSeekComplete }: ScrubberProps = {}): React.JSX.Element {
const seekTo = useSeekTo()
const nowPlaying = useCurrentTrack()
@@ -67,6 +71,11 @@ export default function Scrubber(): React.JSX.Element {
},
)
const handleValueChange = async (value: number) => {
await seekTo(value)
onSeekComplete?.(value)
}
return (
<YStack alignItems='stretch' gap={'$3'}>
<Slider
@@ -74,7 +83,7 @@ export default function Scrubber(): React.JSX.Element {
maxValue={duration}
backgroundColor={theme.neutral.val}
color={theme.primary.val}
onValueChange={seekTo}
onValueChange={handleValueChange}
thumbWidth={getTokenValue('$3')}
trackHeight={getTokenValue('$2')}
gestureActiveRef={isSeeking}
+24 -8
View File
@@ -31,7 +31,6 @@ export default function Miniplayer(): React.JSX.Element {
const skip = useSkip()
const previous = usePrevious()
const theme = useTheme()
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
const translateX = useSharedValue(0)
@@ -73,12 +72,28 @@ export default function Miniplayer(): React.JSX.Element {
}
})
const openPlayer = () => navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' })
const openPlayer = () => {
navigation.navigate('PlayerRoot', { screen: 'PlayerScreen' })
}
const pressStyle = {
opacity: 0.6,
}
// Guard: during track transitions nowPlaying can be briefly null
if (!nowPlaying?.item) {
return (
<YStack
backgroundColor={theme.background.val}
padding={'$2'}
alignItems='center'
justifyContent='center'
>
<Text> </Text>
</YStack>
)
}
return (
<GestureDetector gesture={gesture}>
<Animated.View
@@ -99,10 +114,10 @@ export default function Miniplayer(): React.JSX.Element {
<Animated.View
entering={FadeIn.easing(Easing.in(Easing.ease))}
exiting={FadeOut.easing(Easing.out(Easing.ease))}
key={`${nowPlaying!.item.AlbumId}-album-image`}
key={`${nowPlaying.item.AlbumId}-album-image`}
>
<ItemImage
item={nowPlaying!.item}
item={nowPlaying.item}
width={'$11'}
height={'$11'}
imageOptions={{ maxWidth: 120, maxHeight: 120 }}
@@ -119,15 +134,15 @@ export default function Miniplayer(): React.JSX.Element {
<Animated.View
entering={FadeIn.easing(Easing.in(Easing.ease))}
exiting={FadeOut.easing(Easing.out(Easing.ease))}
key={`${nowPlaying!.item.Id}-mini-player-song-info`}
key={`${nowPlaying.item.Id}-mini-player-song-info`}
>
<TextTicker {...TextTickerConfig}>
<Text bold>{nowPlaying?.title ?? 'Nothing Playing'}</Text>
<Text bold>{nowPlaying.title ?? 'Nothing Playing'}</Text>
</TextTicker>
<TextTicker {...TextTickerConfig}>
<Text height={'$0.5'}>
{nowPlaying?.artist ?? 'Unknown Artist'}
{nowPlaying.artist ?? 'Unknown Artist'}
</Text>
</TextTicker>
</Animated.View>
@@ -163,5 +178,6 @@ function MiniPlayerProgress(): React.JSX.Element {
}
function calculateProgressPercentage(progress: TrackPlayerProgress | undefined): number {
return Math.round((progress!.position / progress!.duration) * 100)
if (!progress || progress.duration <= 0) return 0
return Math.round((progress.position / progress.duration) * 100)
}
+19 -29
View File
@@ -1,11 +1,8 @@
import React, { useState } from 'react'
import React, { useEffect, useState } from 'react'
import Input from '../Global/helpers/input'
import { H5, Text } from '../Global/helpers/text'
import ItemRow from '../Global/components/item-row'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { QueryKeys } from '../../enums/query-keys'
import { fetchSearchResults } from '../../api/queries/search'
import { useQuery } from '@tanstack/react-query'
import { getToken, H3, Spinner, YStack } from 'tamagui'
import Suggestions from './suggestions'
import { isEmpty } from 'lodash'
@@ -13,7 +10,6 @@ import HorizontalCardList from '../Global/components/horizontal-list'
import ItemCard from '../Global/components/item-card'
import SearchParamList from '../../screens/Search/types'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
import { getApi, getUser, useJellifyLibrary } from '../../stores'
import { FlashList } from '@shopify/flash-list'
import navigationRef from '../../../navigation'
import { StackActions } from '@react-navigation/native'
@@ -22,41 +18,35 @@ import Track from '../Global/components/Track'
import { pickRandomItemFromArray } from '../../utils/parsing/random'
import { SEARCH_PLACEHOLDERS } from '../../configs/placeholder.config'
import { formatArtistName } from '../../utils/formatting/artist-names'
import useSearchResults from '../../api/queries/search'
export default function Search({
navigation,
}: {
navigation: NativeStackNavigationProp<SearchParamList, 'SearchScreen'>
}): React.JSX.Element {
const api = getApi()
const user = getUser()
const [library] = useJellifyLibrary()
/**
* Raw text input value from the user, updates immediately as they type
*/
const [inputValue, setInputValue] = useState<string | undefined>(undefined)
/**
* Debounced search string that updates 500ms after the user stops typing, used to trigger the search query
* which is keyed off of this value for caching.
*/
const [searchString, setSearchString] = useState<string | undefined>(undefined)
const {
data: items,
refetch,
isFetching: fetchingResults,
} = useQuery({
queryKey: [QueryKeys.Search, library?.musicLibraryId, searchString],
queryFn: () => fetchSearchResults(api, user, library?.musicLibraryId, searchString),
})
useEffect(() => {
const timeout = setTimeout(() => {
setSearchString(inputValue || undefined)
}, 500)
return () => clearTimeout(timeout)
}, [inputValue])
const search = () => {
let timeout: ReturnType<typeof setTimeout>
return () => {
clearTimeout(timeout)
timeout = setTimeout(() => {
refetch()
}, 1000)
}
}
const { data: items, isFetching: fetchingResults } = useSearchResults(searchString)
const handleSearchStringUpdate = (value: string | undefined) => {
setSearchString(value)
search()
setInputValue(value || undefined)
}
const handleScrollBeginDrag = () => {
@@ -90,7 +80,7 @@ export default function Search({
<Input
placeholder={placeholder}
onChangeText={handleSearchStringUpdate}
value={searchString}
value={inputValue}
testID='search-input'
clearButtonMode='always'
/>
-1
View File
@@ -20,7 +20,6 @@ const TRACK_SORT_OPTIONS: { value: ItemSortBy; label: string }[] = [
const ALBUM_SORT_OPTIONS: { value: ItemSortBy; label: string }[] = [
{ value: ItemSortBy.SortName, label: 'Album' },
{ value: ItemSortBy.Artist, label: 'Artist' },
{ value: ItemSortBy.PlayCount, label: 'Play Count' },
{ value: ItemSortBy.DateCreated, label: 'Date Added' },
{ value: ItemSortBy.PremiereDate, label: 'Release Date' },
]
+1
View File
@@ -96,6 +96,7 @@ export default function Tracks({
queue={queue}
sortingByAlbum={sortBy === ItemSortBy.Album}
sortingByReleasedDate={sortBy === ItemSortBy.PremiereDate}
sortingByPlayCount={sortBy === ItemSortBy.PlayCount}
/>
) : (
<ItemRow navigation={navigation} item={track} />
+1 -1
View File
@@ -2,7 +2,7 @@ import Config from 'react-native-superconfig'
const OTA_UPDATE_ENABLED = Config.OTA_UPDATE_ENABLED === 'true'
const IS_MAESTRO_BUILD = Config.IS_MAESTRO_BUILD === 'true'
const GLITCHTIP_DSN = Config.GLITCHTIP_DSN
const GLITCHTIP_DSN = Config.GLITCHTIP_DSN ?? ''
export { OTA_UPDATE_ENABLED, IS_MAESTRO_BUILD, GLITCHTIP_DSN }
+26
View File
@@ -67,6 +67,8 @@ export async function handleShuffle(keepCurrentTrack: boolean = true): Promise<J
const isDownloaded = filters.isDownloaded === true
const isUnplayed = filters.isUnplayed === true
const genreIds = filters.genreIds
const yearMin = filters.yearMin
const yearMax = filters.yearMax
let randomTracks: JellifyTrack[] = []
@@ -87,6 +89,16 @@ export async function handleShuffle(keepCurrentTrack: boolean = true): Promise<J
// Filter downloaded tracks
let filteredDownloads = downloadedTracks
// Filter by year range
if (yearMin != null || yearMax != null) {
const min = yearMin ?? 0
const max = yearMax ?? new Date().getFullYear()
filteredDownloads = filteredDownloads.filter((download) => {
const y = download.item.ProductionYear
return y != null && y >= min && y <= max
})
}
// Filter by favorites
if (isFavorites) {
filteredDownloads = filteredDownloads.filter((download) => {
@@ -118,6 +130,19 @@ export async function handleShuffle(keepCurrentTrack: boolean = true): Promise<J
apiFilters.push(ItemFilter.IsUnplayed)
}
// Build years param for year range filter
const yearsParam =
yearMin != null || yearMax != null
? (() => {
const min = yearMin ?? 0
const max = yearMax ?? new Date().getFullYear()
if (min > max) return undefined
const years: string[] = []
for (let y = min; y <= max; y++) years.push(String(y))
return years.length > 0 ? years : undefined
})()
: undefined
// Fetch random tracks from Jellyfin with filters
const data = await nitroFetch<{ Items: BaseItemDto[] }>(api, '/Items', {
ParentId: library.musicLibraryId,
@@ -127,6 +152,7 @@ export async function handleShuffle(keepCurrentTrack: boolean = true): Promise<J
SortBy: [ItemSortBy.Random],
Filters: apiFilters.length > 0 ? apiFilters : undefined,
GenreIds: genreIds && genreIds.length > 0 ? genreIds : undefined,
Years: yearsParam,
Limit: ApiLimits.LibraryShuffle,
Fields: [
ItemFields.MediaSources,
+39
View File
@@ -94,6 +94,44 @@ export default function GenreSelectionScreen({
})
}, [triggerHaptic])
const allLoadedGenreIds = useMemo(
() => genres?.map((g) => g.Id!).filter(Boolean) ?? [],
[genres],
)
const allSelected =
allLoadedGenreIds.length > 0 && selectedGenreIds.length === allLoadedGenreIds.length
const handleSelectAll = useCallback(() => {
triggerHaptic('impactLight')
setSelectedGenreIds([...allLoadedGenreIds])
}, [allLoadedGenreIds, triggerHaptic])
const renderListHeader = useCallback(
() => (
<XStack
alignItems='center'
padding='$3'
gap='$3'
pressStyle={{ opacity: 0.6 }}
animation='quick'
onPress={handleSelectAll}
backgroundColor='$backgroundHover'
>
<YStack flex={1}>
<Text bold>Select all</Text>
{genres != null && (
<Text color='$borderColor'>{`${allLoadedGenreIds.length} genres`}</Text>
)}
</YStack>
<Icon
name={allSelected ? 'check-circle-outline' : 'circle-outline'}
color={allSelected ? '$primary' : '$borderColor'}
/>
</XStack>
),
[handleSelectAll, allSelected, allLoadedGenreIds.length, genres],
)
const renderItem: ListRenderItem<BaseItemDto | string> = ({ item }) => {
if (typeof item === 'string') {
// Section header
@@ -181,6 +219,7 @@ export default function GenreSelectionScreen({
data={flattenedGenres}
renderItem={renderItem}
keyExtractor={keyExtractor}
ListHeaderComponent={renderListHeader}
// @ts-expect-error - estimatedItemSize is required by FlashList but types are incorrect
estimatedItemSize={70}
onEndReached={() => {
+1
View File
@@ -1,4 +1,5 @@
import React, { useEffect } from 'react'
import { View } from 'react-native'
import PlayerScreen from '../../components/Player'
import Queue from '../../components/Queue'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
+295
View File
@@ -0,0 +1,295 @@
import React, { useCallback, useMemo, useState } from 'react'
import { YStack, XStack, Button, Spinner } from 'tamagui'
import { Modal, ScrollView, Pressable } from 'react-native'
import { Text } from '../../components/Global/helpers/text'
import Icon from '../../components/Global/components/icon'
import { triggerHaptic } from '../../hooks/use-haptic-feedback'
import { YearSelectionProps } from '../types'
import useLibraryStore from '../../stores/library'
import { useLibraryYears } from '../../api/queries/years'
const ANY = 'any'
type Picking = 'min' | 'max' | null
export default function YearSelectionScreen({
navigation,
route,
}: YearSelectionProps): React.JSX.Element {
const tab = route.params?.tab ?? 'Tracks'
const { years: availableYears, isPending, isError } = useLibraryYears()
const storeFilters = useLibraryStore.getState().filters[tab === 'Albums' ? 'albums' : 'tracks']
const [minYear, setMinYear] = useState<number | typeof ANY>(storeFilters.yearMin ?? ANY)
const [maxYear, setMaxYear] = useState<number | typeof ANY>(storeFilters.yearMax ?? ANY)
const [picking, setPicking] = useState<Picking>(null)
// Min year options: if maxYear is set, only years <= maxYear
const minYearOptions = useMemo(() => {
if (availableYears.length === 0) return []
const max = typeof maxYear === 'number' ? maxYear : Math.max(...availableYears)
return availableYears.filter((y) => y <= max)
}, [availableYears, maxYear])
// Max year options: if minYear is set, only years >= minYear
const maxYearOptions = useMemo(() => {
if (availableYears.length === 0) return []
const min = typeof minYear === 'number' ? minYear : Math.min(...availableYears)
return availableYears.filter((y) => y >= min)
}, [availableYears, minYear])
const handleOpenMin = useCallback(() => {
triggerHaptic('impactLight')
setPicking('min')
}, [])
const handleOpenMax = useCallback(() => {
triggerHaptic('impactLight')
setPicking('max')
}, [])
const handleSelectMin = useCallback(
(year: number | typeof ANY) => {
triggerHaptic('impactLight')
setMinYear(year)
setPicking(null)
if (year !== ANY && typeof maxYear === 'number' && year > maxYear) {
setMaxYear(year)
}
},
[maxYear],
)
const handleSelectMax = useCallback(
(year: number | typeof ANY) => {
triggerHaptic('impactLight')
setMaxYear(year)
setPicking(null)
if (year !== ANY && typeof minYear === 'number' && year < minYear) {
setMinYear(year)
}
},
[minYear],
)
const handleSave = useCallback(() => {
triggerHaptic('impactLight')
const payload = {
yearMin: minYear === ANY ? undefined : minYear,
yearMax: maxYear === ANY ? undefined : maxYear,
}
if (tab === 'Albums') {
useLibraryStore.getState().setAlbumsFilters(payload)
} else {
useLibraryStore.getState().setTracksFilters(payload)
}
navigation.goBack()
}, [minYear, maxYear, navigation, tab])
const handleClear = useCallback(() => {
triggerHaptic('impactLight')
setMinYear(ANY)
setMaxYear(ANY)
const payload = { yearMin: undefined, yearMax: undefined }
if (tab === 'Albums') {
useLibraryStore.getState().setAlbumsFilters(payload)
} else {
useLibraryStore.getState().setTracksFilters(payload)
}
}, [tab])
const hasSelection = minYear !== ANY || maxYear !== ANY
const rangeLabel =
minYear !== ANY || maxYear !== ANY
? `${minYear === ANY ? '…' : minYear} ${maxYear === ANY ? '…' : maxYear}`
: null
const minLabel = minYear === ANY ? 'Any' : String(minYear)
const maxLabel = maxYear === ANY ? 'Any' : String(maxYear)
if (isPending && availableYears.length === 0) {
return (
<YStack flex={1} alignItems='center' justifyContent='center'>
<Spinner size='large' />
</YStack>
)
}
if (isError) {
return (
<YStack flex={1} alignItems='center' justifyContent='center' padding='$4'>
<Text color='$borderColor'>Could not load years</Text>
<Button marginTop='$4' onPress={() => navigation.goBack()}>
Go back
</Button>
</YStack>
)
}
const pickerOptions = picking === 'min' ? minYearOptions : maxYearOptions
const onSelectOption = picking === 'min' ? handleSelectMin : handleSelectMax
const currentValue = picking === 'min' ? minYear : maxYear
return (
<YStack flex={1} backgroundColor='$background'>
<XStack
justifyContent='space-between'
alignItems='center'
padding='$4'
borderBottomWidth={1}
borderBottomColor='$borderColor'
>
<Button variant='outlined' size='$3' onPress={() => navigation.goBack()}>
Cancel
</Button>
<Text bold fontSize='$6'>
Year range
</Text>
<Button variant='outlined' size='$3' onPress={handleClear} disabled={!hasSelection}>
Clear
</Button>
</XStack>
<YStack flex={1} padding='$4' gap='$2'>
<Text bold fontSize='$5' marginBottom='$2'>
Min year
</Text>
<Pressable onPress={handleOpenMin}>
<XStack
alignItems='center'
justifyContent='space-between'
padding='$3'
backgroundColor='$backgroundHover'
borderRadius='$2'
borderWidth={1}
borderColor='$borderColor'
>
<Text>{minLabel}</Text>
<Icon name='chevron-down' color='$borderColor' />
</XStack>
</Pressable>
<Text bold fontSize='$5' marginBottom='$2' marginTop='$3'>
Max year
</Text>
<Pressable onPress={handleOpenMax}>
<XStack
alignItems='center'
justifyContent='space-between'
padding='$3'
backgroundColor='$backgroundHover'
borderRadius='$2'
borderWidth={1}
borderColor='$borderColor'
>
<Text>{maxLabel}</Text>
<Icon name='chevron-down' color='$borderColor' />
</XStack>
</Pressable>
</YStack>
{/* Dropdown picker modal */}
<Modal
visible={picking !== null}
transparent
animationType='fade'
onRequestClose={() => setPicking(null)}
>
<Pressable
style={{
flex: 1,
justifyContent: 'flex-end',
backgroundColor: 'rgba(0,0,0,0.5)',
}}
onPress={() => setPicking(null)}
>
<Pressable style={{ maxHeight: '100%' }} onPress={(e) => e.stopPropagation()}>
<YStack
backgroundColor='$background'
borderTopLeftRadius='$4'
borderTopRightRadius='$4'
padding='$4'
maxHeight='100%'
>
<Text bold fontSize='$5' marginBottom='$3'>
{picking === 'min' ? 'Select min year' : 'Select max year'}
</Text>
<ScrollView
style={{ maxHeight: 330 }}
showsVerticalScrollIndicator
keyboardShouldPersistTaps='handled'
>
<Pressable
onPress={() => onSelectOption(ANY)}
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
>
<XStack
padding='$3'
alignItems='center'
backgroundColor={
currentValue === ANY
? '$backgroundHover'
: 'transparent'
}
borderRadius='$2'
>
<Text
fontWeight={currentValue === ANY ? 'bold' : undefined}
>
Any
</Text>
</XStack>
</Pressable>
{pickerOptions.map((y) => (
<Pressable
key={y}
onPress={() => onSelectOption(y)}
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
>
<XStack
padding='$3'
alignItems='center'
backgroundColor={
currentValue === y
? '$backgroundHover'
: 'transparent'
}
borderRadius='$2'
>
<Text
fontWeight={currentValue === y ? 'bold' : undefined}
>
{String(y)}
</Text>
</XStack>
</Pressable>
))}
</ScrollView>
</YStack>
</Pressable>
</Pressable>
</Modal>
{hasSelection && (
<XStack
justifyContent='space-evenly'
alignItems='center'
padding='$4'
borderTopWidth={1}
borderTopColor='$borderColor'
>
<Text fontSize='$3' bold color='$primary'>
{rangeLabel ?? ''}
</Text>
<Button
variant='outlined'
borderColor='$primary'
color='$primary'
size='$3'
onPress={handleSave}
>
Apply
</Button>
</XStack>
)}
</YStack>
)
}
+11
View File
@@ -19,6 +19,7 @@ import { formatArtistNames } from '../utils/formatting/artist-names'
import FiltersSheet from './Filters'
import SortOptionsSheet from './SortOptions'
import GenreSelectionScreen from './GenreSelection'
import YearSelectionScreen from './YearSelection'
const RootStack = createNativeStackNavigator<RootStackParamList>()
@@ -132,6 +133,16 @@ export default function Root(): React.JSX.Element {
sheetGrabberVisible: true,
}}
/>
<RootStack.Screen
name='YearSelection'
component={YearSelectionScreen}
options={{
headerTitle: 'Year range',
presentation: 'modal',
sheetGrabberVisible: true,
}}
/>
</RootStack.Navigator>
)
}
+2
View File
@@ -74,6 +74,7 @@ export type RootStackParamList = {
}
GenreSelection: undefined
YearSelection: { tab?: 'Tracks' | 'Albums' }
AudioSpecs: {
item: BaseItemDto
@@ -99,6 +100,7 @@ export type DeletePlaylistProps = NativeStackScreenProps<RootStackParamList, 'De
export type FiltersProps = NativeStackScreenProps<RootStackParamList, 'Filters'>
export type SortOptionsProps = NativeStackScreenProps<RootStackParamList, 'SortOptions'>
export type GenreSelectionProps = NativeStackScreenProps<RootStackParamList, 'GenreSelection'>
export type YearSelectionProps = NativeStackScreenProps<RootStackParamList, 'YearSelection'>
export type GenresProps = {
genres: InfiniteData<BaseItemDto[], unknown> | undefined
+6
View File
@@ -10,6 +10,8 @@ type TabFilterState = {
isDownloaded?: boolean // Only for Tracks tab
isUnplayed?: boolean // Only for Tracks tab
genreIds?: string[] // Only for Tracks tab
yearMin?: number // Tracks and Albums
yearMax?: number // Tracks and Albums
}
type SortState = Record<LibraryTab, ItemSortBy>
@@ -93,9 +95,13 @@ const useLibraryStore = create<LibraryStore>()(
isDownloaded: false,
isUnplayed: undefined,
genreIds: undefined,
yearMin: undefined,
yearMax: undefined,
},
albums: {
isFavorites: undefined,
yearMin: undefined,
yearMax: undefined,
},
artists: {
isFavorites: undefined,
+1
View File
@@ -16,6 +16,7 @@ export type BaseItemDtoSlimified = Pick<
| 'RunTimeTicks'
| 'OfficialRating'
| 'CustomRating'
| 'ProductionYear'
>
interface JellifyTrack extends Track {
-10
View File
@@ -1,10 +0,0 @@
declare module 'react-native-superconfig' {
export interface NativeConfig {
OTA_UPDATE_ENABLED?: string
IS_MAESTRO_BUILD?: string
GLITCHTIP_DSN?: string
}
export const Config: NativeConfig
export default Config
}
+8 -4
View File
@@ -1,9 +1,13 @@
export function formatArtistName(artistName: string | null | undefined): string {
if (!artistName) return 'Unknown Artist'
return artistName
export function formatArtistName(
artistName: string | null | undefined,
releaseDate?: string | null | undefined,
): string {
const unknownArtist = 'Unknown Artist'
if (!artistName) return releaseDate ? `${releaseDate}${unknownArtist}` : unknownArtist
return releaseDate ? `${releaseDate}${artistName}` : artistName
}
export function formatArtistNames(artistNames: string[] | null | undefined): string {
if (!artistNames || artistNames.length === 0) return 'Unknown Artist'
return artistNames.map(formatArtistName).join(' • ')
return artistNames.map((artistName) => formatArtistName(artistName)).join(' • ')
}
+9
View File
@@ -0,0 +1,9 @@
export default function buildYearsParam(yearMin?: number, yearMax?: number): string[] | undefined {
if (yearMin == null && yearMax == null) return undefined
const min = yearMin ?? 0
const max = yearMax ?? new Date().getFullYear()
if (min > max) return undefined
const years: string[] = []
for (let y = min; y <= max; y++) years.push(String(y))
return years.length > 0 ? years : undefined
}