feat: Nitro Fetch on Worklets Adapter, exclude CarPlay from Android Build, Fix Image URL Lookup Issues (#1141)

* feat: Nitro Fetch on Worklets Adapter. Nitro Fetch now parses API responses on a separate thread

* update image url unit tests

* fix image url tests

* additional image url lookup fixes

* fix: exclude CarPlay module from Android builds

* fix A-Z selector tracking being off on android, switch to pulsar for haptic feedback

* fix: glitchtip, telemetry deck to use superconfig

* update superconfig mocks

* update README

* bump nitro player, remove patch

adjust maestro test action
This commit is contained in:
Violet Caulfield
2026-04-26 12:54:33 -05:00
committed by GitHub
parent 6876e952ed
commit 39c19cf035
44 changed files with 900 additions and 526 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
OTA_UPDATE_ENABLED=true
IS_MAESTRO_BUILD = false
GLITCHTIP_DSN= ""
GLITCHTIP_DSN= ""
TELEMETRYDECK_APPID=00000000-0000-0000-0000-000000000000
+5 -6
View File
@@ -44,6 +44,11 @@ jobs:
with:
bun-version: 1.3.4
- name: 🟩 Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
- name: 📦 Cache node_modules
uses: actions/cache@v5
with:
@@ -86,12 +91,6 @@ jobs:
if: steps.apk-cache.outputs.cache-hit != 'true'
run: bun scripts/updateEnv.js OTA_UPDATE_ENABLED=false IS_MAESTRO_BUILD=true
- name: ✅ Validate Config Files
if: steps.apk-cache.outputs.cache-hit != 'true'
run: |
bun -p "JSON.parse(require('fs').readFileSync('telemetrydeck.json'))"
bun -p "JSON.parse(require('fs').readFileSync('glitchtip.json'))"
- name: 🚀 Build Android Debug APK
if: steps.apk-cache.outputs.cache-hit != 'true'
run: cd android && ./gradlew assembleDebug -PreactNativeArchitectures=x86_64
+5 -43
View File
@@ -141,29 +141,10 @@ jobs:
env:
PLAY_STORE_CREDENTIALS: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT }}
- name: 🤫 Output TelemetryDeck Secrets to TelemetryDeck.json
run: |
echo "{" > telemetrydeck.json
echo "\"appID\": \"${{ secrets.TELEMETRYDECK_APPID }}\"," >> telemetrydeck.json
echo "\"clientUser\": \"anonymous\"," >> telemetrydeck.json
echo "\"app\": \"Jellify\"" >> telemetrydeck.json
echo "}" >> telemetrydeck.json
- name: 🤫 Output Glitchtip Secrets to Glitchtip.json
run: |
echo "{" > glitchtip.json
echo "\"dsn\": \"${{ secrets.GLITCHTIP_DSN }}\"" >> glitchtip.json
echo "}" >> glitchtip.json
- name: 📝 Output Glitchip secrets to .env
- name: 🤫 Output secrets to .env
run: |
echo "GLITCHTIP_DSN=${{ secrets.GLITCHTIP_DSN }}" >> .env
- name: ✅ Validate Config Files
run: |
bun -p "JSON.parse(require('fs').readFileSync('telemetrydeck.json'))"
bun -p "JSON.parse(require('fs').readFileSync('glitchtip.json'))"
echo "TELEMETRYDECK_APPID=${{ secrets.TELEMETRYDECK_APPID }}" >> .env
- name: 🚀 Run Android fastlane deploy
run: bun run fastlane:android:deploy
@@ -217,29 +198,10 @@ jobs:
run: echo -e '${{ secrets.APPSTORE_CONNECT_API_KEY_JSON }}' > appstore_connect_api_key.json
working-directory: ./ios/fastlane
- name: 🤫 Output TelemetryDeck Secrets to TelemetryDeck.json
run: |
echo "{" > telemetrydeck.json
echo "\"appID\": \"${{ secrets.TELEMETRYDECK_APPID }}\"," >> telemetrydeck.json
echo "\"clientUser\": \"anonymous\"," >> telemetrydeck.json
echo "\"app\": \"Jellify\"" >> telemetrydeck.json
echo "}" >> telemetrydeck.json
- name: 🤫 Output Glitchtip Secrets to Glitchtip.json
run: |
echo "{" > glitchtip.json
echo "\"dsn\": \"${{ secrets.GLITCHTIP_DSN }}\"" >> glitchtip.json
echo "}" >> glitchtip.json
- name: 📝 Output Glitchip secrets to .env
- name: 🤫 Output secrets to .env
run: |
echo "GLITCHTIP_DSN=${{ secrets.GLITCHTIP_DSN }}" >> .env
- name: ✅ Validate Config Files
run: |
bun -p "JSON.parse(require('fs').readFileSync('telemetrydeck.json'))"
bun -p "JSON.parse(require('fs').readFileSync('glitchtip.json'))"
echo "TELEMETRYDECK_APPID=${{ secrets.TELEMETRYDECK_APPID }}" >> .env
- name: 🚀 Run iOS fastlane build and publish to TestFlight
run: bun run fastlane:ios:beta
@@ -365,4 +327,4 @@ jobs:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
APP_VERSION: ${{ needs.publish-ios.outputs.version || needs.publish-android.outputs.version }}
release_url: ${{ steps.githubRelease.outputs.html_url }}
RELEASE_NOTES: ${{ needs.generate-release-notes.outputs.release_notes }}
RELEASE_NOTES: ${{ needs.generate-release-notes.outputs.release_notes }}
+24 -11
View File
@@ -30,12 +30,15 @@ _Jellify_ is a free and open source music player for the [Jellyfin Media Server]
> _Jellify_ requires a connection to a [Jellyfin Media Server](https://jellyfin.org/) server to work. [See also](https://jellyfin.org/docs/)
Showcasing the artwork of your library, it has a user interface congruent to what _the big guys_ do. _Jellify_ also provides algorithmic curation of your music (not that you have to use _Jellify_ that way). It's designed to be lightweight, and scale to even the largest of music libraries (...like 100K tracks large).
Showcasing the artwork of your library, it has a user interface congruent to what _the big guys_ do. _Jellify_ also provides algorithmic curation of your music, driven by Jellyfin's Instant Mix and Suggestions APIs.
### Background
<details>
<summary>Background</summary>
This app was designed with me and my dad in mind. I wanted us to have a sleek, one stop shop for live recordings of bands we like (read: the Grateful Dead). The UI was designed so that we'd find it instantly familiar and useful. CarPlay / Android Auto support was also a must for us, as we both use CarPlay religiously.
</details>
### Recommended Additions
These projects are **not** required to use _Jellify_, but are recommended by us to enrich your Jellyfin music experience!
@@ -61,7 +64,7 @@ These projects are **not** required to use _Jellify_, but are recommended by us
### Android
[![Google Play](https://img.shields.io/badge/Google%20Play-Download-red?logo=googleplay&logoColor=white)](https://play.google.com/store/apps/details?id=com.cosmonautical.jellify)
Download from [![Google Play](https://img.shields.io/badge/Google%20Play-Download-red?logo=googleplay&logoColor=white)](https://play.google.com/store/apps/details?id=com.cosmonautical.jellify)
#### Direct .APK Download
@@ -73,7 +76,7 @@ For Obtanium, click "Add App", put "https://github.com/Jellify-Music/App" as the
### iOS
[![iTunes App Store](https://img.shields.io/itunes/v/6736884612?logo=app-store&logoColor=white&label=Apple%20App%20Store&labelColor=%60&color=blue)](https://apps.apple.com/us/app/jellify/id6736884612)
Download from the [![iTunes App Store](https://img.shields.io/itunes/v/6736884612?logo=app-store&logoColor=white&label=Apple%20App%20Store&labelColor=%60&color=blue)](https://apps.apple.com/us/app/jellify/id6736884612)
#### The TestFlight Way
@@ -215,7 +218,7 @@ Install via [Altstore](https://altstore.io) or your favorite sideloading utility
## Built with Good Stuff
[![Made with React](https://img.shields.io/badge/React-19-blue?logo=react)](https://reactjs.org) [![React Native](https://img.shields.io/badge/React-Native-079?logo=react)](https://reactnative.dev) [![Made with TypeScript](https://img.shields.io/badge/TypeScript-5-blue?logo=typescript&logoColor=white)](https://typescriptlang.org) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![GitHub License](https://img.shields.io/github/license/anultravioletaurora/jellify?color=indigo)](https://github.com/anultravioletaurora/jellify/blob/main/LICENSE)
[![Made with React](https://img.shields.io/badge/React-19-blue?logo=react)](https://reactjs.org) [![React Native](https://img.shields.io/badge/React-Native-079?logo=react)](https://reactnative.dev) [![Made with TypeScript](https://img.shields.io/badge/TypeScript-6-blue?logo=typescript&logoColor=white)](https://typescriptlang.org) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![GitHub License](https://img.shields.io/github/license/anultravioletaurora/jellify?color=indigo)](https://github.com/anultravioletaurora/jellify/blob/main/LICENSE)
### Frontend
@@ -249,15 +252,21 @@ Install via [Altstore](https://altstore.io) or your favorite sideloading utility
### Opt-In Monitoring
All logging and metrics gathering is _opt-in_ __by default__. This is merely here to help us make _Jellify_ better.
All logging and metrics gathering is _opt-in_ **by default**. This is merely here to help us make _Jellify_ better.
All logs and metrics are completely anonymized. No data can be traced back to you.
[GlitchTip](https://glitchtip.com/)
#### Error Reporting — [GlitchTip](https://glitchtip.com/)
- [See logging statements](https://github.com/search?q=repo%3AJellify-Music%2FApp+console.&type=code&p=1)
Unhandled exceptions and captured errors are reported to the SaaS [GlitchTip](https://glitchtip.com/) instance via [`@sentry/react-native`](https://github.com/getsentry/sentry-react-native). Error reporting is initialized at app startup and is **disabled** unless you have opted in to sending metrics.
[TelemetryDeck](https://telemetrydeck.com)
Errors are captured through a shared `captureError` utility ([`src/utils/logging/index.ts`](src/utils/logging/index.ts)) and tagged with a context (e.g. `Initialization`, `Playback Reporting`, `Nitro Fetch`).
In production builds, all `console.*` methods are replaced with no-ops so that no debug output leaks — errors only surface through the explicit `captureError` call path.
#### Usage Analytics — [TelemetryDeck](https://telemetrydeck.com)
Anonymous usage signals are sent via [`@typedigital/telemetrydeck-react`](https://github.com/typedigital/telemetrydeck-react). The only signal currently sent is `Jellify launched` on app open. All signals are anonymous (`clientUser: 'anonymous'`) and are only sent when you have opted in to metrics.
### Love from Wisconsin 🧀
@@ -265,9 +274,13 @@ This is undoubtedly a passion project of [mine](https://github.com/anultraviolet
## Support the Project
You can support _Jellify_ development via [Patreon](https://patreon.com/anultravioletaurora) or [GitHub Sponsors](https://github.com/sponsors/anultravioletaurora) starting at $1.
_Jellify_ is free, open source, and always will be. You can support development via [Patreon](https://patreon.com/anultravioletaurora), [GitHub Sponsors](https://github.com/sponsors/anultravioletaurora), or [Ko-Fi](https://ko-fi.com/jellify) starting at $1.
Paid supporters will be recognized by having their name displayed within the Settings.
All publicly paid supporters on GitHub and Patreon get their name displayed in the app's Settings as a thank-you.
### 🎁 Sticker Club
Patreon supporters on the **$5 or $10 tier** who stay subscribed for **3 months** get a _Jellify_ sticker mailed to them. A small, physical thank-you for supporting a passion project.
## Special Thanks
+8 -18
View File
@@ -41,10 +41,10 @@
"react-native-nitro-fetch": "1.0.3",
"react-native-nitro-modules": "0.35.4",
"react-native-nitro-ota": "0.13.0",
"react-native-nitro-player": "1.0.2",
"react-native-nitro-player": "1.0.3",
"react-native-pager-view": "8.0.0",
"react-native-pulsar": "1.0.2",
"react-native-reanimated": "4.1.6",
"react-native-reanimated": "4.3.0",
"react-native-safe-area-context": "5.7.0",
"react-native-screens": "4.24.0",
"react-native-sortables": "1.9.4",
@@ -55,7 +55,7 @@
"react-native-turbo-image": "1.24.1",
"react-native-url-polyfill": "3.0.0",
"react-native-uuid": "2.0.4",
"react-native-worklets": "0.7.2",
"react-native-worklets": "0.8.1",
"tamagui": "2.0.0-rc.41",
"zustand": "5.0.12",
},
@@ -2056,13 +2056,13 @@
"react-native-nitro-ota": ["react-native-nitro-ota@0.13.0", "", { "dependencies": { "react-native-nitro-ota": "^0.10.1" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "0.35.1" } }, "sha512-9WwrI/NW/ZZfTUifS/l9FBSFgIUIw1uZgSJ2WgObINSz2+EIxY3thjyV2TXZJG45Zh1gJgH+4bNPPt8IG3uL7w=="],
"react-native-nitro-player": ["react-native-nitro-player@1.0.2", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-ROA+FVmiqosbALP1C8YP5q6XUXYttis+DA39Kz7tTO9u3Z2lvd/E5NWtC3Vk/30zaqoyrn+szqsIErNmusF+Eg=="],
"react-native-nitro-player": ["react-native-nitro-player@1.0.3", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-mi2ufjiDcaTQpyu4L+7HuwCSvWapMtvh0kj9dDGP44uQUbwSK3usD0boVUbYMj3Wmzol9LBBGbWa1Xk1RYxMIQ=="],
"react-native-pager-view": ["react-native-pager-view@8.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-oAwlWT1lhTkIs9HhODnjNNl/owxzn9DP1MbP+az6OTUdgbmzA16Up83sBH8NRKwrH8rNm7iuWnX1qMqiiWOLhg=="],
"react-native-pulsar": ["react-native-pulsar@1.0.2", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "*" } }, "sha512-s30qGjDdIpdSAKrHhWz46O/vTOM0HJ70oa6aT2sgoqML8d7m5+4A39pj2NxmizdhydFAuF+jAW/fvE8HI9UETA=="],
"react-native-reanimated": ["react-native-reanimated@4.1.6", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "7.7.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0", "react": "*", "react-native": "*", "react-native-worklets": ">=0.5.0" } }, "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ=="],
"react-native-reanimated": ["react-native-reanimated@4.3.0", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.81 - 0.85", "react-native-worklets": "0.8.x" } }, "sha512-HOTTPdKtddXTOsmQxDASXEwLS3lqEHrKERD3XOgzSqWJ7L3x81Pnx7mTcKx1FKdkgomMug/XSmm1C6Z7GIowxA=="],
"react-native-safe-area-context": ["react-native-safe-area-context@5.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ=="],
@@ -2088,7 +2088,7 @@
"react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="],
"react-native-worklets": ["react-native-worklets@0.7.2", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "7.27.1", "@babel/plugin-transform-class-properties": "7.27.1", "@babel/plugin-transform-classes": "7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1", "@babel/plugin-transform-optional-chaining": "7.27.1", "@babel/plugin-transform-shorthand-properties": "7.27.1", "@babel/plugin-transform-template-literals": "7.27.1", "@babel/plugin-transform-unicode-regex": "7.27.1", "@babel/preset-typescript": "7.27.1", "convert-source-map": "2.0.0", "semver": "7.7.3" }, "peerDependencies": { "@babel/core": "*", "react": "*", "react-native": "*" } }, "sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog=="],
"react-native-worklets": ["react-native-worklets@0.8.1", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.27.1", "@babel/plugin-transform-classes": "^7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", "@babel/plugin-transform-optional-chaining": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "convert-source-map": "^2.0.0", "semver": "^7.7.3" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", "react-native": "0.81 - 0.85" } }, "sha512-oWP/lStsAHU6oYCaWDXrda/wOHVdhusQJz1e6x9gPnXdFf4ndNDAOtWCmk2zGrAnlapfyA3rM6PCQq94mPg9cw=="],
"react-native-worklets-core": ["react-native-worklets-core@1.6.3", "", { "dependencies": { "string-hash-64": "^1.0.3" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-r3Q40XQBccx/iAI5tlyiua+micvO1UGzzUOskNweZUXyfrrE+rb5aqxqruBPqXf90rO+bBiplylLMEAXCLTyGA=="],
@@ -2770,23 +2770,13 @@
"react-native-nitro-ota/react-native-nitro-ota": ["react-native-nitro-ota@0.10.1", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "0.32.0" } }, "sha512-CYEN0pnjAd0BA3fZ9nghBvLKVW40+xQj0JsclrrrccSmG4TcV6FZOgjeT2Hh3zdmBjvAxnvjar5+7HtnbE/6Qg=="],
"react-native-reanimated/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"react-native-reanimated/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"react-native-web/@react-native/normalize-colors": ["@react-native/normalize-colors@0.74.89", "", {}, "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg=="],
"react-native-web/memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="],
"react-native-worklets/@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.27.1", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA=="],
"react-native-worklets/@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.28.4", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-globals": "^7.28.0", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/traverse": "^7.28.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA=="],
"react-native-worklets/@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA=="],
"react-native-worklets/@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg=="],
"react-native-worklets/@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="],
"react-native-worklets/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"react-native-worklets/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"resolve-cwd/resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
-3
View File
@@ -1,3 +0,0 @@
{
"dsn": "https://glitchtip.jellify.app"
}
+10 -5
View File
@@ -1,14 +1,12 @@
import 'react-native-gesture-handler'
// Initialize console override early - disable all console methods in production
import './src/utils/console-override'
import { AppRegistry, __DEV__ } from 'react-native'
import { AppRegistry, Platform, __DEV__ } from 'react-native'
import App from './App'
import { name as appName } from './app.json'
import { enableFreeze, enableScreens } from 'react-native-screens'
import { GLITCHTIP_DSN } from './src/configs/config'
import * as Sentry from '@sentry/react-native'
import registerCarPlayService from './src/services/carplay'
import registerAndroidAutoService from './src/services/android-auto'
enableScreens(true)
enableFreeze(true)
@@ -21,7 +19,14 @@ Sentry.init({
enabled: !!GLITCHTIP_DSN,
})
registerCarPlayService()
registerAndroidAutoService()
// Lazy require the CarPlayService on iOS so react-native-carplay's native
// module is never accessed on Android, as it's only linked for iOS in react-native.config.js
if (Platform.OS === 'ios') {
const { registerCarPlayService } = require('./src/services/carplay')
registerCarPlayService()
} else if (Platform.OS === 'android') {
const { registerAndroidAutoService } = require('./src/services/android-auto')
registerAndroidAutoService()
}
AppRegistry.registerComponent(appName, () => App)
+14 -14
View File
@@ -110,7 +110,7 @@ PODS:
- SSZipArchive
- Yoga
- NitroOtaBundleManager (0.13.0)
- NitroPlayer (1.0.2):
- NitroPlayer (1.0.3):
- hermes-engine
- NitroModules
- RCTRequired
@@ -2178,7 +2178,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- RNReanimated (4.1.6):
- RNReanimated (4.3.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2200,10 +2200,11 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNReanimated/reanimated (= 4.1.6)
- RNReanimated/apple (= 4.3.0)
- RNReanimated/common (= 4.3.0)
- RNWorklets
- Yoga
- RNReanimated/reanimated (4.1.6):
- RNReanimated/apple (4.3.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2225,10 +2226,9 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNReanimated/reanimated/apple (= 4.1.6)
- RNWorklets
- Yoga
- RNReanimated/reanimated/apple (4.1.6):
- RNReanimated/common (4.3.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2368,7 +2368,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- RNWorklets (0.7.2):
- RNWorklets (0.8.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2390,9 +2390,10 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNWorklets/worklets (= 0.7.2)
- RNWorklets/apple (= 0.8.1)
- RNWorklets/common (= 0.8.1)
- Yoga
- RNWorklets/worklets (0.7.2):
- RNWorklets/apple (0.8.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2414,9 +2415,8 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNWorklets/worklets/apple (= 0.7.2)
- Yoga
- RNWorklets/worklets/apple (0.7.2):
- RNWorklets/common (0.8.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2773,7 +2773,7 @@ SPEC CHECKSUMS:
NitroModules: f45159491c5a0e7161685a301b1c6c11440b3ab1
NitroOta: 1e57c09eaf77e35c46d83ed255d302b8a72bf3c3
NitroOtaBundleManager: 601aa26187ebbdf337be5bef8ae3cc4ea83bea46
NitroPlayer: c7c1c9b5a115b509ef1d1a9468c3c04fd7d41e86
NitroPlayer: 6acf7589993792486163b1bdf14795e4c218fbbb
NitroSuperconfig: ae665d0481004b6b2832d4456affd7f2add205a9
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
Pulsar: 1cc6948747204c00d0f44f70a15608a61709fc57
@@ -2859,11 +2859,11 @@ SPEC CHECKSUMS:
RNDeviceInfo: 4c852998208b60dc192ae3529e5867817719ad1e
RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
RNGestureHandler: b633cb9b819ffb23661496af13bd6943d66f093c
RNReanimated: caefad1cdf778187bc12b747ff958ee7c6d2e580
RNReanimated: 923eb30d50cea473eed734767d4c33c374fae5fc
RNScreens: 6cb648bdad8fe9bee9259fe144df95b6d1d5b707
RNSentry: c60f22ba4945e650f727ee40ee3b2ac73bca065a
RNSVG: c69f7709226108f5eb89b5aa8833c17a36345468
RNWorklets: b6bad5e2ccfdddc4751bca114acf1680dc3231d4
RNWorklets: bc32c788b41ef5f9ee519d6bf3f495b73fb7741b
Sentry: 88746bf877eff714bc45315a39ad1d1efea2cdda
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
SwiftUIIntrospect: fee9aa07293ee280373a591e1824e8ddc869ba5d
+286 -22
View File
@@ -101,7 +101,7 @@ describe('getItemImageUrl', () => {
})
describe('fallback - album image', () => {
it('should fall back to album image when item has no own image but has AlbumId', () => {
it('should fall back to album image when item has no own image but has both AlbumId and tag', () => {
const mockItem: BaseItemDto = {
Id: 'item-1',
Name: 'Test Track',
@@ -121,17 +121,24 @@ describe('getItemImageUrl', () => {
})
})
it('should use undefined tag when album has no AlbumPrimaryImageTag', () => {
it('should skip album fallback when AlbumPrimaryImageTag is missing and use artist fallback', () => {
const mockItem: BaseItemDto = {
Id: 'item-1',
Name: 'Test Track',
AlbumId: 'album-1',
AlbumArtists: [
{
Id: 'artist-1',
Name: 'Test Artist',
},
],
Type: 'Audio',
}
getItemImageUrl(mockItem, ImageType.Primary)
expect(mockGetItemImageUrlById).toHaveBeenCalledWith('album-1', ImageType.Primary, {
// Should skip album (no tag) and fall back to artist
expect(mockGetItemImageUrlById).toHaveBeenCalledWith('artist-1', ImageType.Primary, {
tag: undefined,
maxWidth: 200,
maxHeight: 200,
@@ -236,8 +243,71 @@ describe('getItemImageUrl', () => {
})
})
describe('fallback - generic parent image', () => {
it('should fall back to ParentPrimaryImageItemId when no item/album image exists', () => {
const mockItem: BaseItemDto = {
Id: 'item-1',
Name: 'Test Track',
ParentPrimaryImageItemId: 'parent-1',
ParentPrimaryImageTag: 'parent-tag',
Type: 'Audio',
}
const result = getItemImageUrl(mockItem, ImageType.Primary)
expect(result).toBe('http://example.com/image.jpg')
expect(mockGetItemImageUrlById).toHaveBeenCalledWith('parent-1', ImageType.Primary, {
tag: 'parent-tag',
maxWidth: 200,
maxHeight: 200,
quality: 90,
})
})
it('should fall back to ParentId when ParentPrimaryImageItemId is not set', () => {
const mockItem: BaseItemDto = {
Id: 'item-1',
Name: 'Test Track',
ParentId: 'parent-1',
ParentPrimaryImageTag: 'parent-tag',
Type: 'Audio',
}
const result = getItemImageUrl(mockItem, ImageType.Primary)
expect(result).toBe('http://example.com/image.jpg')
expect(mockGetItemImageUrlById).toHaveBeenCalledWith('parent-1', ImageType.Primary, {
tag: 'parent-tag',
maxWidth: 200,
maxHeight: 200,
quality: 90,
})
})
})
describe('fallback - artist own backdrop', () => {
it('should use artist backdrop when requesting Primary and artist has no primary image', () => {
const mockItem: BaseItemDto = {
Id: 'artist-1',
Name: 'Test Artist',
Type: 'MusicArtist',
BackdropImageTags: ['artist-backdrop-tag'],
}
const result = getItemImageUrl(mockItem, ImageType.Primary)
expect(result).toBe('http://example.com/image.jpg')
expect(mockGetItemImageUrlById).toHaveBeenCalledWith('artist-1', ImageType.Backdrop, {
tag: 'artist-backdrop-tag',
maxWidth: 200,
maxHeight: 200,
quality: 90,
})
})
})
describe('fallback - item own ID', () => {
it('should use item ID as last resort', () => {
it('should use item ID as last resort when no other fallback applies', () => {
const mockItem: BaseItemDto = {
Id: 'item-1',
Name: 'Test Track',
@@ -310,46 +380,68 @@ describe('getItemImageUrl', () => {
})
describe('edge cases', () => {
it('should handle different image types', () => {
it('should handle different image types with their own tags', () => {
const mockItem: BaseItemDto = {
Id: 'item-1',
Name: 'Test Album',
ImageTags: {
[ImageType.Backdrop]: 'backdrop-tag',
[ImageType.Primary]: 'primary-tag',
[ImageType.Thumb]: 'thumb-tag',
},
Type: 'MusicAlbum',
}
getItemImageUrl(mockItem, ImageType.Backdrop)
// Request Primary type
getItemImageUrl(mockItem, ImageType.Primary)
expect(mockGetItemImageUrlById).toHaveBeenCalledWith(
'item-1',
ImageType.Backdrop,
ImageType.Primary,
expect.objectContaining({
tag: 'backdrop-tag',
tag: 'primary-tag',
}),
)
// Clear mock
mockGetItemImageUrlById.mockClear()
// Request Thumb type (delegated to getItemBackdropUrl if Backdrop is requested)
getItemImageUrl(mockItem, ImageType.Thumb)
expect(mockGetItemImageUrlById).toHaveBeenCalledWith(
'item-1',
ImageType.Thumb,
expect.objectContaining({
tag: 'thumb-tag',
}),
)
})
it('should not use ImageTag when requesting a different type than available', () => {
it('should handle backdrop images via separate getItemBackdropUrl logic', () => {
// Note: BackdropImageTags is an array, not a keyed object like ImageTags
const mockItem: BaseItemDto = {
Id: 'item-1',
Name: 'Test Track',
Name: 'Test Album',
BackdropImageTags: ['backdrop-tag-1', 'backdrop-tag-2'],
ImageTags: {
[ImageType.Primary]: 'tag-123',
[ImageType.Primary]: 'primary-tag',
},
Type: 'Audio',
Type: 'MusicAlbum',
}
getItemImageUrl(mockItem, ImageType.Backdrop)
// Request Backdrop type - will use getItemBackdropUrl internally
const result = getItemImageUrl(mockItem, ImageType.Backdrop)
expect(mockGetItemImageUrlById).toHaveBeenCalledWith('item-1', ImageType.Backdrop, {
tag: undefined, // Backdrop tag doesn't exist, so undefined
maxWidth: 200,
maxHeight: 200,
quality: 90,
})
expect(result).toBe('http://example.com/image.jpg')
// Backdrop fallback uses its own logic via getItemBackdropUrl
expect(mockGetItemImageUrlById).toHaveBeenCalledWith(
'item-1',
ImageType.Backdrop,
expect.objectContaining({
tag: 'backdrop-tag-1',
}),
)
})
it('should handle ImageTags as empty object', () => {
@@ -370,7 +462,7 @@ describe('getItemImageUrl', () => {
})
})
it('should prefer album over artist even if artist exists', () => {
it('should prefer album with tag over artist even if artist exists', () => {
const mockItem: BaseItemDto = {
Id: 'item-1',
Name: 'Test Track',
@@ -431,6 +523,22 @@ describe('getItemImageUrl', () => {
})
})
describe('fallback - backdrop images', () => {
it('should handle backdrop image type separately', () => {
const mockItem: BaseItemDto = {
Id: 'item-1',
Name: 'Test Album',
BackdropImageTags: ['backdrop-tag-1'],
Type: 'MusicAlbum',
}
const result = getItemImageUrl(mockItem, ImageType.Backdrop)
expect(result).toBe('http://example.com/image.jpg')
// Note: backdrop handling is delegated to getItemBackdropUrl
})
})
describe('fallback - item own ID', () => {
it('should use item ID as last resort when no image tags, album, or artists exist', () => {
const mockItem: BaseItemDto = {
@@ -451,12 +559,13 @@ describe('getItemImageUrl', () => {
})
})
it('should use item ID as last resort when album has no tag and no artists exist', () => {
it('should use item ID as last resort after trying all fallbacks', () => {
const mockItem: BaseItemDto = {
Id: 'item-1',
Name: 'Test Track',
AlbumId: 'album-1',
// No AlbumPrimaryImageTag and no ArtistItems, so album and artist fallbacks won't trigger
Type: 'Audio',
// No AlbumPrimaryImageTag, so album fallback won't trigger
}
const result = getItemImageUrl(mockItem, ImageType.Primary)
@@ -470,4 +579,159 @@ describe('getItemImageUrl', () => {
})
})
})
describe('fallback - artist items', () => {
it('should fall back to ArtistItems when no AlbumArtists exists', () => {
const mockItem: BaseItemDto = {
Id: 'item-1',
Name: 'Test Track',
ArtistItems: [
{
Id: 'artist-1',
Name: 'Solo Artist',
},
],
Type: 'Audio',
}
const result = getItemImageUrl(mockItem, ImageType.Primary)
expect(result).toBe('http://example.com/image.jpg')
expect(mockGetItemImageUrlById).toHaveBeenCalledWith('artist-1', ImageType.Primary, {
tag: undefined,
maxWidth: 200,
maxHeight: 200,
quality: 90,
})
})
it('should prefer AlbumArtists over ArtistItems', () => {
const mockItem: BaseItemDto = {
Id: 'item-1',
Name: 'Test Track',
AlbumArtists: [
{
Id: 'album-artist-1',
Name: 'Album Artist',
},
],
ArtistItems: [
{
Id: 'artist-1',
Name: 'Solo Artist',
},
],
Type: 'Audio',
}
getItemImageUrl(mockItem, ImageType.Primary)
// Should use AlbumArtists, not ArtistItems
expect(mockGetItemImageUrlById).toHaveBeenCalledWith(
'album-artist-1',
ImageType.Primary,
expect.any(Object),
)
})
it('should use only the first ArtistItem when multiple exist', () => {
const mockItem: BaseItemDto = {
Id: 'item-1',
Name: 'Test Track',
ArtistItems: [
{
Id: 'artist-1',
Name: 'First Artist',
},
{
Id: 'artist-2',
Name: 'Second Artist',
},
],
Type: 'Audio',
}
getItemImageUrl(mockItem, ImageType.Primary)
expect(mockGetItemImageUrlById).toHaveBeenCalledWith(
'artist-1',
ImageType.Primary,
expect.any(Object),
)
})
it('should skip ArtistItems without Id and fall back to item ID', () => {
const mockItem: BaseItemDto = {
Id: 'item-1',
Name: 'Test Track',
ArtistItems: [
{
Name: 'Artist without ID',
},
],
Type: 'Audio',
}
const result = getItemImageUrl(mockItem, ImageType.Primary)
expect(result).toBe('http://example.com/image.jpg')
// Should fall back to item's own ID
expect(mockGetItemImageUrlById).toHaveBeenCalledWith(
'item-1',
ImageType.Primary,
expect.any(Object),
)
})
})
describe('fallback order verification', () => {
it('should follow the complete fallback order: own image → album → parent → artist → item ID', () => {
// Test that artist fallback runs before item ID fallback
const mockItem: BaseItemDto = {
Id: 'item-1',
Name: 'Test Track',
AlbumArtists: [
{
Id: 'artist-1',
Name: 'Artist',
},
],
Type: 'Audio',
}
getItemImageUrl(mockItem, ImageType.Primary)
// Should use artist (preferred over item ID)
expect(mockGetItemImageUrlById).toHaveBeenCalledWith(
'artist-1',
ImageType.Primary,
expect.any(Object),
)
})
it('should verify parent image fallback is tried before artist fallback', () => {
const mockItem: BaseItemDto = {
Id: 'item-1',
Name: 'Test Track',
ParentId: 'parent-1',
ParentPrimaryImageTag: 'parent-tag',
AlbumArtists: [
{
Id: 'artist-1',
Name: 'Artist',
},
],
Type: 'Audio',
}
getItemImageUrl(mockItem, ImageType.Primary)
// Should use parent (preferred over artist)
expect(mockGetItemImageUrlById).toHaveBeenCalledWith(
'parent-1',
ImageType.Primary,
expect.objectContaining({ tag: 'parent-tag' }),
)
})
})
})
+1
View File
@@ -22,6 +22,7 @@ jest.mock('react-native-superconfig', () => ({
OTA_UPDATE_ENABLED: 'false',
IS_MAESTRO_BUILD: 'false',
GLITCHTIP_DSN: '',
TELEMETRYDECK_APPID: '00000000-0000-0000-0000-000000000000',
},
}))
+4 -4
View File
@@ -74,10 +74,10 @@
"react-native-nitro-fetch": "1.0.3",
"react-native-nitro-modules": "0.35.4",
"react-native-nitro-ota": "0.13.0",
"react-native-nitro-player": "1.0.2",
"react-native-nitro-player": "1.0.3",
"react-native-pager-view": "8.0.0",
"react-native-pulsar": "1.0.2",
"react-native-reanimated": "4.1.6",
"react-native-reanimated": "4.3.0",
"react-native-safe-area-context": "5.7.0",
"react-native-screens": "4.24.0",
"react-native-sortables": "1.9.4",
@@ -88,7 +88,7 @@
"react-native-turbo-image": "1.24.1",
"react-native-url-polyfill": "3.0.0",
"react-native-uuid": "2.0.4",
"react-native-worklets": "0.7.2",
"react-native-worklets": "0.8.1",
"tamagui": "2.0.0-rc.41",
"zustand": "5.0.12"
},
@@ -147,4 +147,4 @@
"react-native-nitro-modules",
"unrs-resolver"
]
}
}
@@ -1,12 +0,0 @@
diff --git a/node_modules/react-native-nitro-player/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt b/node_modules/react-native-nitro-player/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt
index b6f2daa..83f85ab 100644
--- a/node_modules/react-native-nitro-player/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt
+++ b/node_modules/react-native-nitro-player/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt
@@ -32,7 +32,6 @@ internal fun TrackPlayerCore.rebuildQueueAndPlayFromIndex(index: Int) {
exo.clearMediaItems()
exo.setMediaItems(mediaItems)
exo.seekToDefaultPosition(0)
- exo.playWhenReady = true
exo.prepare()
}
@@ -1,13 +0,0 @@
diff --git a/node_modules/react-native-reanimated/compatibility.json b/node_modules/react-native-reanimated/compatibility.json
index f1ba9cb..ffab7fe 100644
--- a/node_modules/react-native-reanimated/compatibility.json
+++ b/node_modules/react-native-reanimated/compatibility.json
@@ -4,7 +4,7 @@
"react-native-worklets": ["nightly"]
},
"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", "0.84"],
"react-native-worklets": ["0.5.x", "0.6.x", "0.7.x"]
},
"4.0.x": {
-13
View File
@@ -1,13 +0,0 @@
diff --git a/node_modules/react-native-worklets/compatibility.json b/node_modules/react-native-worklets/compatibility.json
index 965bb91..3452554 100644
--- a/node_modules/react-native-worklets/compatibility.json
+++ b/node_modules/react-native-worklets/compatibility.json
@@ -3,7 +3,7 @@
"react-native": ["0.79", "0.80", "0.81", "0.82"]
},
"0.7.x": {
- "react-native": ["0.79", "0.80", "0.81", "0.82", "0.83"]
+ "react-native": ["0.79", "0.80", "0.81", "0.82", "0.83", "0.84"]
},
"0.6.x": {
"react-native": ["0.78", "0.79", "0.80", "0.81", "0.82"]
+7
View File
@@ -3,5 +3,12 @@ module.exports = {
ios: {},
android: {},
},
dependencies: {
'react-native-carplay': {
platforms: {
android: null,
},
},
},
assets: ['./assets/fonts/'],
}
+1 -1
View File
@@ -11,7 +11,7 @@ const usePostFullCapabilities = () => {
const { mutate } = useMutation({
mutationFn: async () => {
if (!api) return
if (!api || api.accessToken === '') return
return await getSessionApi(api).postFullCapabilities({
clientCapabilitiesDto: {
+16 -15
View File
@@ -10,8 +10,8 @@ import { Api } from '@jellyfin/sdk'
import { fetchItem, fetchItems } from '../../item'
import { JellifyUser } from '../../../../types/JellifyUser'
import { ApiLimits } from '../../../../configs/query.config'
import { nitroFetch } from '../../../utils/nitro'
import buildYearsParam from '../../../../utils/mapping/build-years-param'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api'
export function fetchAlbums(
api: Api | undefined,
@@ -31,20 +31,21 @@ export function fetchAlbums(
const yearsParam = buildYearsParam(yearMin, yearMax)
nitroFetch<{ Items: BaseItemDto[] }>(api, '/Items', {
ParentId: library.musicLibraryId,
IncludeItemTypes: [BaseItemKind.MusicAlbum],
UserId: user.id,
SortBy: sortBy,
SortOrder: sortOrder,
StartIndex: page * ApiLimits.Library,
Limit: ApiLimits.Library,
IsFavorite: isFavorite,
Fields: [ItemFields.SortName],
Recursive: true,
Years: yearsParam,
})
.then((data) => {
getItemsApi(api)
.getItems({
parentId: library.musicLibraryId,
includeItemTypes: [BaseItemKind.MusicAlbum],
userId: user.id,
sortBy: sortBy,
sortOrder: sortOrder,
startIndex: page * ApiLimits.Library,
limit: ApiLimits.Library,
isFavorite: isFavorite,
fields: [ItemFields.SortName],
recursive: true,
years: yearsParam,
})
.then(({ data }) => {
return data.Items ? resolve(data.Items) : resolve([])
})
.catch((error) => {
+40 -33
View File
@@ -3,6 +3,7 @@ import { Api } from '@jellyfin/sdk/lib/api'
import {
BaseItemDto,
BaseItemKind,
ImageType,
ItemFields,
ItemSortBy,
SortOrder,
@@ -10,7 +11,6 @@ import {
import { getArtistsApi, getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { JellifyUser } from '../../../../types/JellifyUser'
import { ApiLimits } from '../../../../configs/query.config'
import { nitroFetch } from '../../../utils/nitro'
export function fetchArtists(
api: Api | undefined,
@@ -26,17 +26,21 @@ export function fetchArtists(
if (!user) return reject('No user provided')
if (!library) return reject('Library has not been set')
nitroFetch<{ Items: BaseItemDto[] }>(api, '/Artists/AlbumArtists', {
ParentId: library.musicLibraryId,
UserId: user.id,
SortBy: sortBy,
SortOrder: sortOrder,
StartIndex: page * ApiLimits.Library,
Limit: ApiLimits.Library,
IsFavorite: isFavorite,
Fields: [ItemFields.SortName, ItemFields.Genres],
})
.then((data) => {
getArtistsApi(api)
.getAlbumArtists({
parentId: library.musicLibraryId,
userId: user.id,
sortBy: sortBy,
sortOrder: sortOrder,
startIndex: page * ApiLimits.Library,
limit: ApiLimits.Library,
isFavorite: isFavorite,
fields: [ItemFields.SortName, ItemFields.Genres],
enableImages: true,
enableImageTypes: [ImageType.Backdrop, ImageType.Primary],
imageTypeLimit: 1,
})
.then(({ data }) => {
return data.Items ? resolve(data.Items) : resolve([])
})
.catch((error) => {
@@ -60,17 +64,18 @@ export function fetchArtistAlbums(
if (!api) return reject('No API instance provided')
if (!libraryId) return reject('Library has not been set')
nitroFetch<{ Items: BaseItemDto[] }>(api!, '/Items', {
ParentId: libraryId,
IncludeItemTypes: [BaseItemKind.MusicAlbum],
Recursive: true,
ExcludeItemIds: [artist.Id!],
SortBy: [ItemSortBy.PremiereDate, ItemSortBy.ProductionYear, ItemSortBy.SortName],
SortOrder: [SortOrder.Descending],
AlbumArtistIds: [artist.Id!],
Fields: [ItemFields.ChildCount],
})
.then((data) => {
getItemsApi(api)
.getItems({
parentId: libraryId,
includeItemTypes: [BaseItemKind.MusicAlbum],
recursive: true,
excludeItemIds: [artist.Id!],
sortBy: [ItemSortBy.PremiereDate, ItemSortBy.ProductionYear, ItemSortBy.SortName],
sortOrder: [SortOrder.Descending],
albumArtistIds: [artist.Id!],
fields: [ItemFields.ChildCount],
})
.then(({ data }) => {
return data.Items ? resolve(data.Items) : resolve([])
})
.catch((error) => {
@@ -94,16 +99,18 @@ export function fetchArtistFeaturedOn(
if (!api) return reject('No API instance provided')
if (!libraryId) return reject('Library has not been set')
nitroFetch<{ Items: BaseItemDto[] }>(api, '/Items', {
ParentId: libraryId,
IncludeItemTypes: [BaseItemKind.MusicAlbum],
Recursive: true,
ExcludeItemIds: [artist.Id!],
SortBy: [ItemSortBy.PremiereDate, ItemSortBy.ProductionYear, ItemSortBy.SortName],
SortOrder: [SortOrder.Descending],
ContributingArtistIds: [artist.Id!],
})
.then((data) => {
getItemsApi(api)
.getItems({
parentId: libraryId,
includeItemTypes: [BaseItemKind.MusicAlbum],
recursive: true,
excludeItemIds: [artist.Id!],
sortBy: [ItemSortBy.PremiereDate, ItemSortBy.ProductionYear, ItemSortBy.SortName],
sortOrder: [SortOrder.Descending],
contributingArtistIds: [artist.Id!],
fields: [ItemFields.ParentId, ItemFields.ChildCount],
})
.then(({ data }) => {
return data.Items ? resolve(data.Items) : resolve([])
})
.catch((error) => {
@@ -1,6 +1,8 @@
import {
BaseItemDto,
BaseItemKind,
ImageType,
ItemFields,
ItemSortBy,
SortOrder,
} from '@jellyfin/sdk/lib/generated-client/models'
@@ -40,6 +42,7 @@ export function fetchFrequentlyPlayed(
startIndex: page * ApiLimits.Home,
sortBy: [ItemSortBy.PlayCount],
sortOrder: [SortOrder.Descending],
fields: [ItemFields.ParentId, ItemFields.Tags],
})
.then(({ data }) => {
if (data.Items) resolve(data.Items)
@@ -113,6 +116,10 @@ export function fetchFrequentlyPlayedArtists(
const { data } = await getItemsApi(api!).getItems({
ids: uniqueArtistIds,
includeItemTypes: [BaseItemKind.MusicArtist],
fields: [ItemFields.Genres, ItemFields.SortName, ItemFields.Tags],
enableImages: true,
enableImageTypes: [ImageType.Backdrop, ImageType.Primary],
imageTypeLimit: 1,
})
if (data.Items) {
+13 -12
View File
@@ -2,12 +2,12 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { Api } from '@jellyfin/sdk'
import { JellifyLibrary } from '../../../../types/JellifyLibrary'
import { JellifyUser } from '../../../../types/JellifyUser'
import { nitroFetch } from '../../../utils/nitro'
import { isUndefined } from 'lodash'
import { ApiLimits } from '../../../../configs/query.config'
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by'
import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order'
import { ItemFields } from '@jellyfin/sdk/lib/generated-client/models'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api'
export function fetchGenres(
api: Api | undefined,
@@ -20,17 +20,18 @@ export function fetchGenres(
if (isUndefined(library)) return reject('Library instance not set')
if (isUndefined(user)) return reject('User instance not set')
nitroFetch<{ Items: BaseItemDto[] }>(api, '/Genres', {
ParentId: library.musicLibraryId,
UserId: user.id,
SortBy: [ItemSortBy.SortName],
SortOrder: [SortOrder.Ascending],
Recursive: true,
Fields: [ItemFields.PrimaryImageAspectRatio, ItemFields.ItemCounts],
StartIndex: pageParam * ApiLimits.Library,
Limit: ApiLimits.Library,
})
.then((data) => {
getItemsApi(api)
.getItems({
parentId: library.musicLibraryId,
userId: user.id,
sortBy: [ItemSortBy.SortName],
sortOrder: [SortOrder.Ascending],
recursive: true,
fields: [ItemFields.PrimaryImageAspectRatio, ItemFields.ItemCounts],
startIndex: pageParam * ApiLimits.Library,
limit: ApiLimits.Library,
})
.then(({ data }) => {
if (data.Items) return resolve(data.Items)
else return resolve([])
})
+117 -25
View File
@@ -1,6 +1,7 @@
import { getApi } from '../../../../stores'
import { BaseItemDto, ImageType } from '@jellyfin/sdk/lib/generated-client/models'
import { BaseItemDto, BaseItemKind, ImageType } from '@jellyfin/sdk/lib/generated-client/models'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import { ImageUrlsApi } from '@jellyfin/sdk/lib/utils/api/image-urls-api'
// Default image size for list thumbnails (optimized for common row heights)
const DEFAULT_THUMBNAIL_SIZE = 200
@@ -19,7 +20,19 @@ export function getItemImageUrl(
type: ImageType,
options?: ImageUrlOptions,
): string | undefined {
const { AlbumId, AlbumPrimaryImageTag, ImageTags, Id, AlbumArtists, ArtistItems } = item
const {
AlbumId,
AlbumPrimaryImageTag,
Type,
BackdropImageTags,
ParentId,
ParentPrimaryImageItemId,
ParentPrimaryImageTag,
ImageTags,
Id,
AlbumArtists,
ArtistItems,
} = item
const api = getApi()
@@ -33,37 +46,116 @@ export function getItemImageUrl(
quality: options?.quality ?? 90,
}
// Check if the item has its own image for the requested type first
const hasOwnImage = ImageTags && ImageTags[type]
const imageApi = getImageApi(api)
let imageUrl: string | undefined = undefined
// Backdrop images are stored separately from ImageTags in the Jellyfin data model.
// BackdropImageTags is an array of cache-buster tags, not a keyed object like ImageTags.
if (type === ImageType.Backdrop) return getItemBackdropUrl(item, imageApi, imageParams)
if (hasOwnImage && Id) {
// Use the item's own image (e.g., track-specific artwork)
imageUrl = getImageApi(api).getItemImageUrlById(Id, type, {
// For all other image types (Primary, Thumb, Logo, etc.)
// 1. Item has its own tag for the requested type
if (Id && ImageTags?.[type]) {
return imageApi.getItemImageUrlById(Id, type, {
...imageParams,
tag: ImageTags ? ImageTags[type] : undefined,
tag: ImageTags[type],
})
} else if (AlbumId) {
// Fall back to album primary image (tag may be undefined if album has no image tag)
imageUrl = getImageApi(api).getItemImageUrlById(AlbumId, type, {
}
// 1b. Artist cards request Primary by default, but some libraries only expose artist backdrops.
// Use the first backdrop as a visual fallback to avoid blank artist avatars.
if (
type === ImageType.Primary &&
Type === BaseItemKind.MusicArtist &&
Id &&
BackdropImageTags?.length
) {
return imageApi.getItemImageUrlById(Id, ImageType.Backdrop, {
...imageParams,
tag: AlbumPrimaryImageTag ?? undefined,
tag: BackdropImageTags[0],
})
} else if (AlbumArtists?.[0]?.Id || ArtistItems?.[0]?.Id) {
// Fall back to first artist's image (AlbumArtists or ArtistItems for slimified tracks)
const artistId = AlbumArtists?.[0]?.Id ?? ArtistItems?.[0]?.Id
if (artistId) {
imageUrl = getImageApi(api).getItemImageUrlById(artistId, type, {
...imageParams,
})
}
} else if (Id) {
// Last ditch effort: use the item's own ID without a specific type tag
imageUrl = getImageApi(api).getItemImageUrlById(Id, type, {
}
// 2a. Fall back to album primary image (music-specific parent path)
// Only use this path when the album actually has an image tag,
// otherwise continue to artist fallback.
if (AlbumId && AlbumPrimaryImageTag) {
return imageApi.getItemImageUrlById(AlbumId, ImageType.Primary, {
...imageParams,
tag: AlbumPrimaryImageTag,
})
}
// 2b. Fall back via generic parent primary image (used by some Jellyfin servers
// instead of AlbumId/AlbumPrimaryImageTag). Some responses only provide ParentId.
const parentPrimaryId = ParentPrimaryImageItemId ?? ParentId
if (parentPrimaryId) {
return imageApi.getItemImageUrlById(parentPrimaryId, ImageType.Primary, {
...imageParams,
tag: ParentPrimaryImageTag ?? undefined,
})
}
// 3. Fall back to first artist's primary image
const artistId = AlbumArtists?.[0]?.Id ?? ArtistItems?.[0]?.Id
if (artistId) {
return imageApi.getItemImageUrlById(artistId, ImageType.Primary, {
...imageParams,
})
}
return imageUrl
// 4. Last ditch: item's own ID with requested type (can still resolve inherited image server-side)
if (Id) {
return imageApi.getItemImageUrlById(Id, type, {
...imageParams,
})
}
return undefined
}
function getItemBackdropUrl(
item: BaseItemDto,
imageApi: ImageUrlsApi,
imageParams: ImageUrlOptions,
): string | undefined {
const {
Id,
BackdropImageTags,
ParentBackdropItemId,
ParentBackdropImageTags,
AlbumId,
AlbumPrimaryImageTag,
ImageTags,
} = item
// 1. Item's own backdrop
if (Id && BackdropImageTags && BackdropImageTags.length > 0) {
return imageApi.getItemImageUrlById(Id, ImageType.Backdrop, {
...imageParams,
tag: BackdropImageTags[0],
})
}
// 2. Parent backdrop (e.g. artist backdrop surfaced on a track/album)
if (ParentBackdropItemId && ParentBackdropImageTags && ParentBackdropImageTags.length > 0) {
return imageApi.getItemImageUrlById(ParentBackdropItemId, ImageType.Backdrop, {
...imageParams,
tag: ParentBackdropImageTags[0],
})
}
// 3. Fall back to album primary image
if (AlbumId) {
return imageApi.getItemImageUrlById(AlbumId, ImageType.Primary, {
...imageParams,
tag: AlbumPrimaryImageTag ?? undefined,
})
}
// 4. Fall back to item's own primary image
if (Id && ImageTags?.[ImageType.Primary]) {
return imageApi.getItemImageUrlById(Id, ImageType.Primary, {
...imageParams,
tag: ImageTags[ImageType.Primary],
})
}
return undefined
}
+22 -20
View File
@@ -12,7 +12,6 @@ import { Api } from '@jellyfin/sdk/lib/api'
import { JellifyLibrary } from '../../types/JellifyLibrary'
import QueryConfig from '../../configs/query.config'
import { JellifyUser } from '../../types/JellifyUser'
import { nitroFetch } from '../utils/nitro'
/**
* Fetches a single Jellyfin item by it's ID
@@ -69,20 +68,21 @@ export async function fetchItems(
if (isUndefined(user)) return reject('User not initialized')
if (isUndefined(library)) return reject('Library not initialized')
nitroFetch<{ Items: BaseItemDto[] }>(api, '/Items', {
ParentId: parentId ?? library.musicLibraryId,
UserId: user.id,
IncludeItemTypes: types,
SortBy: sortBy,
Recursive: true,
SortOrder: sortOrder,
Fields: [ItemFields.ChildCount, ItemFields.SortName, ItemFields.Genres],
StartIndex: typeof page === 'number' ? page * QueryConfig.limits.library : 0,
Limit: QueryConfig.limits.library,
IsFavorite: isFavorite,
Ids: ids,
})
.then((data) => {
getItemsApi(api)
.getItems({
parentId: parentId ?? library.musicLibraryId,
userId: user.id,
includeItemTypes: types,
sortBy: sortBy,
recursive: true,
sortOrder: sortOrder,
fields: [ItemFields.ChildCount, ItemFields.SortName, ItemFields.Genres],
startIndex: typeof page === 'number' ? page * QueryConfig.limits.library : 0,
limit: QueryConfig.limits.library,
isFavorite: isFavorite,
ids: ids,
})
.then(({ data }) => {
resolve({ title: page, data: data.Items ?? [] })
})
.catch((error) => {
@@ -109,11 +109,13 @@ export async function fetchAlbumDiscs(
sortBy = [ItemSortBy.ParentIndexNumber, ItemSortBy.IndexNumber, ItemSortBy.SortName]
nitroFetch<{ Items: BaseItemDto[] }>(api, '/Items', {
ParentId: album.Id!,
SortBy: sortBy,
})
.then((data) => {
getItemsApi(api)
.getItems({
parentId: album.Id!,
sortBy: sortBy,
fields: [ItemFields.SortName],
})
.then(({ data }) => {
const discs = data.Items
? Object.keys(groupBy(data.Items, (track) => track.ParentIndexNumber)).map(
(discNumber) => {
+1
View File
@@ -1,4 +1,5 @@
import { Api } from '@jellyfin/sdk'
import { fetch } from 'react-native-nitro-fetch'
const PATRON_API_ENDPOINT = 'https://patrons.jellify.app'
+14 -19
View File
@@ -10,7 +10,6 @@ import { JellifyUser } from '../../../../types/JellifyUser'
import { Api } from '@jellyfin/sdk'
import { isUndefined } from 'lodash'
import QueryConfig, { ApiLimits } from '../../../../configs/query.config'
import { nitroFetch } from '../../../utils/nitro'
/**
* Returns the user's playlists from the Jellyfin server
@@ -122,23 +121,19 @@ export async function fetchPlaylistTracks(
throw new Error('Client instance not set')
}
const data = await nitroFetch<{ Items: BaseItemDto[]; TotalRecordCount: number }>(
api,
'/Items',
{
ParentId: playlistId,
IncludeItemTypes: [BaseItemKind.Audio],
Recursive: false,
Limit: ApiLimits.Library,
StartIndex: pageParam * ApiLimits.Library,
Fields: [
ItemFields.MediaSources,
ItemFields.ParentId,
ItemFields.Path,
ItemFields.SortName,
],
},
)
const response = await getItemsApi(api).getItems({
parentId: playlistId,
includeItemTypes: [BaseItemKind.Audio],
recursive: false,
limit: ApiLimits.Library,
startIndex: pageParam * ApiLimits.Library,
fields: [
ItemFields.MediaSources,
ItemFields.ParentId,
ItemFields.Path,
ItemFields.SortName,
],
})
return data.Items ?? []
return response.data.Items ?? []
}
+24 -16
View File
@@ -1,6 +1,7 @@
import {
BaseItemDto,
BaseItemKind,
ImageType,
ItemFields,
ItemSortBy,
SortOrder,
@@ -13,7 +14,6 @@ import { isUndefined } from 'lodash'
import { JellifyLibrary } from '../../../../types/JellifyLibrary'
import { JellifyUser } from '../../../../types/JellifyUser'
import { queryClient } from '../../../../constants/query-client'
import { fetchItems } from '../../item'
import { RECENTLY_PLAYED_ALBUM_THRESHOLD } from '../../../../configs/home.config'
import { PlayItAgainQuery } from '..'
import { ArtistQueryKey } from '../../artist/keys'
@@ -72,7 +72,7 @@ export async function fetchRecentlyPlayed(
recursive: true,
sortBy: [ItemSortBy.DatePlayed],
sortOrder: [SortOrder.Descending],
fields: [ItemFields.ParentId],
fields: [ItemFields.ParentId, ItemFields.Tags],
})
.then((response) => {
if (!response.data.Items) return resolve([])
@@ -143,6 +143,8 @@ export function fetchRecentlyPlayedArtists(
page: number,
): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(user)) return reject('User instance not set')
if (isUndefined(library)) return reject('Library instance not set')
// Get the recently played tracks from the query client
@@ -170,25 +172,31 @@ export function fetchRecentlyPlayedArtists(
) === index,
)
fetchItems(
api,
user,
library,
[BaseItemKind.MusicArtist],
page,
undefined,
undefined,
undefined,
undefined,
artists.map((artist) => artist.Id!),
)
const artistIds = artists.map((artist) => artist.Id!).filter(Boolean)
if (artistIds.length === 0) {
return resolve([])
}
getItemsApi(api)
.getItems({
userId: user.id,
includeItemTypes: [BaseItemKind.MusicArtist],
ids: artistIds,
fields: [ItemFields.Genres, ItemFields.SortName, ItemFields.Tags],
enableImages: true,
enableImageTypes: [ImageType.Backdrop, ImageType.Primary],
imageTypeLimit: 1,
})
.then(({ data }) => {
data.forEach((artist) => {
const fetchedArtists = data.Items ?? []
fetchedArtists.forEach((artist) => {
queryClient.setQueryData(ArtistQueryKey(artist.Id), artist)
})
resolve(
data.sort((a, b) => {
fetchedArtists.sort((a, b) => {
const aIndex = artists.findIndex((artist) => artist.Id === a.Id)
const bIndex = artists.findIndex((artist) => artist.Id === b.Id)
return aIndex - bIndex
@@ -2,6 +2,7 @@ import { getArtistsApi, getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import {
BaseItemDto,
BaseItemKind,
ImageType,
ItemFields,
ItemSortBy,
SortOrder,
@@ -69,6 +70,9 @@ export async function fetchArtistSuggestions(
startIndex: page * 50,
fields: [ItemFields.ChildCount, ItemFields.SortName, ItemFields.Genres],
sortBy: ['Random'],
enableImages: true,
enableImageTypes: [ImageType.Backdrop, ImageType.Primary],
imageTypeLimit: 1,
})
.then(({ data }) => {
if (data.Items) resolve(data.Items)
+18 -17
View File
@@ -8,11 +8,11 @@ import {
ItemSortBy,
SortOrder,
} from '@jellyfin/sdk/lib/generated-client/models'
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'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
export default function fetchTracks(
api: Api | undefined,
@@ -48,22 +48,23 @@ export default function fetchTracks(
const yearsParam = buildYearsParam(yearMin, yearMax)
nitroFetch<{ Items: BaseItemDto[] }>(api, '/Items', {
IncludeItemTypes: [BaseItemKind.Audio],
ParentId: library.musicLibraryId,
UserId: user.id,
Recursive: true,
Filters: filters.length > 0 ? filters : undefined,
Limit: ApiLimits.Library,
StartIndex: pageParam * ApiLimits.Library,
SortBy: [finalSortBy],
SortOrder: [sortOrder],
Fields: [ItemFields.SortName],
ArtistIds: artistId ? [artistId] : undefined,
GenreIds: genreIds && genreIds.length > 0 ? genreIds : undefined,
Years: yearsParam,
})
.then((data) => {
getItemsApi(api)
.getItems({
includeItemTypes: [BaseItemKind.Audio],
parentId: library.musicLibraryId,
userId: user.id,
recursive: true,
filters: filters.length > 0 ? filters : undefined,
limit: ApiLimits.Library,
startIndex: pageParam * ApiLimits.Library,
sortBy: [finalSortBy],
sortOrder: [sortOrder],
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)
else return resolve([])
})
+5 -5
View File
@@ -1,8 +1,8 @@
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'
import { getFilterApi } from '@jellyfin/sdk/lib/utils/api'
export type ItemsFiltersResponse = {
Genres?: string[] | null
@@ -25,10 +25,10 @@ export async function fetchLibraryYears(
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 { data } = await getFilterApi(api).getQueryFiltersLegacy({
userId,
parentId: library.musicLibraryId,
includeItemTypes: [BaseItemKind.MusicAlbum],
})
const years = data?.Years ?? []
-81
View File
@@ -1,81 +0,0 @@
import { Api } from '@jellyfin/sdk'
import { nitroFetchOnWorklet } from 'react-native-nitro-fetch'
import { isUndefined } from 'lodash'
import { getModel, getUniqueIdSync } from 'react-native-device-info'
import { name, version } from '../../../package.json'
/**
* Helper to perform a GET request using NitroFetch.
* @param api The Jellyfin Api instance (used for basePath and accessToken).
* @param path The API endpoint path (e.g., '/Items').
* @param params Optional query parameters object.
* @returns The parsed JSON response.
*
* @deprecated Because of the Axios Adapter being used for the Jellyfin SDK, Nitro Fetch is used by default for all API requests, so this helper is no longer necessary.
*/
export async function nitroFetch<T>(
api: Api | undefined,
path: string,
params?: Record<string, string | number | boolean | undefined | string[]>,
timeoutMs: number = 60000,
): Promise<T> {
if (isUndefined(api)) {
throw new Error('Client instance not set')
}
const basePath = api.basePath
const accessToken = api.accessToken
// Construct query string
const urlParams = new URLSearchParams()
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (Array.isArray(value)) {
// Jellyfin often expects comma-separated values or repeated keys.
// The SDK usually does comma-separated for things like 'Fields'.
// We'll join with commas for now as that's common for Jellyfin lists in query params.
urlParams.append(key, value.join(','))
} else {
urlParams.append(key, String(value))
}
}
})
}
const url = `${basePath}${path}?${urlParams.toString()}`
console.debug(`[NitroFetch] GET ${url}`)
try {
// Use nitroFetchOnWorklet to offload JSON parsing to a background thread
const data = await nitroFetchOnWorklet<T>(
url,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Emby-Token': accessToken,
Authorization: `MediaBrowser Client="${name}", Device="${getModel()}", DeviceId="${getUniqueIdSync()}", Version="${version}", Token="${accessToken}"`,
},
// @ts-expect-error - timeoutMs is a custom property supported by nitro-fetch
timeoutMs,
},
(response) => {
'worklet'
if (response.status >= 200 && response.status < 300) {
if (response.bodyString) {
return JSON.parse(response.bodyString) as T
}
throw new Error('NitroFetch error: Empty response body')
} else {
throw new Error(`NitroFetch error: ${response.status} ${response.bodyString}`)
}
},
)
return data
} catch (error) {
console.error('[NitroFetch] Error:', error)
throw error
}
}
@@ -1,12 +1,12 @@
import React, { RefObject, useEffect, useRef, useState } from 'react'
import { View as RNView, Text as RNText } from 'react-native'
import { LayoutChangeEvent, View as RNView, Text as RNText } from 'react-native'
import { getToken, Paragraph, Spinner, useTheme, View, YStack } from 'tamagui'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated'
import { scheduleOnRN } from 'react-native-worklets'
import { UseInfiniteQueryResult, useMutation } from '@tanstack/react-query'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { triggerHaptic } from '../../../hooks/use-haptic-feedback'
import { Presets } from 'react-native-pulsar'
const alphabetAtoZ = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
const alphabetZtoA = '#ZYXWVUTSRQPONMLKJIHGFEDCBA'.split('')
@@ -41,7 +41,6 @@ export default function AZScroller({
const alphabetSelectorRef = useRef<RNView>(null)
const alphabetSelectorTopY = useRef(0)
const alphabetSelectorHeight = useRef(0)
const letterHeight = useRef(0)
@@ -80,8 +79,8 @@ export default function AZScroller({
)
}
const handleGestureBeginOrUpdate = (e: { absoluteY: number }) => {
const relativeY = e.absoluteY - alphabetSelectorTopY.current
const handleGestureBeginOrUpdate = (e: { y: number }) => {
const relativeY = e.y
setOverlayPositionY(relativeY)
const index = Math.floor(relativeY / letterHeight.current)
if (alphabetToUse[index]) {
@@ -141,24 +140,26 @@ export default function AZScroller({
useEffect(() => {
if (overlayLetter !== '') {
triggerHaptic('impactLight')
Presets.peck()
}
}, [overlayLetter])
useEffect(() => {
if (alphabetSelectorRef.current) {
alphabetSelectorRef.current.measure((x, y, width, height, pageX, pageY) => {
alphabetSelectorTopY.current = pageY
alphabetSelectorHeight.current = height
letterHeight.current = height / alphabetToUse.length
})
}
}, [alphabetSelectorRef.current])
const handleLayout = (e: LayoutChangeEvent) => {
const { height } = e.nativeEvent.layout
alphabetSelectorHeight.current = height
letterHeight.current = height / alphabetToUse.length
}
return (
<View>
<GestureDetector gesture={gesture}>
<YStack minWidth={'$2'} maxWidth={'$3'} flex={1} ref={alphabetSelectorRef}>
<YStack
minWidth={'$2'}
maxWidth={'$3'}
flex={1}
ref={alphabetSelectorRef}
onLayout={handleLayout}
>
{alphabetElements}
</YStack>
</GestureDetector>
@@ -4,6 +4,7 @@ import Animated, {
FadeOut,
LinearTransition,
Easing,
useReducedMotion,
} from 'react-native-reanimated'
interface AnimatedRowProps {
@@ -12,12 +13,18 @@ interface AnimatedRowProps {
}
export default function AnimatedRow({ children, testID }: AnimatedRowProps) {
const reducedMotion = useReducedMotion()
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)}
layout={
reducedMotion
? undefined
: LinearTransition.springify().reduceMotion(ReduceMotion.System)
}
style={{
flex: 1,
}}
+5 -1
View File
@@ -26,13 +26,14 @@ import { RootStackParamList } from '../../screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import ItemImage from '../Global/components/image'
import { useCurrentTrack } from '../../stores/player/queue'
import getTrackDto from '../../utils/mapping/track-extra-payload'
import getTrackDto, { getTypedExtraPayload } from '../../utils/mapping/track-extra-payload'
import { ICON_PRESS_STYLES } from '../../configs/style.config'
import { previous, skip } from '../../hooks/player/functions/controls'
export default function Miniplayer(): React.JSX.Element | null {
const nowPlaying = useCurrentTrack()
const item = getTrackDto(nowPlaying)
const payload = getTypedExtraPayload(nowPlaying)
const theme = useTheme()
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
@@ -96,6 +97,8 @@ export default function Miniplayer(): React.JSX.Element | null {
)
}
const customBlurhash = typeof payload?.blurhash === 'string' ? payload.blurhash : undefined
return (
<GestureDetector gesture={gesture}>
<Animated.View
@@ -118,6 +121,7 @@ export default function Miniplayer(): React.JSX.Element | null {
>
<ItemImage
item={item!}
customBlurhash={customBlurhash}
width={'$11'}
height={'$11'}
imageOptions={{ maxWidth: 120, maxHeight: 120 }}
+5 -2
View File
@@ -8,7 +8,7 @@ import {
TelemetryDeckProvider,
useTelemetryDeck,
} from '@typedigital/telemetrydeck-react'
import telemetryDeckConfig from '../../telemetrydeck.json'
import { TELEMETRYDECK_APPID } from '../configs/config'
import { getToken, Theme, ThemeName, useTheme } from 'tamagui'
import Toast from 'react-native-toast-message'
import JellifyToastConfig from '../configs/toast.config'
@@ -28,7 +28,10 @@ import {
*
* @see https://github.com/typedigital/telemetrydeck-react
*/
const telemetrydeck = createTelemetryDeck(telemetryDeckConfig)
const telemetrydeck = createTelemetryDeck({
appID: TELEMETRYDECK_APPID,
clientUser: 'anonymous',
})
/**
* The main component for the Jellify app. Children are wrapped in the {@link JellifyProvider}
+170 -36
View File
@@ -1,48 +1,182 @@
import axios, { AxiosAdapter } from 'axios'
import { fetch } from 'react-native-nitro-fetch'
/* eslint-disable @typescript-eslint/no-explicit-any */
import axios, {
AxiosAdapter,
AxiosError,
AxiosHeaders,
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios'
import { nitroFetchOnWorklet } from 'react-native-nitro-fetch'
import { captureError } from '../utils/logging'
import LoggingContext from '../utils/logging/enums'
/**
* Custom Axios adapter using {@link fetch} from `react-native-nitro-fetch`.
*
* This will handle HTTP requests made through Axios by leveraging the Nitro Fetch API.
*
* @param config the Axios request config
* @returns
*/
const nitroAxiosAdapter: AxiosAdapter = async (config) => {
const response = await fetch(config.url!, {
method: config.method?.toUpperCase(),
headers: config.headers,
body: config.data,
cache: 'no-store',
})
type NitroMappedResponse = {
status: number
statusText: string
headers: Array<{ key: string; value: string }>
data: unknown
}
const responseText = await response.text()
function mapNitroJsonPayload(payload: {
status: number
statusText: string
headers: Array<{ key: string; value: string }>
bodyString?: string
}): NitroMappedResponse {
'worklet'
const data = responseText.length > 0 ? JSON.parse(responseText) : null
const headers: Record<string, string> = {}
response.headers.forEach((value, key) => {
headers[key] = value
})
const rawBody = payload.bodyString ?? ''
let data: unknown = null
if (rawBody.length > 0) {
try {
data = JSON.parse(rawBody)
} catch {
data = rawBody
}
}
return {
status: payload.status,
statusText: payload.statusText,
headers: payload.headers,
data,
status: response.status,
statusText: response.statusText,
headers,
config,
request: null,
}
}
/**
* The Axios instance for making HTTP requests.
*
* Leverages the {@link nitroAxiosAdapter} for handling requests.
*
* Default timeout is set to 60 seconds.
*/
const nitroAxiosAdapter: AxiosAdapter = async (config) => {
const url = buildFullURL(config)
// Merge axios's signal / cancelToken / timeout into one AbortController
// so native code sees a single abort event.
const controller = new AbortController()
const abortWith = (reason?: unknown) => controller.abort(reason)
const external = config.signal
const onExternalAbort = () => abortWith((external as any)?.reason)
if (external) {
if (external.aborted) abortWith((external as any).reason)
else external.addEventListener?.('abort', onExternalAbort, { once: true })
}
config.cancelToken?.promise.then((cancel) => abortWith(cancel))
let timeoutId: ReturnType<typeof setTimeout> | undefined
if (config.timeout && config.timeout > 0) {
timeoutId = setTimeout(() => {
abortWith(
new AxiosError(
`timeout of ${config.timeout}ms exceeded`,
AxiosError.ECONNABORTED,
config,
),
)
}, config.timeout)
}
try {
const headers = AxiosHeaders.from(config.headers as any).toJSON() as Record<string, unknown>
const normalizedHeaders: Record<string, string> = {}
for (const [key, value] of Object.entries(headers)) {
if (value === undefined || value === null) continue
normalizedHeaders[key] = Array.isArray(value) ? value.join(',') : String(value)
}
const mapper = mapNitroJsonPayload
const requestPromise = nitroFetchOnWorklet(
url,
{
method: (config.method ?? 'get').toUpperCase(),
headers: normalizedHeaders,
// `config.data` is already transformed by axios's transformRequest
// pipeline by the time the adapter sees it (string / FormData /
// URLSearchParams / Blob / ArrayBuffer). Don't re-serialize.
body: config.data,
signal: controller.signal,
},
mapper,
{
preferBytes: false,
},
)
const canceledPromise = new Promise<never>((_, reject) => {
const onAbort = () => {
const reason = (controller.signal as any).reason
if (reason instanceof AxiosError) {
reject(reason)
return
}
reject(
new AxiosError(reason?.message ?? 'canceled', AxiosError.ERR_CANCELED, config),
)
}
if (controller.signal.aborted) {
onAbort()
return
}
controller.signal.addEventListener('abort', onAbort, { once: true })
})
const response = await Promise.race([requestPromise, canceledPromise])
const data = response.data
const responseHeaders = new AxiosHeaders()
response.headers.forEach(({ value, key }) => responseHeaders.set(key, value))
const axiosResponse: AxiosResponse = {
data,
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
config,
request: null,
}
const validate = config.validateStatus
if (!validate || validate(response.status)) return axiosResponse
throw new AxiosError(
`Request failed with status code ${response.status}`,
Math.floor(response.status / 100) === 4
? AxiosError.ERR_BAD_REQUEST
: AxiosError.ERR_BAD_RESPONSE,
config,
null,
axiosResponse,
)
} catch (err: any) {
if (err?.name === 'AbortError' || controller.signal.aborted) {
if (err instanceof AxiosError) throw err
throw new AxiosError(err?.message ?? 'canceled', AxiosError.ERR_CANCELED, config)
}
throw err
} finally {
if (timeoutId !== undefined) clearTimeout(timeoutId)
external?.removeEventListener?.('abort', onExternalAbort)
}
}
function buildFullURL(config: InternalAxiosRequestConfig): string {
let url = config.url ?? ''
const isAbsolute = /^([a-z][a-z\d+\-.]*:)?\/\//i.test(url)
if (config.baseURL && !isAbsolute) {
url = config.baseURL.replace(/\/+$/, '') + '/' + url.replace(/^\/+/, '')
}
if (config.params) {
const serializer = config.paramsSerializer
const qs =
typeof serializer === 'function'
? serializer(config.params)
: new URLSearchParams(config.params as Record<string, string>).toString()
if (qs) url += (url.includes('?') ? '&' : '?') + qs
}
return url
}
const AXIOS_INSTANCE = axios.create({
timeout: 60000,
adapter: nitroAxiosAdapter,
+2 -1
View File
@@ -3,8 +3,9 @@ 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 TELEMETRYDECK_APPID = Config.TELEMETRYDECK_APPID ?? ''
export { OTA_UPDATE_ENABLED, IS_MAESTRO_BUILD, GLITCHTIP_DSN }
export { OTA_UPDATE_ENABLED, IS_MAESTRO_BUILD, GLITCHTIP_DSN, TELEMETRYDECK_APPID }
export const MONOCHROME_ICON_URL =
'https://raw.githubusercontent.com/Jellify-Music/App/refs/heads/main/assets/monochrome-logo.svg'
+1 -1
View File
@@ -10,7 +10,7 @@ export enum ApiLimits {
Discover = 50,
Home = 100,
Library = 400,
Similar = 5,
Similar = 10,
LibraryShuffle = 50,
}
/* eslint-enable @typescript-eslint/no-duplicate-enum-values */
+1 -1
View File
@@ -139,7 +139,7 @@ async function publishMediaLibrary(): Promise<void> {
let isRegistered = false
export default function registerAndroidAutoService(): () => void {
export function registerAndroidAutoService(): () => void {
if (Platform.OS !== 'android') return () => {}
// Guard against re-registration on JS reload — the native side keeps every
+16 -5
View File
@@ -1,8 +1,9 @@
import { CarPlay } from 'react-native-carplay'
import { Platform } from 'react-native'
import { getApi } from '../stores'
import { useAutoStore } from '../stores/auto'
import CarPlayNavigation from '../components/CarPlay/Navigation'
import config from '../../react-native.config'
import { Platform } from 'react-native'
function onConnect() {
const api = getApi()
@@ -10,9 +11,7 @@ function onConnect() {
if (api) {
CarPlay.setRootTemplate(CarPlayNavigation)
if (Platform.OS === 'ios') {
CarPlay.enableNowPlaying(true)
}
CarPlay.enableNowPlaying(true)
}
useAutoStore.getState().setIsConnected(true)
}
@@ -21,7 +20,19 @@ function onDisconnect() {
useAutoStore.getState().setIsConnected(false)
}
export default function registerCarPlayService() {
/**
* Registers the CarPlay service and sets up event listeners for connection and disconnection.
*
* Gated to only run on iOS devices, since we are excluding the `react-native-carplay`
* dependency on Android.
*
* @see {@link config} for how the `react-native-carplay` dependency is excluded from Android builds.
*
* @returns A clean-up function
*/
export function registerCarPlayService() {
if (Platform.OS !== 'ios') return () => {}
CarPlay.registerOnConnect(onConnect)
CarPlay.registerOnDisconnect(onDisconnect)
+7
View File
@@ -34,6 +34,7 @@ export type TrackExtraPayload = Record<string, unknown> & {
* You should use the
*/
mediaSourceInfo: string
blurhash?: string
}
export type SlimifiedBaseItemDto = Pick<
@@ -52,4 +53,10 @@ export type SlimifiedBaseItemDto = Pick<
| 'ImageTags'
| 'Type'
| 'AlbumPrimaryImageTag'
| 'ParentId'
| 'ParentPrimaryImageItemId'
| 'ParentPrimaryImageTag'
| 'BackdropImageTags'
| 'ParentBackdropItemId'
| 'ParentBackdropImageTags'
>
+1
View File
@@ -1,6 +1,7 @@
enum LoggingContext {
Initialization = 'Initialization',
PlaybackReporting = 'Playback Reporting',
NitroFetch = 'Nitro Fetch',
}
export default LoggingContext
+3 -3
View File
@@ -1,9 +1,9 @@
export default function buildYearsParam(yearMin?: number, yearMax?: number): string[] | undefined {
export default function buildYearsParam(yearMin?: number, yearMax?: number): number[] | 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))
const years: number[] = []
for (let y = min; y <= max; y++) years.push(y)
return years.length > 0 ? years : undefined
}
+2 -45
View File
@@ -1,8 +1,6 @@
import { BaseItemDto, ImageType } from '@jellyfin/sdk/lib/generated-client/models'
import { TrackExtraPayload } from '../../types/JellifyTrack'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
import { Api } from '@jellyfin/sdk/lib/api'
import uuid from 'react-native-uuid'
import { convertRunTimeTicksToSeconds } from './ticks-to-seconds'
import { getApi } from '../../stores'
import { DownloadManager, TrackItem } from 'react-native-nitro-player'
@@ -10,48 +8,7 @@ import { formatArtistItemsNames } from '../formatting/artist-names'
import { getBlurhashFromDto } from '../parsing/blurhash'
import { slimifyDto } from './slimify-dto'
import { getTrackMediaSourceInfo } from './track-extra-payload'
/**
* Ensures a valid session ID is returned.
* The ?? operator doesn't catch empty strings, so we need this helper.
* Empty session IDs cause MusicService to crash with "Session ID must be unique. ID="
*/
function getValidSessionId(sessionId: string | null | undefined): string {
if (sessionId && sessionId.trim() !== '') {
return sessionId
}
return uuid.v4().toString()
}
/**
* Gets the artwork URL for a track, prioritizing the track's own artwork over the album's artwork.
* Falls back to artist image if no album artwork is available.
*
* @param api The API instance
* @param item The track item
* @returns The artwork URL or undefined
*/
function getTrackArtworkUrl(api: Api, item: BaseItemDto): string | undefined {
const { AlbumId, AlbumPrimaryImageTag, ImageTags, Id, AlbumArtists, ArtistItems } = item
// Check if the track has its own Primary image
if (ImageTags?.Primary && Id) {
return getImageApi(api).getItemImageUrlById(Id, ImageType.Primary)
}
// Fall back to album artwork (only if the album has an image)
if (AlbumId && AlbumPrimaryImageTag) {
return getImageApi(api).getItemImageUrlById(AlbumId, ImageType.Primary)
}
// Fall back to first album/artist image (ArtistItems works with SlimifiedBaseItemDto)
const artistId = AlbumArtists?.[0]?.Id ?? ArtistItems?.[0]?.Id
if (artistId) {
return getImageApi(api).getItemImageUrlById(artistId, ImageType.Primary)
}
return undefined
}
import { getItemImageUrl } from '../../api/queries/image/utils'
/**
* A mapper function that can be used to get a RNTP {@link Track} compliant object
@@ -93,7 +50,7 @@ export async function mapDtoToTrack(item: BaseItemDto): Promise<TrackItem> {
album: item.Album,
duration: convertRunTimeTicksToSeconds(item.RunTimeTicks ?? 0),
url: '',
artwork: getTrackArtworkUrl(api, item),
artwork: getItemImageUrl(item, ImageType.Primary),
extraPayload: {
item: JSON.stringify(slimifyDto(item)),
mediaSourceInfo: JSON.stringify(mediaSourceInfo), // This will be populated later in the playback flow when we have the MediaSourceInfo available
+14
View File
@@ -19,6 +19,12 @@ export function slimifyDto(dto: BaseItemDto): SlimifiedBaseItemDto {
ImageTags: dto.ImageTags,
Type: dto.Type,
AlbumPrimaryImageTag: dto.AlbumPrimaryImageTag,
ParentId: dto.ParentId,
ParentPrimaryImageItemId: dto.ParentPrimaryImageItemId,
ParentPrimaryImageTag: dto.ParentPrimaryImageTag,
BackdropImageTags: dto.BackdropImageTags,
ParentBackdropItemId: dto.ParentBackdropItemId,
ParentBackdropImageTags: dto.ParentBackdropImageTags,
}
}
@@ -28,6 +34,7 @@ export default function mapTrackToSlimifiedDto(track: TrackItem): SlimifiedBaseI
return {
Id: dto.Id,
Name: dto.Name,
Album: dto.Album,
AlbumId: dto.AlbumId,
ArtistItems: dto.ArtistItems,
ImageBlurHashes: dto.ImageBlurHashes,
@@ -38,5 +45,12 @@ export default function mapTrackToSlimifiedDto(track: TrackItem): SlimifiedBaseI
ProductionYear: dto.ProductionYear,
ImageTags: dto.ImageTags,
Type: dto.Type,
AlbumPrimaryImageTag: dto.AlbumPrimaryImageTag,
ParentId: dto.ParentId,
ParentPrimaryImageItemId: dto.ParentPrimaryImageItemId,
ParentPrimaryImageTag: dto.ParentPrimaryImageTag,
BackdropImageTags: dto.BackdropImageTags,
ParentBackdropItemId: dto.ParentBackdropItemId,
ParentBackdropImageTags: dto.ParentBackdropImageTags,
}
}
-5
View File
@@ -1,5 +0,0 @@
{
"app": "Jellify",
"appID": "00000000-0000-0000-0000-000000000000",
"clientUser": "anonymous"
}