mirror of
https://github.com/anultravioletaurora/Jellify.git
synced 2026-05-08 22:49:28 -05:00
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:
@@ -1,3 +1,4 @@
|
||||
OTA_UPDATE_ENABLED=true
|
||||
IS_MAESTRO_BUILD = false
|
||||
GLITCHTIP_DSN= ""
|
||||
GLITCHTIP_DSN= ""
|
||||
TELEMETRYDECK_APPID=00000000-0000-0000-0000-000000000000
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
[](https://play.google.com/store/apps/details?id=com.cosmonautical.jellify)
|
||||
Download from [](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
|
||||
|
||||
[](https://apps.apple.com/us/app/jellify/id6736884612)
|
||||
Download from the [](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
|
||||
|
||||
[](https://reactjs.org) [](https://reactnative.dev) [](https://typescriptlang.org) [](https://github.com/prettier/prettier) [](https://github.com/anultravioletaurora/jellify/blob/main/LICENSE)
|
||||
[](https://reactjs.org) [](https://reactnative.dev) [](https://typescriptlang.org) [](https://github.com/prettier/prettier) [](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
|
||||
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"dsn": "https://glitchtip.jellify.app"
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
@@ -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"]
|
||||
@@ -3,5 +3,12 @@ module.exports = {
|
||||
ios: {},
|
||||
android: {},
|
||||
},
|
||||
dependencies: {
|
||||
'react-native-carplay': {
|
||||
platforms: {
|
||||
android: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
assets: ['./assets/fonts/'],
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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
@@ -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,4 +1,5 @@
|
||||
import { Api } from '@jellyfin/sdk'
|
||||
import { fetch } from 'react-native-nitro-fetch'
|
||||
|
||||
const PATRON_API_ENDPOINT = 'https://patrons.jellify.app'
|
||||
|
||||
|
||||
@@ -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 ?? []
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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 ?? []
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
enum LoggingContext {
|
||||
Initialization = 'Initialization',
|
||||
PlaybackReporting = 'Playback Reporting',
|
||||
NitroFetch = 'Nitro Fetch',
|
||||
}
|
||||
|
||||
export default LoggingContext
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"app": "Jellify",
|
||||
"appID": "00000000-0000-0000-0000-000000000000",
|
||||
"clientUser": "anonymous"
|
||||
}
|
||||
Reference in New Issue
Block a user