Merge branch 'main' of github.com:Jellify-Music/App into 466-feature-add-quick-connect-support

This commit is contained in:
Violet Caulfield
2025-12-07 19:47:59 -06:00
278 changed files with 12579 additions and 15017 deletions

0
.env.devrelease Normal file
View File

2
.github/FUNDING.yml vendored
View File

@@ -3,7 +3,7 @@
github: [anultravioletaurora, riteshshukla04, felinusfish, skalthoff] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] github: [anultravioletaurora, riteshshukla04, felinusfish, skalthoff] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: anultravioletaurora # Replace with a single Patreon username patreon: anultravioletaurora # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username ko_fi: jellify # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username liberapay: # Replace with a single Liberapay username

View File

@@ -0,0 +1,121 @@
name: Report an Issue
description: Did you experience an issue using Jellify? Please report it here.
title: "[BUG] "
labels: ["bug"]
type: bug
assignees:
- anultravioletaurora
body:
- type: markdown
attributes:
value: "Please provide the following information to help us investigate and resolve the issue."
- type: input
id: summary
attributes:
label: Describe the issue.
description: A clear and concise summary of the issue you are experiencing.
placeholder: "When I do X, Y happens instead of Z. This causes..."
validations:
required: true
- type: textarea
id: reproduction_steps
attributes:
label: Can the issue be reproduced?
description: "If you can reproduce the issue, please provide step-by-step instructions below. Otherwise, leave this field blank."
placeholder: |
1.
2.
3.
...
validations:
required: false
- type: textarea
id: expected_behavior
attributes:
label: What is the expected behavior?
description: What did you expect to happen in an ideal situation?
placeholder: "X should happen when Y is done."
validations:
required: true
- type: textarea
id: actual_behavior
attributes:
label: What actually happened in this case?
description: Try to provide a detailed description if you can.
placeholder: "When I did Y, Z happened instead."
validations:
required: true
- type: dropdown
id: os-types
attributes:
label: What operating system(s) were you using when the issue occurred?
description: Which operating systems did the issue happen? Please select all that apply.
multiple: true
options:
- Android
- iOS
- iPadOS
validations:
required: true
- type: input
id: device-model
attributes:
label: Which device(s) were you using when the issue occurred?
description: Please specify the model of your device.
placeholder: "Google Pixel 7 / iPhone 14 Pro"
validations:
required: true
- type: input
id: os-version
attributes:
label: Which OS version(s) did you experience this on?
description: Please specify the version of your operating system.
placeholder: "Android 16 / iOS 26"
validations:
required: true
- type: input
id: jellify-version
attributes:
label: Which version of Jellify (and OTA Version, if applicable)?
description: Which version of Jellify are you using? You can find this in the app settings. You can find this in Settings -> About.
placeholder: "Jellify 0.19.0"
validations:
required: true
- type: dropdown
id: version
attributes:
label: Which distribution are you using?
description: As Jellify is distributed on multiple platforms, please select which version you are using.
options:
- Apple App Store
- Google Play Store
- GitHub Releases
default: 0
validations:
required: true
- type: textarea
id: additional_info
attributes:
label: Any additional information goes here.
description: Any additional information, logs, or screenshots that may help in diagnosing the issue.
placeholder: "Provide any additional information here."
validations:
required: false
- type: input
id: relevant_links
attributes:
label: If there any relevant links, please provide them here.
description: Links to Discord messages, GitHub issues, or other resources related to this issue.
validations:
required: false

View File

@@ -0,0 +1,64 @@
name: Request a Feature
description: Have an idea for a new feature or improvement? We'd love to hear it!
title: "[FEATURE] "
labels: ["enhancement"]
type: feature
assignees:
- anultravioletaurora
body:
- type: markdown
attributes:
value: "Please provide the following information to help us understand and evaluate your feature request."
- type: input
id: feature_summary
attributes:
label: Summarize the requested feature.
description: A clear and concise summary of the feature you are requesting. Be sure to provide more details in the next section.
placeholder: "Jellify would benefit from a feature that..."
validations:
required: true
- type: textarea
id: problem_description
attributes:
label: Is there a problem this feature solves?
description: If this feature is solving an existing problem, please describe context and details.
placeholder: "This feature would help by..."
validations:
required: false
- type: textarea
id: proposed_solution
attributes:
label: Describe your solution in as much detail as possible.
description: Describe your proposed solution and how it would work
placeholder: "The feature could work by..."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Have you considered any alternatives?
description: What alternative solutions have you considered?
placeholder: "The alternatives I have considered are..."
validations:
required: false
- type: textarea
id: additional_info
attributes:
label: Any additional information goes here.
description: Any additional information, logs, or screenshots that may help in understanding your feature request.
placeholder: "Provide any additional information here."
validations:
required: false
- type: input
id: relevant_links
attributes:
label: If there any relevant links, please provide them here.
description: Links to Discord messages, GitHub issues, or other resources related to this issue.
validations:
required: false

View File

@@ -1,35 +0,0 @@
---
name: Bug report
about: Create a report to help us improve Jellify
title: "[BUG]"
type: bug
assignees: anultravioletaurora
---
**Describe the bug**
_A clear and concise description of what the bug is._
**How did this happen?**
_Steps to reproduce the behavior_
**What should have happened?**
_A clear and concise description of what you expected to happen._
**Screenshots / Recordings**
_A picture is worth a thousand words_
**What Device do you have?**
- Device: [e.g. iPhone 15 Pro]
- OS: [e.g. iOS26]
- Version [e.g. 0.19.1]
**Anything else we should know?**
_Add any other context about the problem here._
**Relevant Links**
_Links from Discord, TestFlight, etc_

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Discord Server
url: https://discord.gg/jellify
about: Please ask and answer questions here.

View File

@@ -1,21 +0,0 @@
---
name: Feature request
about: Suggest an idea for Jellify
title: "[FEATURE]"
type: feature
labels: enhancement
assignees: anultravioletaurora
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

72
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,72 @@
version: 2
updates:
# JavaScript/TypeScript dependencies (npm/bun)
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 15
groups:
react-native:
patterns:
- "react-native*"
- "@react-native*"
react-navigation:
patterns:
- "@react-navigation/*"
tanstack:
patterns:
- "@tanstack/*"
tamagui:
patterns:
- "tamagui"
- "@tamagui/*"
babel:
patterns:
- "@babel/*"
eslint:
patterns:
- "eslint*"
- "@eslint/*"
types:
patterns:
- "@types/*"
ignore:
# Ignore major version updates for React Native (can have breaking changes)
- dependency-name: "react-native"
update-types: ["version-update:semver-major"]
- dependency-name: "react"
update-types: ["version-update:semver-major"]
# Ruby dependencies for iOS (CocoaPods, Fastlane)
- package-ecosystem: "bundler"
directory: "/ios"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 15
# Ruby dependencies for Android (Fastlane)
- package-ecosystem: "bundler"
directory: "/android"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 15
# Root Gemfile (if used)
- package-ecosystem: "bundler"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 15
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 15

View File

@@ -1,6 +1,12 @@
name: Build Android APK name: Build Android APK
on: on:
pull_request: pull_request:
paths:
- '.github/workflows/build-android.yml'
- 'android/**'
- 'package.json'
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
@@ -12,12 +18,11 @@ jobs:
- name: 🛒 Checkout - name: 🛒 Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: 🖥 Setup Node 20 - name: 🖥 Setup Bun 1.3.2
uses: actions/setup-node@v4 uses: oven-sh/setup-bun@v2
with: with:
node-version: 20 bun-version: 1.3.2
cache: 'yarn'
- name: 💎 Set up Ruby - name: 💎 Set up Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
@@ -25,8 +30,8 @@ jobs:
bundler-cache: true bundler-cache: true
- name: 💬 Echo package.json version to Github ENV - name: 💬 Echo package.json version to Github ENV
run: echo VERSION_NUMBER=$(node -p -e "require('./package.json').version") >> $GITHUB_ENV run: echo VERSION_NUMBER=$(bun -p "require('./package.json').version") >> $GITHUB_ENV
- name: 🔑 Decode and setup release keystore - name: 🔑 Decode and setup release keystore
continue-on-error: true continue-on-error: true
run: | run: |
@@ -36,31 +41,31 @@ jobs:
else else
echo "No keystore secret found, will use debug keystore" echo "No keystore secret found, will use debug keystore"
fi fi
- uses: actions/cache@v3 - uses: actions/cache@v3
with: with:
path: | path: |
node_modules node_modules
~/.gradle/caches ~/.gradle/caches
~/.gradle/wrapper ~/.gradle/wrapper
~/.cache/turbo ~/.cache/turbo
android/.gradle android/.gradle
android/app/build android/app/build
key: ${{ runner.os }}-gradle-turbo-${{ hashFiles('**/yarn.lock', '**/build.gradle') }} key: ${{ runner.os }}-gradle-turbo-${{ hashFiles('**/build.gradle') }}
restore-keys: | restore-keys: |
${{ runner.os }}-gradle-turbo- ${{ runner.os }}-gradle-turbo-
- name: 🤖 Run yarn init-android - name: 🤖 Run bun init-android
run: yarn install --network-concurrency 1 run: bun i
- name: 🚀 Run turbo build - name: 🚀 Run turbo build
run: yarn android-build run: bun android-build
env: env:
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
- name: 📦 Upload APK for testing - name: 📦 Upload APK for testing
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@@ -16,20 +16,19 @@ jobs:
- name: 🧾 Checkout repository - name: 🧾 Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: ⚙️ Setup Node.js - name: 🖥 Setup Bun 1.3.2
uses: actions/setup-node@v4 uses: oven-sh/setup-bun@v2
with: with:
node-version: '20' bun-version: 1.3.2
cache: 'yarn'
- name: 📦 Install dependencies - name: 📦 Install dependencies
run: yarn install --network-concurrency 1 run: bun i
- name: 🧩 Build JS bundle for iOS - name: 🧩 Build JS bundle for iOS
run: | run: |
mkdir -p ios/build mkdir -p ios/build
npx react-native bundle \ bun x react-native bundle \
--platform ios \ --platform ios \
--dev false \ --dev false \
--entry-file index.js \ --entry-file index.js \
@@ -40,12 +39,9 @@ jobs:
run: | run: |
mkdir -p android/app/src/main/assets mkdir -p android/app/src/main/assets
mkdir -p android/app/src/main/res mkdir -p android/app/src/main/res
npx react-native bundle \ bun x react-native bundle \
--platform android \ --platform android \
--dev false \ --dev false \
--entry-file index.js \ --entry-file index.js \
--bundle-output android/app/src/main/assets/index.android.bundle \ --bundle-output android/app/src/main/assets/index.android.bundle \
--assets-dest android/app/src/main/res --assets-dest android/app/src/main/res

View File

@@ -1,7 +1,11 @@
name: Build iOS IPA name: Build iOS IPA
on: on:
workflow_dispatch: workflow_dispatch:
pull_request: pull_request:
paths:
- '.github/workflows/build-ios.yml'
- 'ios/**'
- 'package.json'
concurrency: concurrency:
@@ -15,21 +19,21 @@ jobs:
- name: 🛒 Checkout - name: 🛒 Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: 🖥 Setup Node 20 - name: 🖥 Setup Bun 1.3.2
uses: actions/setup-node@v4 uses: oven-sh/setup-bun@v2
with: with:
node-version: 20 bun-version: 1.3.2
- name: 💬 Echo package.json version to Github ENV - name: 💬 Echo package.json version to Github ENV
run: echo VERSION_NUMBER=$(node -p -e "require('./package.json').version") >> $GITHUB_ENV run: echo VERSION_NUMBER=$(bun -p "require('./package.json').version") >> $GITHUB_ENV
- name: 🍎 Setup Xcode - name: 🍎 Setup Xcode
uses: ./.github/actions/setup-xcode uses: ./.github/actions/setup-xcode
- name: 🍎 Run yarn init-ios:new-arch
run: yarn init-android && cd ios && bundle install && bundle exec pod install
- name: 🍎 Run bun init-ios:new-arch
run: bun run init-android && cd ios && bundle install && bundle exec pod install
- name: 🚀 Run fastlane build - name: 🚀 Run fastlane build
run: | run: |
cd ios cd ios
@@ -61,4 +65,4 @@ jobs:
*.zip *.zip
ios/build/Build/Products/Release-iphonesimulator/Jellify-Release-Simulator.zip ios/build/Build/Products/Release-iphonesimulator/Jellify-Release-Simulator.zip
retention-days: 7 retention-days: 7
if-no-files-found: warn if-no-files-found: warn

View File

@@ -1,13 +1,14 @@
name: Run Maestro Tests name: Run Maestro Tests
on: on:
pull_request: workflow_dispatch:
schedule:
- cron: "0 3 * * *"
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
build-android: build-android:
runs-on: macos-15 runs-on: macos-15
@@ -16,19 +17,18 @@ jobs:
steps: steps:
- name: 🛒 Checkout - name: 🛒 Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: 🖥 Setup Node 20 - name: 🖥 Setup Bun 1.3.2
uses: actions/setup-node@v4 uses: oven-sh/setup-bun@v2
with: with:
node-version: 20 bun-version: 1.3.2
cache: 'yarn'
- name: 💎 Set up Ruby - name: 💎 Set up Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: '3.0' ruby-version: '3.0'
bundler-cache: true bundler-cache: true
- uses: actions/cache@v3 - uses: actions/cache@v3
with: with:
path: | path: |
@@ -38,25 +38,25 @@ jobs:
~/.cache/turbo ~/.cache/turbo
android/.gradle android/.gradle
android/app/build android/app/build
key: ${{ runner.os }}-gradle-turbo-${{ hashFiles('**/yarn.lock', '**/build.gradle') }} key: ${{ runner.os }}-gradle-turbo-${{ hashFiles('**/bun.lock', '**/build.gradle') }}
restore-keys: | restore-keys: |
${{ runner.os }}-gradle-turbo- ${{ runner.os }}-gradle-turbo-
- name: 🍎 Run yarn init-android - name: 🍎 Run bun init-android
run: yarn install --network-concurrency 1 run: bun i
- name: 💬 Disable OTA Updates and Enable Maestro Build - name: 💬 Disable OTA Updates and Enable Maestro Build
run: node scripts/updateEnv.js OTA_UPDATE_ENABLED=false IS_MAESTRO_BUILD=true run: bun scripts/updateEnv.js OTA_UPDATE_ENABLED=false IS_MAESTRO_BUILD=true
- name: ✅ Validate Config Files - name: ✅ Validate Config Files
run: | run: |
node -e "JSON.parse(require('fs').readFileSync('telemetrydeck.json'))" bun -p "JSON.parse(require('fs').readFileSync('telemetrydeck.json'))"
node -e "JSON.parse(require('fs').readFileSync('glitchtip.json'))" bun -p "JSON.parse(require('fs').readFileSync('glitchtip.json'))"
- name: 🚀 Run Android fastlane build - name: 🚀 Run Android fastlane build
run: yarn android-build run: bun run android-build
- name: 📤 Upload Android Artifacts - name: 📤 Upload Android Artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
@@ -73,10 +73,10 @@ jobs:
- name: 🛒 Checkout - name: 🛒 Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: 🖥 Setup Node 20 - name: 🖥 Setup Bun 1.3.2
uses: actions/setup-node@v4 uses: oven-sh/setup-bun@v2
with: with:
node-version: 20 bun-version: 1.3.2
- name: Installing Maestro - name: Installing Maestro
shell: bash shell: bash
@@ -88,17 +88,15 @@ jobs:
java-version: '17' java-version: '17'
distribution: 'zulu' distribution: 'zulu'
- name: 💎 Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.0'
bundler-cache: true
- name: ⬇️ Download Android Artifacts - name: ⬇️ Download Android Artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: android-artifacts name: android-artifacts
path: artifacts/ path: artifacts/
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4' # Not needed with a .ruby-version, .tool-versions or mise.toml
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- name: Enable KVM group perms - name: Enable KVM group perms
shell: bash shell: bash
@@ -121,8 +119,22 @@ jobs:
cores: '4' cores: '4'
disable-animations: false disable-animations: false
avd-name: e2e_emulator avd-name: e2e_emulator
script: | script: bash scripts/maestro-android-retry.sh "https://jellyfin.jellify.app" "jerry"
node scripts/maestro-android.js "https://jellyfin.jellify.app" "jerry"
- name: 🗣️ Notify Success on Discord
if: success()
run: |
bun scripts/sendDiscordMessage.js "__**## ✅ Maestro Test Passed**__All checks completed successfully!"
env:
DISCORD_WEBHOOK_URL: ${{ secrets.MAESTRO_WEBHOOK_RESULTS }}
- name: 🗣️ Notify Failure on Discord
if: failure()
run: |
bun scripts/sendDiscordMessage.js "__**## ❌ Maestro Test Failed**__Some tests did not pass."
env:
DISCORD_WEBHOOK_URL: ${{ secrets.MAESTRO_WEBHOOK_RESULTS }}
- name: Store tests result - name: Store tests result
uses: actions/upload-artifact@v4.3.4 uses: actions/upload-artifact@v4.3.4
if: always() if: always()

View File

@@ -22,7 +22,7 @@ on:
- minor - minor
- patch - patch
- major - major
jobs: jobs:
generate-release-notes: generate-release-notes:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -33,8 +33,12 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.SIGNING_REPO_PAT }} token: ${{ secrets.SIGNING_REPO_PAT }}
- name: 🖥 Setup Bun 1.3.2
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.2
- name: 🧠 Collect commit messages - name: 🧠 Collect commit messages
id: commits id: commits
run: | run: |
@@ -54,10 +58,10 @@ jobs:
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
- name: 📜 Generate release notes using Node.js - name: 📜 Generate release notes using Bun
run: | run: |
yarn install --network-concurrency 1 bun i
node scripts/generate-release-notes.js "${{ steps.commits.outputs.messages }}" bun scripts/generate-release-notes.js "${{ steps.commits.outputs.messages }}"
env: env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -79,32 +83,32 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
token: ${{ secrets.SIGNING_REPO_PAT }} token: ${{ secrets.SIGNING_REPO_PAT }}
- name: 🖥 Setup Node 20 - name: 🖥 Setup Bun 1.3.2
uses: actions/setup-node@v4 uses: oven-sh/setup-bun@v2
with: with:
node-version: 20 bun-version: 1.3.2
- name: 💎 Set up Ruby - name: 💎 Set up Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: '3.0' ruby-version: '3.0'
bundler-cache: true bundler-cache: true
- name: 🍎 Run yarn init-android - name: 🍎 Run bun init-android # you never actually run yarn init-android, so I kept the same
run: yarn install --network-concurrency 1 run: bun i
- name: Version Up - name: Version Up
if: ${{ github.event.inputs['version-bump'] != 'No Bump' }} if: ${{ github.event.inputs['version-bump'] != 'No Bump' }}
run: yarn react-native bump-version --type ${{ github.event.inputs['version-bump'] }} run: bun x react-native bump-version --type ${{ github.event.inputs['version-bump'] }} # Is this supposed to be like npx, or some special yarn thing?
- id: setver - id: setver
run: echo "version=$(node -p -e "require('./package.json').version")" >> $GITHUB_OUTPUT run: echo "version=$(bun -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: 💬 Echo package.json version to Github ENV - name: 💬 Echo package.json version to Github ENV
run: echo VERSION_NUMBER=$(node -p -e "require('./package.json').version") >> $GITHUB_ENV run: echo VERSION_NUMBER=$(bun -p "require('./package.json').version") >> $GITHUB_ENV
- name: 🔑 Setup release keystore - name: 🔑 Setup release keystore
run: | run: |
if [ -n "${{ secrets.ANDROID_SIGNING_BASE64 }}" ]; then if [ -n "${{ secrets.ANDROID_SIGNING_BASE64 }}" ]; then
@@ -114,7 +118,7 @@ jobs:
echo "ERROR: No keystore secret found!" echo "ERROR: No keystore secret found!"
exit 1 exit 1
fi fi
- name: 🔑 Setup Play Store credentials - name: 🔑 Setup Play Store credentials
run: | run: |
if [ -n "$PLAY_STORE_CREDENTIALS" ]; then if [ -n "$PLAY_STORE_CREDENTIALS" ]; then
@@ -126,8 +130,8 @@ jobs:
fi fi
env: env:
PLAY_STORE_CREDENTIALS: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT }} PLAY_STORE_CREDENTIALS: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT }}
- name: 🤫 Output TelemetryDeck Secrets to TelemetryDeck.json - name: 🤫 Output TelemetryDeck Secrets to TelemetryDeck.json
run: | run: |
echo "{" > telemetrydeck.json echo "{" > telemetrydeck.json
echo "\"appID\": \"${{ secrets.TELEMETRYDECK_APPID }}\"," >> telemetrydeck.json echo "\"appID\": \"${{ secrets.TELEMETRYDECK_APPID }}\"," >> telemetrydeck.json
@@ -135,25 +139,29 @@ jobs:
echo "\"app\": \"Jellify\"" >> telemetrydeck.json echo "\"app\": \"Jellify\"" >> telemetrydeck.json
echo "}" >> telemetrydeck.json echo "}" >> telemetrydeck.json
- name: 🤫 Output Glitchtip Secrets to Glitchtip.json - name: 🤫 Output Glitchtip Secrets to Glitchtip.json
run: | run: |
echo "{" > glitchtip.json echo "{" > glitchtip.json
echo "\"dsn\": \"${{ secrets.GLITCHTIP_DSN }}\"" >> glitchtip.json echo "\"dsn\": \"${{ secrets.GLITCHTIP_DSN }}\"" >> glitchtip.json
echo "}" >> glitchtip.json echo "}" >> glitchtip.json
- name: 📝 Output Glitchip secrets to .env
run: |
echo "GLITCHTIP_DSN=${{ secrets.GLITCHTIP_DSN }}" >> .env
- name: ✅ Validate Config Files - name: ✅ Validate Config Files
run: | run: |
node -e "JSON.parse(require('fs').readFileSync('telemetrydeck.json'))" bun -p "JSON.parse(require('fs').readFileSync('telemetrydeck.json'))"
node -e "JSON.parse(require('fs').readFileSync('glitchtip.json'))" bun -p "JSON.parse(require('fs').readFileSync('glitchtip.json'))"
- name: 🚀 Run Android fastlane deploy - name: 🚀 Run Android fastlane deploy
run: yarn fastlane:android:deploy run: bun run fastlane:android:deploy
env: env:
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
- name: 📤 Upload Android Artifacts - name: 📤 Upload Android Artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
@@ -168,38 +176,38 @@ jobs:
version: ${{ steps.setver.outputs.version }} version: ${{ steps.setver.outputs.version }}
needs: [generate-release-notes] needs: [generate-release-notes]
steps: steps:
- name: 🛒 Checkout - name: 🛒 Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
token: ${{ secrets.SIGNING_REPO_PAT }} token: ${{ secrets.SIGNING_REPO_PAT }}
- name: 🖥 Setup Node 20 - name: 🖥 Setup Bun 1.3.2
uses: actions/setup-node@v4 uses: oven-sh/setup-bun@v2
with: with:
node-version: 20 bun-version: 1.3.2
- name: 🍎 Setup Xcode - name: 🍎 Setup Xcode
uses: ./.github/actions/setup-xcode uses: ./.github/actions/setup-xcode
- name: 🍎 Run yarn init-ios:new-arch - name: 🍎 Run run init-ios:new-arch
run: yarn init-ios:new-arch run: bun run init-ios:new-arch
- name: Version Up - name: Version Up
if: ${{ github.event.inputs['version-bump'] != 'No Bump' }} if: ${{ github.event.inputs['version-bump'] != 'No Bump' }}
run: yarn react-native bump-version --type ${{ github.event.inputs['version-bump'] }} run: bun x react-native bump-version --type ${{ github.event.inputs['version-bump'] }} # Is this supposed to be like npx, or some special yarn thing?
- id: setver - id: setver
run: echo "version=$(node -p -e "require('./package.json').version")" >> $GITHUB_OUTPUT run: echo "version=$(bun -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: 💬 Echo package.json version to Github ENV - name: 💬 Echo package.json version to Github ENV
run: echo VERSION_NUMBER=$(node -p -e "require('./package.json').version") >> $GITHUB_ENV run: echo VERSION_NUMBER=$(bun -p "require('./package.json').version") >> $GITHUB_ENV
- name: 🤫 Output App Store Connect API Key JSON to Fastlane - name: 🤫 Output App Store Connect API Key JSON to Fastlane
run: echo -e '${{ secrets.APPSTORE_CONNECT_API_KEY_JSON }}' > appstore_connect_api_key.json run: echo -e '${{ secrets.APPSTORE_CONNECT_API_KEY_JSON }}' > appstore_connect_api_key.json
working-directory: ./ios/fastlane working-directory: ./ios/fastlane
- name: 🤫 Output TelemetryDeck Secrets to TelemetryDeck.json - name: 🤫 Output TelemetryDeck Secrets to TelemetryDeck.json
run: | run: |
echo "{" > telemetrydeck.json echo "{" > telemetrydeck.json
echo "\"appID\": \"${{ secrets.TELEMETRYDECK_APPID }}\"," >> telemetrydeck.json echo "\"appID\": \"${{ secrets.TELEMETRYDECK_APPID }}\"," >> telemetrydeck.json
@@ -207,20 +215,24 @@ jobs:
echo "\"app\": \"Jellify\"" >> telemetrydeck.json echo "\"app\": \"Jellify\"" >> telemetrydeck.json
echo "}" >> telemetrydeck.json echo "}" >> telemetrydeck.json
- name: 🤫 Output Glitchtip Secrets to Glitchtip.json - name: 🤫 Output Glitchtip Secrets to Glitchtip.json
run: | run: |
echo "{" > glitchtip.json echo "{" > glitchtip.json
echo "\"dsn\": \"${{ secrets.GLITCHTIP_DSN }}\"" >> glitchtip.json echo "\"dsn\": \"${{ secrets.GLITCHTIP_DSN }}\"" >> glitchtip.json
echo "}" >> glitchtip.json echo "}" >> glitchtip.json
- name: 📝 Output Glitchip secrets to .env
run: |
echo "GLITCHTIP_DSN=${{ secrets.GLITCHTIP_DSN }}" >> .env
- name: ✅ Validate Config Files - name: ✅ Validate Config Files
run: | run: |
node -e "JSON.parse(require('fs').readFileSync('telemetrydeck.json'))" bun -p "JSON.parse(require('fs').readFileSync('telemetrydeck.json'))"
node -e "JSON.parse(require('fs').readFileSync('glitchtip.json'))" bun -p "JSON.parse(require('fs').readFileSync('glitchtip.json'))"
- name: 🚀 Run iOS fastlane build and publish to TestFlight - name: 🚀 Run iOS fastlane build and publish to TestFlight
run: yarn fastlane:ios:beta run: bun run fastlane:ios:beta
env: env:
APPSTORE_CONNECT_API_KEY_JSON: ${{ secrets.APPSTORE_CONNECT_API_KEY_JSON }} APPSTORE_CONNECT_API_KEY_JSON: ${{ secrets.APPSTORE_CONNECT_API_KEY_JSON }}
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }} FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
@@ -241,7 +253,7 @@ jobs:
- name: 🛒 Checkout Repo - name: 🛒 Checkout Repo
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
token: ${{ secrets.SIGNING_REPO_PAT }} token: ${{ secrets.SIGNING_REPO_PAT }}
- name: ❌ Fail if selected job failed - name: ❌ Fail if selected job failed
run: | run: |
@@ -268,8 +280,13 @@ jobs:
exit 1 exit 1
fi fi
- name: 🖥 Setup Bun 1.3.2
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.2
- name: 📦 Install dependencies - name: 📦 Install dependencies
run: yarn install --network-concurrency 1 run: bun i
- name: ⬇️ Download Android Artifacts - name: ⬇️ Download Android Artifacts
if: ${{ github.event.inputs['build-platform'] == 'Android' || github.event.inputs['build-platform'] == 'Both' }} if: ${{ github.event.inputs['build-platform'] == 'Android' || github.event.inputs['build-platform'] == 'Both' }}
@@ -287,8 +304,8 @@ jobs:
- name: Version Up - name: Version Up
if: ${{ github.event.inputs['version-bump'] != 'No Bump' }} if: ${{ github.event.inputs['version-bump'] != 'No Bump' }}
run: yarn react-native bump-version --type ${{ github.event.inputs['version-bump'] }} run: bun x react-native bump-version --type ${{ github.event.inputs['version-bump'] }} # Is this supposed to be like npx, or some special yarn thing?
- name: 🔢 Set artifact version numbers - name: 🔢 Set artifact version numbers
run: | run: |
VERSION=${{ needs.publish-ios.outputs.version || needs.publish-android.outputs.version }} VERSION=${{ needs.publish-ios.outputs.version || needs.publish-android.outputs.version }}
@@ -331,10 +348,10 @@ jobs:
- name: 🗣️ Notify on Discord - name: 🗣️ Notify on Discord
run: | run: |
cd ios cd ios
bundle install && bundle exec fastlane notifyOnDiscord bundle install && bundle exec fastlane notifyOnDiscordForRelease
cd .. cd ..
env: env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
APP_VERSION: ${{ needs.publish-ios.outputs.version || needs.publish-android.outputs.version }} APP_VERSION: ${{ needs.publish-ios.outputs.version || needs.publish-android.outputs.version }}
release_url: ${{ steps.githubRelease.outputs.html_url }} 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 }}

View File

@@ -0,0 +1,35 @@
name: Publish Over-the-Air Update PR
on:
pull_request:
paths:
- 'src/**'
- 'App.tsx'
- '.github/workflows/publish-ota-update-pr.yml'
- 'scripts/ota-PR.sh'
- 'scripts/getRandomVersion.sh'
jobs:
publish-ota-update:
runs-on: macos-15
steps:
- name: 🛒 Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.SIGNING_REPO_PAT }}
- name: 🖥 Setup Bun 1.3.2
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.2
- name: 🥟 Run bun
run: bun i
- name: 👩‍💻 Configure Git
run: |
git config --global user.email "violet@cosmonautical.cloud"
git config --global user.name "anultravioletaurora"
- name: 🤖 Publish Android Update
run: bun run sendOTA:PR ${{ github.event.pull_request.number }}
env:
SIGNING_REPO_PAT: ${{ secrets.SIGNING_REPO_PAT }}

View File

@@ -1,7 +1,7 @@
name: Publish Over-the-Air Update name: Publish Over-the-Air Update
on: on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
publish-ota-update: publish-ota-update:
runs-on: macos-15 runs-on: macos-15
@@ -11,25 +11,25 @@ jobs:
with: with:
token: ${{ secrets.SIGNING_REPO_PAT }} token: ${{ secrets.SIGNING_REPO_PAT }}
- name: 🖥 Setup Node 20 - name: 🖥 Setup Bun 1.3.2
uses: actions/setup-node@v4 uses: oven-sh/setup-bun@v2
with: with:
node-version: 20 bun-version: 1.3.2
- name: 🥟 Run bun install
run: bun i
- name: 🧵 Run yarn
run: yarn install --network-concurrency 1
- name: 👩‍💻 Configure Git - name: 👩‍💻 Configure Git
run: | run: |
git config --global user.email "violet@cosmonautical.cloud" git config --global user.email "violet@cosmonautical.cloud"
git config --global user.name "anultravioletaurora" git config --global user.name "anultravioletaurora"
- name: 🤖 Publish Android Update - name: 🤖 Publish Android Update
run: yarn sendOTA:android run: bun run sendOTA:android
env: env:
SIGNING_REPO_PAT: ${{ secrets.SIGNING_REPO_PAT }} SIGNING_REPO_PAT: ${{ secrets.SIGNING_REPO_PAT }}
- name: 🍎 Publish iOS Update - name: 🍎 Publish iOS Update
run: yarn sendOTA:iOS run: bun run sendOTA:iOS
env: env:
SIGNING_REPO_PAT: ${{ secrets.SIGNING_REPO_PAT }} SIGNING_REPO_PAT: ${{ secrets.SIGNING_REPO_PAT }}

View File

@@ -16,22 +16,33 @@ jobs:
- name: 🛒 Checkout - name: 🛒 Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: 🖥 Setup Node 20 - name: 🖥 Setup Bun 1.3.2
uses: actions/setup-node@v4 uses: oven-sh/setup-bun@v2
with: with:
node-version: 20 bun-version: 1.3.2
- name: 📦 Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
.jest-cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
- name: 💬 Echo package.json version to Github ENV - name: 💬 Echo package.json version to Github ENV
run: echo VERSION_NUMBER=$(node -p -e "require('./package.json').version") >> $GITHUB_ENV run: echo VERSION_NUMBER=$(bun -p "require('./package.json').version") >> $GITHUB_ENV
- name: 🤖 Run yarn init-android - name: 🤖 Run yarn init-android
run: yarn install --network-concurrency 1 run: bun i
- name: 🔍 Run yarn tsc - name: 🔍 Run yarn tsc
run: yarn tsc run: bun tsc
- name: 🧪 Run yarn test - name: 🧪 Run yarn test
run: yarn test run: CI=true bun run test
- name: 🦋 Check Styling - name: 🦋 Check Styling
run: yarn format:check run: bun run format:check

21
.gitignore vendored
View File

@@ -37,8 +37,10 @@ local.properties
# node.js # node.js
# #
node_modules/ node_modules/
npm-debug.log
yarn-error.log # Don't think these will exist anymore!
# npm-debug.log
# yarn-error.log
# fastlane # fastlane
# #
@@ -64,14 +66,15 @@ yarn-error.log
# testing # testing
/coverage /coverage
.jest-cache
# Yarn # Yarn
.yarn/* #.yarn/*
!.yarn/patches #!.yarn/patches
!.yarn/plugins #!.yarn/plugins
!.yarn/releases #!.yarn/releases
!.yarn/sdks #!.yarn/sdks
!.yarn/versions #!.yarn/versions
# Expo # Expo
.expo .expo
@@ -80,4 +83,4 @@ web-build/
# Maestro Output # Maestro Output
video.mp4 video.mp4
.github/copilot-instructions.md .github/copilot-instructions.md

View File

@@ -1 +1 @@
yarn lint-staged bun lint-staged

86
App.tsx
View File

@@ -1,72 +1,80 @@
import './gesture-handler' import './gesture-handler'
import React, { useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import 'react-native-url-polyfill/auto' import 'react-native-url-polyfill/auto'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client' import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import Jellify from './src/components/jellify' import Jellify from './src/components/jellify'
import { TamaguiProvider } from 'tamagui' import { TamaguiProvider } from 'tamagui'
import { Platform, useColorScheme } from 'react-native' import { LogBox, Platform, useColorScheme } from 'react-native'
import jellifyConfig from './tamagui.config' import jellifyConfig from './tamagui.config'
import { queryClientPersister } from './src/constants/storage' import { queryClientPersister } from './src/constants/storage'
import { ONE_DAY, queryClient } from './src/constants/query-client' import { ONE_DAY, queryClient } from './src/constants/query-client'
import { GestureHandlerRootView } from 'react-native-gesture-handler' import { GestureHandlerRootView } from 'react-native-gesture-handler'
import TrackPlayer, { import TrackPlayer, {
AndroidAudioContentType, AndroidAudioContentType,
AppKilledPlaybackBehavior,
IOSCategory, IOSCategory,
IOSCategoryOptions, IOSCategoryOptions,
} from 'react-native-track-player' } from 'react-native-track-player'
import { CAPABILITIES } from './src/player/constants' import { CAPABILITIES } from './src/player/constants'
import { SafeAreaProvider } from 'react-native-safe-area-context' import { SafeAreaProvider } from 'react-native-safe-area-context'
import { NavigationContainer } from '@react-navigation/native' import { NavigationContainer } from '@react-navigation/native'
import { JellifyDarkTheme, JellifyLightTheme } from './src/components/theme' import { JellifyDarkTheme, JellifyLightTheme, JellifyOLEDTheme } from './src/components/theme'
import { requestStoragePermission } from './src/utils/permisson-helpers' import { requestStoragePermission } from './src/utils/permisson-helpers'
import ErrorBoundary from './src/components/ErrorBoundary' import ErrorBoundary from './src/components/ErrorBoundary'
import OTAUpdateScreen from './src/components/OtaUpdates' import OTAUpdateScreen from './src/components/OtaUpdates'
import { usePerformanceMonitor } from './src/hooks/use-performance-monitor' import { usePerformanceMonitor } from './src/hooks/use-performance-monitor'
import navigationRef from './navigation' import navigationRef from './navigation'
import { PROGRESS_UPDATE_EVENT_INTERVAL } from './src/player/config' import { BUFFERS, PROGRESS_UPDATE_EVENT_INTERVAL } from './src/player/config'
import { useThemeSetting } from './src/stores/settings/app' import { useThemeSetting } from './src/stores/settings/app'
LogBox.ignoreAllLogs()
export default function App(): React.JSX.Element { export default function App(): React.JSX.Element {
// Add performance monitoring to track app-level re-renders // Add performance monitoring to track app-level re-renders
const performanceMetrics = usePerformanceMonitor('App', 3) const performanceMetrics = usePerformanceMonitor('App', 3)
const [playerIsReady, setPlayerIsReady] = useState<boolean>(false) const [playerIsReady, setPlayerIsReady] = useState<boolean>(false)
const playerInitializedRef = useRef<boolean>(false)
/** useEffect(() => {
* Enhanced Android buffer settings for gapless playback // Guard against double initialization (React StrictMode, hot reload)
* if (playerInitializedRef.current) return
* @see playerInitializedRef.current = true
*/
const buffers =
Platform.OS === 'android'
? {
maxCacheSize: 50 * 1024, // 50MB cache
maxBuffer: 30, // 30 seconds buffer
playBuffer: 2.5, // 2.5 seconds play buffer
backBuffer: 5, // 5 seconds back buffer
}
: {}
TrackPlayer.setupPlayer({ TrackPlayer.setupPlayer({
autoHandleInterruptions: true, autoHandleInterruptions: true,
iosCategory: IOSCategory.Playback, iosCategory: IOSCategory.Playback,
iosCategoryOptions: [IOSCategoryOptions.AllowAirPlay, IOSCategoryOptions.AllowBluetooth], iosCategoryOptions: [
androidAudioContentType: AndroidAudioContentType.Music, IOSCategoryOptions.AllowAirPlay,
minBuffer: 30, // 30 seconds minimum buffer IOSCategoryOptions.AllowBluetooth,
...buffers, ],
}) androidAudioContentType: AndroidAudioContentType.Music,
.then(() => minBuffer: 30, // 30 seconds minimum buffer
TrackPlayer.updateOptions({ ...BUFFERS,
capabilities: CAPABILITIES,
notificationCapabilities: CAPABILITIES,
// Reduced interval for smoother progress tracking and earlier prefetch detection
progressUpdateEventInterval: PROGRESS_UPDATE_EVENT_INTERVAL,
}),
)
.finally(() => {
setPlayerIsReady(true)
requestStoragePermission()
}) })
.then(() =>
TrackPlayer.updateOptions({
capabilities: CAPABILITIES,
notificationCapabilities: CAPABILITIES,
// Reduced interval for smoother progress tracking and earlier prefetch detection
progressUpdateEventInterval: PROGRESS_UPDATE_EVENT_INTERVAL,
// Stop playback and remove notification when app is killed to prevent battery drain
android: {
appKilledPlaybackBehavior:
AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification,
},
}),
)
.catch((error) => {
// Player may already be initialized (e.g., after hot reload)
// This is expected and not a fatal error
console.log('[TrackPlayer] Setup caught:', error?.message ?? error)
})
.finally(() => {
setPlayerIsReady(true)
requestStoragePermission()
})
}, []) // Empty deps - only run once on mount
const [reloader, setReloader] = useState(0) const [reloader, setReloader] = useState(0)
@@ -111,7 +119,9 @@ function Container({ playerIsReady }: { playerIsReady: boolean }): React.JSX.Ele
: JellifyLightTheme : JellifyLightTheme
: theme === 'dark' : theme === 'dark'
? JellifyDarkTheme ? JellifyDarkTheme
: JellifyLightTheme : theme === 'oled'
? JellifyOLEDTheme
: JellifyLightTheme
} }
> >
<GestureHandlerRootView> <GestureHandlerRootView>

View File

@@ -1,4 +1,4 @@
# 👩‍💻 Contributing # Contributing
We are open to any developer that wants to lend their hand at _Jellify_ development, and developers can join our [Discord server](https://discord.gg/jellify) to get in contact with us. We are open to any developer that wants to lend their hand at _Jellify_ development, and developers can join our [Discord server](https://discord.gg/jellify) to get in contact with us.
@@ -12,13 +12,13 @@ Here's the best way to get started:
- Submit a Pull Request to sync the main repository with your fork - Submit a Pull Request to sync the main repository with your fork
- Profit! 🎉 - Profit! 🎉
## 🏃‍♀️Running Locally ## Running Locally
### ⚛️ Universal Dependencies ### Universal Dependencies
- [Ruby](https://www.ruby-lang.org/en/documentation/installation/) for Fastlane - [Ruby](https://www.ruby-lang.org/en/documentation/installation/) for Fastlane
- [NodeJS v22](https://nodejs.org/en/download) for React Native - [NodeJS v22](https://nodejs.org/en/download) for React Native
- [Maestro](https://docs.maestro.dev/getting-started/installing-maestro) for running E2E tests - [Bun](https://bun.sh/) for managing dependencies
### 🍎 iOS ### 🍎 iOS
@@ -31,21 +31,21 @@ Here's the best way to get started:
##### Setup ##### Setup
- Clone this repository - Clone this repository
- Run `yarn init-ios:new-arch` to initialize the project - Run `bun init-ios:new-arch` to initialize the project
- This will install `npm` packages, install `bundler` and required gems, and install required CocoaPods with [React Native's New Architecture](https://reactnative.dev/blog/2024/10/23/the-new-architecture-is-here#what-is-the-new-architecture) - This will install `npm` packages, install `bundler` and required gems, and install required CocoaPods with [React Native's New Architecture](https://reactnative.dev/blog/2024/10/23/the-new-architecture-is-here#what-is-the-new-architecture)
- In the `ios` directory, run `fastlane match development --readonly` to fetch the development signing certificates - In the `ios` directory, run `fastlane match development --readonly` to fetch the development signing certificates
- _You will need access to the "Jellify Signing" private repository_ - _You will need access to the "Jellify Signing" private repository_
##### Running ##### Running
- Run `yarn start` to start the dev server - Run `bun start` to start the dev server
- Open the `Jellify.xcodeworkspace` with Xcode, _not_ the `Jellify.xcodeproject` - Open the `Jellify.xcodeworkspace` with Xcode, _not_ the `Jellify.xcodeproject`
- Run either on a device or in the simulator - Run either on a device or in the simulator
- _You will need to wait for Xcode to finish it's "Indexing" step_ - _You will need to wait for Xcode to finish it's "Indexing" step_
##### Building ##### Building
- To create a build, run `yarn fastlane:ios:build` to use fastlane to compile an `.ipa` - To create a build, run `bun fastlane:ios:build` to use fastlane to compile an `.ipa`
### 🤖 Android ### 🤖 Android
@@ -59,21 +59,22 @@ Here's the best way to get started:
##### Setup ##### Setup
- Clone this repository - Clone this repository
- Run `yarn install` to install `npm` packages - Run `bun install` to install `npm` packages
##### Running ##### Running
- Run `yarn start` to start the dev server - Run `bun start` to start the dev server
- Open the `android` folder with Android Studio - Open the `android` folder with Android Studio
- _Android Studio should automatically grab the "Run Configurations" and initialize Gradle_ - _Android Studio should automatically grab the "Run Configurations" and initialize Gradle_
- Run either on a device or in the simulator - Run either on a device or in the simulator
##### Building ##### Building
- To create a build, run `yarn fastlane:android:build` to use fastlane to compile an `.apk` for all architectures - To create a build, run `bun fastlane:android:build` to use fastlane to compile an `.apk` for all architectures
- Alternatively, run `cd android; ./gradlew assembleRelease` to use Gradle to compile an `.apk`
#### References #### References
- [Setting up Android SDK](https://developer.android.com/about/versions/14/setup-sdk) - [Setting up Android SDK](https://developer.android.com/about/versions/14/setup-sdk)
- [ANDROID_HOME not being set](https://stackoverflow.com/questions/26356359/error-android-home-is-not-set-and-android-command-not-in-your-path-you-must/54888107#54888107) - [ANDROID_HOME not being set](https://stackoverflow.com/questions/26356359/error-android-home-is-not-set-and-android-command-not-in-your-path-you-must/54888107#54888107)
- [Android Auto app not showing up](https://www.reddit.com/r/AndroidAuto/s/LGYHoSPdXm) - [Android Auto app not showing up](https://www.reddit.com/r/AndroidAuto/s/LGYHoSPdXm)

View File

@@ -2,8 +2,8 @@
<img alt='Jellify logo' src='assets/transparent-banner.png' width="600" height="300" /> <img alt='Jellify logo' src='assets/transparent-banner.png' width="600" height="300" />
</p> </p>
[![Latest Version](https://img.shields.io/github/package-json/version/anultravioletaurora/jellify?label=Latest%20Version&color=indigo)](https://github.com/anultravioletaurora/Jellify/releases) [![Latest Version](https://img.shields.io/github/package-json/version/anultravioletaurora/jellify?label=Latest%20Version&color=indigo)](https://github.com/anultravioletaurora/Jellify/releases) [![iTunes App Store](https://img.shields.io/itunes/v/6736884612?logo=app-store&logoColor=white&label=Apple%20App%20Store&labelColor=%60&color=blue)](https://apps.apple.com/us/app/jellify/id6736884612) [![Google Play](https://img.shields.io/badge/Google%20Play-Download-red?logo=googleplay&logoColor=white)](https://play.google.com/store/apps/details?id=com.cosmonautical.jellify)
[![publish-beta](https://github.com/anultravioletaurora/Jellify/actions/workflows/publish-beta.yml/badge.svg?branch=main)](https://github.com/anultravioletaurora/Jellify/actions/workflows/publish-beta.yml) [![Publish Over-the-Air Update](https://github.com/Jellify-Music/App/actions/workflows/publish-ota-update.yml/badge.svg)](https://github.com/Jellify-Music/App/actions/workflows/publish-ota-update.yml)
[![Sponsors](https://img.shields.io/github/sponsors/anultravioletaurora?label=Project%20Sponsors&color=magenta)](https://github.com/sponsors/anultravioletaurora) [![Patreon](https://img.shields.io/badge/Patreon-F96854?logo=patreon&logoColor=white)](https://patreon.com/anultravioletaurora?utm_medium=unknown&utm_source=join_link&utm_campaign=creatorshare_creator&utm_content=copyLink) [![Sponsors](https://img.shields.io/github/sponsors/anultravioletaurora?label=Project%20Sponsors&color=magenta)](https://github.com/sponsors/anultravioletaurora) [![Patreon](https://img.shields.io/badge/Patreon-F96854?logo=patreon&logoColor=white)](https://patreon.com/anultravioletaurora?utm_medium=unknown&utm_source=join_link&utm_campaign=creatorshare_creator&utm_content=copyLink)
@@ -19,7 +19,7 @@
- [Info](#info) - [Info](#info)
- [Downloading](#downloading) - [Downloading](#downloading)
- [Screenshots](#screenshots) - [Screenshots](#screenshots)
- [Features](#features) - [Features and Roadmap](#features)
- [Built with](#built-with-good-stuff) - [Built with](#built-with-good-stuff)
- [Support](#support-the-project) - [Support](#support-the-project)
- [Special Thanks](#special-thanks) - [Special Thanks](#special-thanks)
@@ -65,6 +65,10 @@ These projects are **not** required to use _Jellify_, but are recommended by us
### Android ### Android
[![Google Play](https://img.shields.io/badge/Google%20Play-Download-red?logo=googleplay&logoColor=white)](https://play.google.com/store/apps/details?id=com.cosmonautical.jellify)
#### Direct .APK Download
Head to [releases](https://github.com/Jellify-Music/App/releases) to download the required .APK directly. Head to [releases](https://github.com/Jellify-Music/App/releases) to download the required .APK directly.
Also there is [obtanium](https://github.com/ImranR98/Obtainium) to which you can add Jellify as a repo to use the above releases as a repository. Also there is [obtanium](https://github.com/ImranR98/Obtainium) to which you can add Jellify as a repo to use the above releases as a repository.
@@ -73,6 +77,8 @@ For Obtanium, click "Add App", put "https://github.com/Jellify-Music/App" as the
### iOS ### iOS
[![iTunes App Store](https://img.shields.io/itunes/v/6736884612?logo=app-store&logoColor=white&label=Apple%20App%20Store&labelColor=%60&color=blue)](https://apps.apple.com/us/app/jellify/id6736884612)
#### The TestFlight Way #### The TestFlight Way
Join the [TestFlight](https://testflight.apple.com/join/etVSc7ZQ) and install the latest version from there Join the [TestFlight](https://testflight.apple.com/join/etVSc7ZQ) and install the latest version from there
@@ -103,13 +109,9 @@ Install via [Altstore](https://altstore.io) or your favorite sideloading utility
**Artists** **Artists**
<p align="center"> <p align="center">
<img src="screenshots/library_artists.png" alt="Library Artists" width="275" height="600"> <img src="screenshots/library_artists.png" alt="Library Artists" width="275" height="600" />
</p> <img src="screenshots/library_albums.PNG" alt="Library Albums" width="275" height="600" />
<img src="screenshots/library_downloaded_tracks.PNG" alt="Library Tracks" width="275" height="600" />
**Downloaded Tracks**
<p align="center">
<img src="screenshots/library_downloaded_tracks.PNG" alt="Library Tracks" width="275" height="600">
</p> </p>
**Artist View** **Artist View**
@@ -171,7 +173,7 @@ Install via [Altstore](https://altstore.io) or your favorite sideloading utility
### Current ### Current
- Available via Testflight and Android APK - Available via [Play Store](https://play.google.com/store/apps/details?id=com.cosmonautical.jellify&pcampaignid=web_share), [App Store](https://apps.apple.com/us/app/jellify/id6736884612), [Testflight](https://testflight.apple.com/join/etVSc7ZQ), and Android APKs
- APKs are associated with each [release](https://github.com/anultravioletaurora/Jellify/releases) - APKs are associated with each [release](https://github.com/anultravioletaurora/Jellify/releases)
- Light and Dark modes - Light and Dark modes
- Home screen access to previously played tracks, artists, and your playlists - Home screen access to previously played tracks, artists, and your playlists
@@ -182,21 +184,44 @@ Install via [Altstore](https://altstore.io) or your favorite sideloading utility
- Offline Playback - Offline Playback
- Support for Jellyfin Instant Mixes - Support for Jellyfin Instant Mixes
- Over-the-Air Updates - Over-the-Air Updates
- Powered by [react-native-ota-hot-update](https://github.com/vantuan88291/react-native-ota-hot-update), incremental app updates are automatically fetched and applied from our [App Bundles Repository](https://github.com/Jellify-Music/App-Bundles) - Powered by [react-native-nitro-ota](https://github.com/riteshshukla04/react-native-nitro-ota), incremental app updates are automatically fetched and applied from our [App Bundles Repository](https://github.com/Jellify-Music/App-Bundles)
- Shuffling - Shuffling
- Switching Music Libraries - Switching Music Libraries
- Google Cast Support - Google Cast Support (still in early stages)
- Storage UI Manager
### Roadmap (in order of priority) ### Roadmap
#### 1.1.0 (Socket To Me Baby) - March '26
- Android Auto/CarPlay Support
- Websocket Support (Server online status)
- Home Screen Updates
- Discover Screen Updates
- Artist Screen Redesign
- Library Redesign
- Quick Connect Support
- Allow Self-Signed Certificates
#### 1.2.0 (We Made a Language For Us Two...) - June '26
- Collaborative Playlists
- App Customization Options
- Desktop Support (Experimental)
#### 1.3.0 (Playin' All Day) - September '26
- Autoplay Integration
- Tablet Support
#### 2.0.0 - December '26
- Gapless Playback
- Seerr (formerly Jellyseerr) Integration
- JellyJam
- EQ Controls
#### 3.0.0 - TBD
- Watch Support
\*This is subject to change
- ["Smart Shuffle"](https://github.com/anultravioletaurora/Jellify/issues/57)
- [CarPlay / Android Auto Support](https://github.com/anultravioletaurora/Jellify/issues/5)
- [App Store / Google Play / FDroid Release](https://github.com/anultravioletaurora/Jellify/issues/361)
- [Translations](https://github.com/anultravioletaurora/Jellify/issues/317)
- [Web / Desktop support](https://github.com/anultravioletaurora/Jellify/issues/71)
- [Shared, Public, and Collaborative Playlists](https://github.com/anultravioletaurora/Jellify/issues/175)
- [Watch (Apple Watch / WearOS) Support](https://github.com/anultravioletaurora/Jellify/issues/61)
- [TV (Android, Apple, Samsung) Support](https://github.com/anultravioletaurora/Jellify/issues/85)
## Built with Good Stuff ## Built with Good Stuff
@@ -271,8 +296,6 @@ Paid supporters will be recognized by having their name displayed within the Set
- Quality Selection - Quality Selection
- Many thanks to PDB3D for the logo design! - Many thanks to PDB3D for the logo design!
- Huge thank you to [Ritesh](https://github.com/riteshshukla04) for literally so many things: - Huge thank you to [Ritesh](https://github.com/riteshshukla04) for literally so many things:
- Offline Mode and Network Detection
- Error Boundary Detection
- Over-the-Air Updates - Over-the-Air Updates
- Cast Support - Cast Support
- The friends we made along the way that have been critical in fostering an amazing community around _Jellify_ - The friends we made along the way that have been critical in fostering an amazing community around _Jellify_

View File

@@ -1,5 +1,6 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="app" type="AndroidRunConfigurationType" factoryName="Android App" activateToolWindowBeforeRun="false"> <configuration default="false" name="app" type="AndroidRunConfigurationType" factoryName="Android App" activateToolWindowBeforeRun="false">
<module name="Jellify.app" />
<option name="ANDROID_RUN_CONFIGURATION_SCHEMA_VERSION" value="1" /> <option name="ANDROID_RUN_CONFIGURATION_SCHEMA_VERSION" value="1" />
<option name="DEPLOY" value="true" /> <option name="DEPLOY" value="true" />
<option name="DEPLOY_APK_FROM_BUNDLE" value="false" /> <option name="DEPLOY_APK_FROM_BUNDLE" value="false" />

View File

@@ -91,8 +91,10 @@ android {
applicationId "com.cosmonautical.jellify" applicationId "com.cosmonautical.jellify"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 133 versionCode 158
versionName "0.19.1" versionName "1.0.0"
resValue "string", "build_config_package", "com.jellify"
} }
signingConfigs { signingConfigs {
debug { debug {
@@ -146,4 +148,4 @@ dependencies {
implementation "com.google.android.gms:play-services-cast-framework:+" implementation "com.google.android.gms:play-services-cast-framework:+"
implementation("com.facebook.react:hermes-android") implementation("com.facebook.react:hermes-android")
} }

View File

@@ -2,4 +2,7 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
<!-- Please Do not remove, this use for adaptive icon https://developer.android.com/develop/ui/views/launch/icon_design_adaptive -->
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>

View File

@@ -2,4 +2,7 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
<!-- Please Do not remove, this use for adaptive icon https://developer.android.com/develop/ui/views/launch/icon_design_adaptive -->
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>

BIN
assets/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -1,4 +1,8 @@
module.exports = { module.exports = {
presets: ['module:@react-native/babel-preset'], presets: ['module:@react-native/babel-preset'],
plugins: ['react-native-worklets/plugin'], plugins: [
'babel-plugin-react-compiler',
'react-native-worklets/plugin',
'react-native-worklets-core/plugin',
],
} }

2856
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -46,6 +46,22 @@ module.exports = defineConfig([
'@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-explicit-any': 'error',
'no-mixed-spaces-and-tabs': 'off', 'no-mixed-spaces-and-tabs': 'off',
semi: ['error', 'never'], semi: ['error', 'never'],
// Prevent importing RefreshControl from react-native-gesture-handler
// as it uses deprecated findNodeHandle which causes warnings in StrictMode.
// Use RefreshControl from 'react-native' instead.
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'react-native-gesture-handler',
importNames: ['RefreshControl'],
message:
"Import RefreshControl from 'react-native' instead. The gesture-handler version uses deprecated findNodeHandle which causes warnings in StrictMode.",
},
],
},
],
}, },
settings: { settings: {

View File

@@ -5,13 +5,12 @@ import CarPlay
class CarSceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { class CarSceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController) { func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController) {
RNCarPlay.connect(with: interfaceController, window: templateApplicationScene.carWindow); RNCarPlay.connect(with: interfaceController, window: templateApplicationScene.carWindow)
} }
func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didDisconnectInterfaceController interfaceController: CPInterfaceController) { func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didDisconnectInterfaceController interfaceController: CPInterfaceController) {
RNCarPlay.disconnect() RNCarPlay.disconnect()
} }
} }

View File

@@ -63,6 +63,7 @@
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = Jellify/LaunchScreen.storyboard; sourceTree = "<group>"; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = Jellify/LaunchScreen.storyboard; sourceTree = "<group>"; };
8798FC37A1454014A7B318F9 /* Figtree-SemiBold.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Figtree-SemiBold.otf"; path = "../assets/fonts/Figtree-SemiBold.otf"; sourceTree = "<group>"; }; 8798FC37A1454014A7B318F9 /* Figtree-SemiBold.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Figtree-SemiBold.otf"; path = "../assets/fonts/Figtree-SemiBold.otf"; sourceTree = "<group>"; };
8B91428F7F524687A96EE362 /* Figtree-LightItalic.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Figtree-LightItalic.otf"; path = "../assets/fonts/Figtree-LightItalic.otf"; sourceTree = "<group>"; }; 8B91428F7F524687A96EE362 /* Figtree-LightItalic.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Figtree-LightItalic.otf"; path = "../assets/fonts/Figtree-LightItalic.otf"; sourceTree = "<group>"; };
940806CC81921C976BDC3779 /* Pods-Jellify.devrelease.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Jellify.devrelease.xcconfig"; path = "Target Support Files/Pods-Jellify/Pods-Jellify.devrelease.xcconfig"; sourceTree = "<group>"; };
C5258FBB23272277847FE07E /* libPods-Jellify.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Jellify.a"; sourceTree = BUILT_PRODUCTS_DIR; }; C5258FBB23272277847FE07E /* libPods-Jellify.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Jellify.a"; sourceTree = BUILT_PRODUCTS_DIR; };
CF605E5D2DF95BAB00858968 /* Figtree-Black.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Figtree-Black.otf"; sourceTree = "<group>"; }; CF605E5D2DF95BAB00858968 /* Figtree-Black.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Figtree-Black.otf"; sourceTree = "<group>"; };
CF605E5E2DF95BAB00858968 /* Figtree-BlackItalic.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Figtree-BlackItalic.otf"; sourceTree = "<group>"; }; CF605E5E2DF95BAB00858968 /* Figtree-BlackItalic.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Figtree-BlackItalic.otf"; sourceTree = "<group>"; };
@@ -219,6 +220,7 @@
children = ( children = (
1EFD74F540EE131CCCC762FE /* Pods-Jellify.debug.xcconfig */, 1EFD74F540EE131CCCC762FE /* Pods-Jellify.debug.xcconfig */,
E53A46F6214019C12F016ACB /* Pods-Jellify.release.xcconfig */, E53A46F6214019C12F016ACB /* Pods-Jellify.release.xcconfig */,
940806CC81921C976BDC3779 /* Pods-Jellify.devrelease.xcconfig */,
); );
path = Pods; path = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -306,7 +308,7 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = YES; BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 2600; LastUpgradeCheck = 2610;
TargetAttributes = { TargetAttributes = {
00E356ED1AD99517003FC87E = { 00E356ED1AD99517003FC87E = {
CreatedOnToolsVersion = 6.2; CreatedOnToolsVersion = 6.2;
@@ -547,7 +549,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 245; CURRENT_PROJECT_VERSION = 267;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -558,7 +560,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.19.1; MARKETING_VERSION = 1.0.0;
NEW_SETTING = ""; NEW_SETTING = "";
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
@@ -589,7 +591,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 245; CURRENT_PROJECT_VERSION = 267;
DEVELOPMENT_TEAM = WAH9CZ8BPG; DEVELOPMENT_TEAM = WAH9CZ8BPG;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -599,7 +601,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.19.1; MARKETING_VERSION = 1.0.0;
NEW_SETTING = ""; NEW_SETTING = "";
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
@@ -618,6 +620,13 @@
}; };
name = Release; name = Release;
}; };
47C4374A2EBD5610003A655B /* DevRelease */ = {
isa = XCBuildConfiguration;
buildSettings = {
PRODUCT_NAME = JellifyTests;
};
name = DevRelease;
};
83CBBA201A601CBA00E9B192 /* Debug */ = { 83CBBA201A601CBA00E9B192 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -801,6 +810,138 @@
}; };
name = Release; name = Release;
}; };
CFDEVREL001 /* DevRelease */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 940806CC81921C976BDC3779 /* Pods-Jellify.devrelease.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Jellify/Jellify.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 267;
DEVELOPMENT_TEAM = WAH9CZ8BPG;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
ENVFILE = .env.devrelease;
INFOPLIST_FILE = Jellify/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.0;
NEW_SETTING = "";
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE -D DEV_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.cosmonautical.jellify;
PRODUCT_NAME = Jellify;
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development com.cosmonautical.jellify";
SWIFT_OBJC_BRIDGING_HEADER = "Jellify-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = DevRelease;
};
CFDEVRELPROJ001 /* DevRelease */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CC = "";
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = YES;
CXX = "";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
HEADER_SEARCH_PATHS = (
"$(inherited)",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx",
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
);
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD = "";
LDPLUSPLUS = "";
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = (
"\"$(SDKROOT)/usr/lib/swift\"",
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
"\"$(inherited)\"",
);
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_CPLUSPLUSFLAGS = (
"$(OTHER_CFLAGS)",
"-DFOLLY_NO_CONFIG",
"-DFOLLY_MOBILE=1",
"-DFOLLY_USE_LIBCPP=1",
"-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
);
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_VERSION = 5.0;
USE_HERMES = true;
VALIDATE_PRODUCT = YES;
};
name = DevRelease;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -809,6 +950,7 @@
buildConfigurations = ( buildConfigurations = (
00E356F61AD99517003FC87E /* Debug */, 00E356F61AD99517003FC87E /* Debug */,
00E356F71AD99517003FC87E /* Release */, 00E356F71AD99517003FC87E /* Release */,
47C4374A2EBD5610003A655B /* DevRelease */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
@@ -818,6 +960,7 @@
buildConfigurations = ( buildConfigurations = (
13B07F941A680F5B00A75B9A /* Debug */, 13B07F941A680F5B00A75B9A /* Debug */,
13B07F951A680F5B00A75B9A /* Release */, 13B07F951A680F5B00A75B9A /* Release */,
CFDEVREL001 /* DevRelease */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
@@ -827,6 +970,7 @@
buildConfigurations = ( buildConfigurations = (
83CBBA201A601CBA00E9B192 /* Debug */, 83CBBA201A601CBA00E9B192 /* Debug */,
83CBBA211A601CBA00E9B192 /* Release */, 83CBBA211A601CBA00E9B192 /* Release */,
CFDEVRELPROJ001 /* DevRelease */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "2600" LastUpgradeVersion = "2610"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "2600" LastUpgradeVersion = "2610"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

View File

@@ -58,6 +58,13 @@
<key>NSIncludesSubdomains</key> <key>NSIncludesSubdomains</key>
<true/> <true/>
</dict> </dict>
<key>100.64.0.0/10</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict> </dict>
</dict> </dict>
<key>NSBonjourServices</key> <key>NSBonjourServices</key>
@@ -66,7 +73,7 @@
<string>_CC1AD845._googlecast._tcp</string> <string>_CC1AD845._googlecast._tcp</string>
</array> </array>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>${PRODUCT_NAME} uses the local network to connect to one's Jellyfin server for streaming music</string> <string>${PRODUCT_NAME} uses the local network to connect to one&apos;s Jellyfin server for streaming music</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string></string> <string></string>
<key>RCTNewArchEnabled</key> <key>RCTNewArchEnabled</key>

View File

@@ -4,6 +4,16 @@
<dict> <dict>
<key>NSPrivacyAccessedAPITypes</key> <key>NSPrivacyAccessedAPITypes</key>
<array> <array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
<string>0A2A.1</string>
<string>3B52.1</string>
</array>
</dict>
<dict> <dict>
<key>NSPrivacyAccessedAPIType</key> <key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string> <string>NSPrivacyAccessedAPICategorySystemBootTime</string>
@@ -21,16 +31,6 @@
<string>C56D.1</string> <string>C56D.1</string>
</array> </array>
</dict> </dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
<string>0A2A.1</string>
<string>3B52.1</string>
</array>
</dict>
<dict> <dict>
<key>NSPrivacyAccessedAPIType</key> <key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string> <string>NSPrivacyAccessedAPICategoryDiskSpace</string>

View File

@@ -12,7 +12,7 @@ PODS:
- hermes-engine (0.82.1): - hermes-engine (0.82.1):
- hermes-engine/Pre-built (= 0.82.1) - hermes-engine/Pre-built (= 0.82.1)
- hermes-engine/Pre-built (0.82.1) - hermes-engine/Pre-built (0.82.1)
- NitroImage (0.8.1): - NitroFetch (0.1.6):
- boost - boost
- DoubleConversion - DoubleConversion
- fast_float - fast_float
@@ -42,7 +42,7 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- SocketRocket - SocketRocket
- Yoga - Yoga
- NitroModules (0.31.1): - NitroModules (0.31.10):
- boost - boost
- DoubleConversion - DoubleConversion
- fast_float - fast_float
@@ -71,7 +71,7 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- SocketRocket - SocketRocket
- Yoga - Yoga
- NitroOta (0.3.0): - NitroOta (0.7.2):
- boost - boost
- DoubleConversion - DoubleConversion
- fast_float - fast_float
@@ -102,7 +102,7 @@ PODS:
- SocketRocket - SocketRocket
- SSZipArchive - SSZipArchive
- Yoga - Yoga
- NitroOtaBundleManager (0.3.0): - NitroOtaBundleManager (0.7.2):
- boost - boost
- DoubleConversion - DoubleConversion
- fast_float - fast_float
@@ -130,38 +130,6 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- SocketRocket - SocketRocket
- Yoga - Yoga
- NitroWebImage (0.8.1):
- boost
- DoubleConversion
- fast_float
- fmt
- glog
- hermes-engine
- NitroImage
- NitroModules
- RCT-Folly
- RCT-Folly/Fabric
- RCTRequired
- RCTTypeSafety
- React-callinvoker
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- SDWebImage
- SocketRocket
- Yoga
- PromisesObjC (2.4.0) - PromisesObjC (2.4.0)
- Protobuf (3.29.5) - Protobuf (3.29.5)
- RCT-Folly (2024.11.18.00): - RCT-Folly (2024.11.18.00):
@@ -2036,7 +2004,7 @@ PODS:
- Yoga - Yoga
- react-native-netinfo (11.4.1): - react-native-netinfo (11.4.1):
- React-Core - React-Core
- react-native-pager-view (6.9.1): - react-native-pager-view (7.0.2):
- boost - boost
- DoubleConversion - DoubleConversion
- fast_float - fast_float
@@ -2064,7 +2032,7 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- SocketRocket - SocketRocket
- Yoga - Yoga
- react-native-safe-area-context (5.6.1): - react-native-safe-area-context (5.6.2):
- boost - boost
- DoubleConversion - DoubleConversion
- fast_float - fast_float
@@ -2082,8 +2050,8 @@ PODS:
- React-graphics - React-graphics
- React-ImageManager - React-ImageManager
- React-jsi - React-jsi
- react-native-safe-area-context/common (= 5.6.1) - react-native-safe-area-context/common (= 5.6.2)
- react-native-safe-area-context/fabric (= 5.6.1) - react-native-safe-area-context/fabric (= 5.6.2)
- React-NativeModulesApple - React-NativeModulesApple
- React-RCTFabric - React-RCTFabric
- React-renderercss - React-renderercss
@@ -2094,7 +2062,7 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- SocketRocket - SocketRocket
- Yoga - Yoga
- react-native-safe-area-context/common (5.6.1): - react-native-safe-area-context/common (5.6.2):
- boost - boost
- DoubleConversion - DoubleConversion
- fast_float - fast_float
@@ -2122,7 +2090,7 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- SocketRocket - SocketRocket
- Yoga - Yoga
- react-native-safe-area-context/fabric (5.6.1): - react-native-safe-area-context/fabric (5.6.2):
- boost - boost
- DoubleConversion - DoubleConversion
- fast_float - fast_float
@@ -2180,7 +2148,35 @@ PODS:
- SocketRocket - SocketRocket
- SwiftAudioEx (= 1.1.0) - SwiftAudioEx (= 1.1.0)
- Yoga - Yoga
- react-native-vector-icons-material-design-icons (12.3.0) - react-native-vector-icons-material-design-icons (12.4.0)
- react-native-worklets-core (1.6.2):
- boost
- DoubleConversion
- fast_float
- fmt
- glog
- hermes-engine
- RCT-Folly
- RCT-Folly/Fabric
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- React-NativeModulesApple (0.82.1): - React-NativeModulesApple (0.82.1):
- boost - boost
- DoubleConversion - DoubleConversion
@@ -2720,6 +2716,34 @@ PODS:
- React-perflogger (= 0.82.1) - React-perflogger (= 0.82.1)
- React-utils (= 0.82.1) - React-utils (= 0.82.1)
- SocketRocket - SocketRocket
- RNCAsyncStorage (2.2.0):
- boost
- DoubleConversion
- fast_float
- fmt
- glog
- hermes-engine
- RCT-Folly
- RCT-Folly/Fabric
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- RNCMaskedView (0.3.2): - RNCMaskedView (0.3.2):
- boost - boost
- DoubleConversion - DoubleConversion
@@ -2748,13 +2772,13 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- SocketRocket - SocketRocket
- Yoga - Yoga
- RNDeviceInfo (14.0.4): - RNDeviceInfo (15.0.1):
- React-Core - React-Core
- RNDnsLookup (1.0.6): - RNDnsLookup (1.0.6):
- React - React
- RNFS (2.20.0): - RNFS (2.20.0):
- React-Core - React-Core
- RNGestureHandler (2.28.0): - RNGestureHandler (2.29.1):
- boost - boost
- DoubleConversion - DoubleConversion
- fast_float - fast_float
@@ -2810,7 +2834,7 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- SocketRocket - SocketRocket
- Yoga - Yoga
- RNReanimated (4.1.3): - RNReanimated (4.1.5):
- boost - boost
- DoubleConversion - DoubleConversion
- fast_float - fast_float
@@ -2837,11 +2861,11 @@ PODS:
- ReactCodegen - ReactCodegen
- ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- RNReanimated/reanimated (= 4.1.3) - RNReanimated/reanimated (= 4.1.5)
- RNWorklets - RNWorklets
- SocketRocket - SocketRocket
- Yoga - Yoga
- RNReanimated/reanimated (4.1.3): - RNReanimated/reanimated (4.1.5):
- boost - boost
- DoubleConversion - DoubleConversion
- fast_float - fast_float
@@ -2868,11 +2892,11 @@ PODS:
- ReactCodegen - ReactCodegen
- ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- RNReanimated/reanimated/apple (= 4.1.3) - RNReanimated/reanimated/apple (= 4.1.5)
- RNWorklets - RNWorklets
- SocketRocket - SocketRocket
- Yoga - Yoga
- RNReanimated/reanimated/apple (4.1.3): - RNReanimated/reanimated/apple (4.1.5):
- boost - boost
- DoubleConversion - DoubleConversion
- fast_float - fast_float
@@ -2961,7 +2985,7 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- SocketRocket - SocketRocket
- Yoga - Yoga
- RNSentry (7.1.0): - RNSentry (7.6.0):
- boost - boost
- DoubleConversion - DoubleConversion
- fast_float - fast_float
@@ -2988,7 +3012,7 @@ PODS:
- ReactCodegen - ReactCodegen
- ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- Sentry/HybridSDK (= 8.56.0) - Sentry/HybridSDK (= 8.57.2)
- SocketRocket - SocketRocket
- Yoga - Yoga
- RNWorklets (0.6.1): - RNWorklets (0.6.1):
@@ -3083,7 +3107,7 @@ PODS:
- SDWebImage (5.21.2): - SDWebImage (5.21.2):
- SDWebImage/Core (= 5.21.2) - SDWebImage/Core (= 5.21.2)
- SDWebImage/Core (5.21.2) - SDWebImage/Core (5.21.2)
- Sentry/HybridSDK (8.56.0) - Sentry/HybridSDK (8.57.2)
- SocketRocket (0.7.1) - SocketRocket (0.7.1)
- SSZipArchive (2.4.3) - SSZipArchive (2.4.3)
- SwiftAudioEx (1.1.0) - SwiftAudioEx (1.1.0)
@@ -3098,11 +3122,10 @@ DEPENDENCIES:
- fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`) - fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`)
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
- NitroImage (from `../node_modules/react-native-nitro-image`) - NitroFetch (from `../node_modules/react-native-nitro-fetch`)
- NitroModules (from `../node_modules/react-native-nitro-modules`) - NitroModules (from `../node_modules/react-native-nitro-modules`)
- NitroOta (from `../node_modules/react-native-nitro-ota`) - NitroOta (from `../node_modules/react-native-nitro-ota`)
- NitroOtaBundleManager (from `../node_modules/react-native-nitro-ota`) - NitroOtaBundleManager (from `../node_modules/react-native-nitro-ota`)
- NitroWebImage (from `../node_modules/react-native-nitro-web-image`)
- RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
- RCTRequired (from `../node_modules/react-native/Libraries/Required`) - RCTRequired (from `../node_modules/react-native/Libraries/Required`)
@@ -3149,6 +3172,7 @@ DEPENDENCIES:
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- react-native-track-player (from `../node_modules/react-native-track-player`) - react-native-track-player (from `../node_modules/react-native-track-player`)
- "react-native-vector-icons-material-design-icons (from `../node_modules/@react-native-vector-icons/material-design-icons`)" - "react-native-vector-icons-material-design-icons (from `../node_modules/@react-native-vector-icons/material-design-icons`)"
- react-native-worklets-core (from `../node_modules/react-native-worklets-core`)
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
- React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`) - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`)
- React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
@@ -3181,6 +3205,7 @@ DEPENDENCIES:
- ReactAppDependencyProvider (from `build/generated/ios`) - ReactAppDependencyProvider (from `build/generated/ios`)
- ReactCodegen (from `build/generated/ios`) - ReactCodegen (from `build/generated/ios`)
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)" - "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)"
- RNDeviceInfo (from `../node_modules/react-native-device-info`) - RNDeviceInfo (from `../node_modules/react-native-device-info`)
- RNDnsLookup (from `../node_modules/react-native-dns-lookup`) - RNDnsLookup (from `../node_modules/react-native-dns-lookup`)
@@ -3224,16 +3249,14 @@ EXTERNAL SOURCES:
hermes-engine: hermes-engine:
:podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
:tag: hermes-2025-09-01-RNv0.82.0-265ef62ff3eb7289d17e366664ac0da82303e101 :tag: hermes-2025-09-01-RNv0.82.0-265ef62ff3eb7289d17e366664ac0da82303e101
NitroImage: NitroFetch:
:path: "../node_modules/react-native-nitro-image" :path: "../node_modules/react-native-nitro-fetch"
NitroModules: NitroModules:
:path: "../node_modules/react-native-nitro-modules" :path: "../node_modules/react-native-nitro-modules"
NitroOta: NitroOta:
:path: "../node_modules/react-native-nitro-ota" :path: "../node_modules/react-native-nitro-ota"
NitroOtaBundleManager: NitroOtaBundleManager:
:path: "../node_modules/react-native-nitro-ota" :path: "../node_modules/react-native-nitro-ota"
NitroWebImage:
:path: "../node_modules/react-native-nitro-web-image"
RCT-Folly: RCT-Folly:
:podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" :podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec"
RCTDeprecation: RCTDeprecation:
@@ -3324,6 +3347,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-track-player" :path: "../node_modules/react-native-track-player"
react-native-vector-icons-material-design-icons: react-native-vector-icons-material-design-icons:
:path: "../node_modules/@react-native-vector-icons/material-design-icons" :path: "../node_modules/@react-native-vector-icons/material-design-icons"
react-native-worklets-core:
:path: "../node_modules/react-native-worklets-core"
React-NativeModulesApple: React-NativeModulesApple:
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" :path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios"
React-oscompat: React-oscompat:
@@ -3388,6 +3413,8 @@ EXTERNAL SOURCES:
:path: build/generated/ios :path: build/generated/ios
ReactCommon: ReactCommon:
:path: "../node_modules/react-native/ReactCommon" :path: "../node_modules/react-native/ReactCommon"
RNCAsyncStorage:
:path: "../node_modules/@react-native-async-storage/async-storage"
RNCMaskedView: RNCMaskedView:
:path: "../node_modules/@react-native-masked-view/masked-view" :path: "../node_modules/@react-native-masked-view/masked-view"
RNDeviceInfo: RNDeviceInfo:
@@ -3421,11 +3448,10 @@ SPEC CHECKSUMS:
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
google-cast-sdk: 1fb6724e94cc5ff23b359176e0cf6360586bb97a google-cast-sdk: 1fb6724e94cc5ff23b359176e0cf6360586bb97a
hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5 hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5
NitroImage: 76da8995cc5476111ac5069300a3ec5de0f65e9b NitroFetch: 660adfb47f84b28db664f97b50e5dc28506ab6c1
NitroModules: 0ba3a58906a86566ea83abc016f8692374c19761 NitroModules: 5bc319d441f4983894ea66b1d392c519536e6d23
NitroOta: 460722ac309996c07ea88134f47101246fe65658 NitroOta: 7755c4728f7348584cebb2d428480b1ed0cd2679
NitroOtaBundleManager: 66a5b277368a6c7f977134258663531441e37522 NitroOtaBundleManager: 482abb17f0ca629ad551da43f13e76e59dba9568
NitroWebImage: 5cd76cf34fb1661acc4daf5a6925d5a29448c7c4
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
Protobuf: 164aea2ae380c3951abdc3e195220c01d17400e0 Protobuf: 164aea2ae380c3951abdc3e195220c01d17400e0
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
@@ -3469,10 +3495,11 @@ SPEC CHECKSUMS:
react-native-google-cast: 7be68a5d0b7eeb95a5924c3ecef8d319ef6c0a44 react-native-google-cast: 7be68a5d0b7eeb95a5924c3ecef8d319ef6c0a44
react-native-mmkv: ac7507625cd74bac0eb5333604a7cd7b08fe9e3e react-native-mmkv: ac7507625cd74bac0eb5333604a7cd7b08fe9e3e
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-pager-view: a0516effb17ca5120ac2113bfd21b91130ad5748 react-native-pager-view: 5c3098839820aa73d75873e7b1a7eb9f119602b7
react-native-safe-area-context: c6e2edd1c1da07bdce287fa9d9e60c5f7b514616 react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460
react-native-track-player: 89d8e641c83a89bea5dee43c381be743282553e9 react-native-track-player: 89d8e641c83a89bea5dee43c381be743282553e9
react-native-vector-icons-material-design-icons: c502df5b988ce85d6c7d2b7ee909818315760b82 react-native-vector-icons-material-design-icons: 76cd460b3540b80527b4a80fb7f867f7deedb498
react-native-worklets-core: 28a6e2121dcf62543b703e81bc4860e9a0150cee
React-NativeModulesApple: 46690a0fe94ec28fc6fc686ec797b911d251ded0 React-NativeModulesApple: 46690a0fe94ec28fc6fc686ec797b911d251ded0
React-oscompat: 95875e81f5d4b3c7b2c888d5bd2c9d83450d8bdb React-oscompat: 95875e81f5d4b3c7b2c888d5bd2c9d83450d8bdb
React-perflogger: 2e229bf33e42c094fd64516d89ec1187a2b79b5b React-perflogger: 2e229bf33e42c094fd64516d89ec1187a2b79b5b
@@ -3505,18 +3532,19 @@ SPEC CHECKSUMS:
ReactAppDependencyProvider: a45ef34bb22dc1c9b2ac1f74167d9a28af961176 ReactAppDependencyProvider: a45ef34bb22dc1c9b2ac1f74167d9a28af961176
ReactCodegen: 878add6c7d8ff8cea87697c44d29c03b79b6f2d9 ReactCodegen: 878add6c7d8ff8cea87697c44d29c03b79b6f2d9
ReactCommon: 804dc80944fa90b86800b43c871742ec005ca424 ReactCommon: 804dc80944fa90b86800b43c871742ec005ca424
RNCAsyncStorage: 29f0230e1a25f36c20b05f65e2eb8958d6526e82
RNCMaskedView: 5ef8c95cbab95334a32763b72896a7b7d07e6299 RNCMaskedView: 5ef8c95cbab95334a32763b72896a7b7d07e6299
RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047 RNDeviceInfo: 36d7f232bfe7c9b5c494cb7793230424ed32c388
RNDnsLookup: db4a89381b80ec1a5153088518d2c4f8e51f2521 RNDnsLookup: db4a89381b80ec1a5153088518d2c4f8e51f2521
RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8 RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
RNGestureHandler: f1dd7f92a0faa2868a919ab53bb9d66eb4ebfcf5 RNGestureHandler: e1cf8ef3f11045536eed6bd4f132b003ef5f9a5f
RNReactNativeHapticFeedback: be4f1b4bf0398c30b59b76ed92ecb0a2ff3a69c6 RNReactNativeHapticFeedback: be4f1b4bf0398c30b59b76ed92ecb0a2ff3a69c6
RNReanimated: 732e7d1662f8cc0e533fa32791800de5b5934726 RNReanimated: ac06da53579693ab451941ef89f5a55afeab0dd9
RNScreens: d821082c6dd1cb397cc0c98b026eeafaa68be479 RNScreens: d821082c6dd1cb397cc0c98b026eeafaa68be479
RNSentry: 60919c9cdac7e4b35e9f5dd0149f551ec12f35cb RNSentry: d48cee794dd35d77930dcf89b983dc8c6498ec0d
RNWorklets: ab618bf7d1c7fd2cb793b9f0f39c3e29274b3ebf RNWorklets: ab618bf7d1c7fd2cb793b9f0f39c3e29274b3ebf
SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a
Sentry: 3d82977434c80381cae856c40b99c39e4be6bc11 Sentry: 83a3814c3ca042874b39c5c5bdffb6570d4d760e
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
SwiftAudioEx: f6aa653770f3a0d3851edaf8d834a30aee4a7646 SwiftAudioEx: f6aa653770f3a0d3851edaf8d834a30aee4a7646

View File

@@ -74,12 +74,13 @@ platform :ios do
) )
end end
lane :notifyOnDiscord do lane :notifyOnDiscordForRelease do
app_version = ENV["APP_VERSION"] || "N/A" app_version = ENV["APP_VERSION"] || "N/A"
changelog = sh("git log -n 3 --pretty=format:'• %s (%h) - %an'").split("\n").join("\n") changelog = sh("git log -n 3 --pretty=format:'• %s (%h) - %an'").split("\n").join("\n")
discord_url = ENV["DISCORD_WEBHOOK_URL"] || "N/A" discord_url = ENV["DISCORD_WEBHOOK_URL"] || "N/A"
release_url = ENV["release_url"] release_url = ENV["release_url"]
testflight_url = "https://testflight.apple.com/join/etVSc7ZQ" testflight_url = "https://testflight.apple.com/join/etVSc7ZQ"
unix_timestamp = Time.now.to_i
discord_notifier( discord_notifier(
webhook_url: ENV["DISCORD_WEBHOOK_URL"], webhook_url: ENV["DISCORD_WEBHOOK_URL"],
title: "🎉 App v#{app_version} Released!", title: "🎉 App v#{app_version} Released!",
@@ -96,7 +97,7 @@ platform :ios do
}, },
{ {
name: "🕒 Released On", name: "🕒 Released On",
value: Time.now.strftime("%B %d, %Y at %I:%M %p") value: "<t:#{unix_timestamp}:f>"
}, },
{ {
name: "📝 Release Notes", name: "📝 Release Notes",
@@ -107,4 +108,17 @@ platform :ios do
) )
end end
lane :notifyOnDiscord do |options|
title = options[:title]
description = options[:description]
discord_notifier(
webhook_url: ENV["DISCORD_WEBHOOK_URL"],
title: title,
description: description,
success:true
)
end
end end

View File

@@ -31,6 +31,14 @@ Push a new beta build to TestFlight
### ios notifyOnDiscordForRelease
```sh
[bundle exec] fastlane ios notifyOnDiscordForRelease
```
### ios notifyOnDiscord ### ios notifyOnDiscord
```sh ```sh

View File

@@ -2,9 +2,15 @@
module.exports = { module.exports = {
preset: 'react-native', preset: 'react-native',
testTimeout: 10000, testTimeout: 10000,
// Performance optimizations for CI
maxWorkers: process.env.CI ? 2 : '50%',
cacheDirectory: '.jest-cache',
setupFiles: ['./node_modules/react-native-gesture-handler/jestSetup.js'], setupFiles: ['./node_modules/react-native-gesture-handler/jestSetup.js'],
setupFilesAfterEnv: [ setupFilesAfterEnv: [
'./jest/setup/setup.ts', './jest/setup/setup.ts',
'./jest/setup/async-storage.ts',
'./jest/setup/blur.ts', './jest/setup/blur.ts',
'./jest/setup/carplay.ts', './jest/setup/carplay.ts',
'./jest/setup/device-info.js', // JS to prevent Typescript implicit any warning './jest/setup/device-info.js', // JS to prevent Typescript implicit any warning
@@ -12,6 +18,7 @@ module.exports = {
'./jest/setup/rnfs.ts', './jest/setup/rnfs.ts',
'./jest/setup/rntp.ts', './jest/setup/rntp.ts',
'./jest/setup/sentry.ts', './jest/setup/sentry.ts',
'./jest/setup/nitro-fetch.ts',
'./jest/setup/nitro-image.ts', './jest/setup/nitro-image.ts',
'./jest/setup/nitro-ota.ts', './jest/setup/nitro-ota.ts',
'./tamagui.config.ts', './tamagui.config.ts',

View File

@@ -1,62 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react-native'
import { JellifyProvider, useJellifyContext } from '../../src/providers'
import { Text, View } from 'react-native'
import { MMKVStorageKeys } from '../../src/enums/mmkv-storage-keys'
import { storage } from '../../src/constants/storage'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
const JellifyConsumer = () => {
const { server, user, library } = useJellifyContext()
return (
<View>
<Text testID='api-base-path'>{server?.url}</Text>
<Text testID='user-name'>{user?.name}</Text>
<Text testID='library-name'>{library?.musicLibraryName}</Text>
</View>
)
}
test(`${JellifyProvider.name} renders correctly`, async () => {
storage.set(
MMKVStorageKeys.Server,
JSON.stringify({
url: 'http://localhost:8096',
}),
)
storage.set(
MMKVStorageKeys.User,
JSON.stringify({
name: 'Violet Caulfield',
}),
)
storage.set(
MMKVStorageKeys.Library,
JSON.stringify({
musicLibraryName: 'Music Library',
}),
)
render(
<QueryClientProvider client={queryClient}>
<JellifyProvider>
<JellifyConsumer />
</JellifyProvider>
,
</QueryClientProvider>,
)
const apiBasePath = screen.getByTestId('api-base-path')
const userName = screen.getByTestId('user-name')
const libraryName = screen.getByTestId('library-name')
await waitFor(() => {
expect(apiBasePath.props.children).toBe('http://localhost:8096')
expect(userName.props.children).toBe('Violet Caulfield')
expect(libraryName.props.children).toBe('Music Library')
})
})

View File

@@ -4,16 +4,13 @@ import { render } from '@testing-library/react-native'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { PlayerProvider } from '../../src/providers/Player' import { PlayerProvider } from '../../src/providers/Player'
import { JellifyProvider } from '../../src/providers'
const queryClient = new QueryClient() const queryClient = new QueryClient()
test(`${PlayerProvider.name} renders correctly`, () => { test(`${PlayerProvider.name} renders correctly`, () => {
render( render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<JellifyProvider> <PlayerProvider />
<PlayerProvider />
</JellifyProvider>
</QueryClientProvider>, </QueryClientProvider>,
) )
}) })

View File

@@ -0,0 +1,148 @@
import React from 'react'
import { render } from '@testing-library/react-native'
import SwipeableRow, {
type QuickAction,
type SwipeAction,
} from '../../src/components/Global/components/SwipeableRow'
import { Text } from '../../src/components/Global/helpers/text'
import { TamaguiProvider, Theme } from 'tamagui'
import config from '../../tamagui.config'
/**
* Expectation-driven tests for SwipeableRow.
* We validate the user-observable contract:
* - Tapping quick-action triggers handler and row closes
* - Single-open invariant across rows (via registry)
* - Scroll/drag elsewhere closes open menu (simulated by calling registry API)
*
* Notes:
* - The actual pan gesture and animated values are mocked by reanimated/gesture handler in Jest.
* We simulate the outcomes: menu opened or closed by calling the internal registry functions,
* and we assert calls/close behavior via provided callbacks.
*/
import {
closeAllSwipeableRows,
notifySwipeableRowOpened,
} from '../../src/components/Global/components/swipeable-row-registry'
function Row({
leftAction,
leftActions,
rightAction,
rightActions,
testID,
}: {
leftAction?: SwipeAction | null
leftActions?: QuickAction[] | null
rightAction?: SwipeAction | null
rightActions?: QuickAction[] | null
testID: string
}) {
return (
<TamaguiProvider config={config}>
<Theme name='dark'>
<SwipeableRow
leftAction={leftAction ?? undefined}
leftActions={leftActions ?? undefined}
rightAction={rightAction ?? undefined}
rightActions={rightActions ?? undefined}
>
<Text testID={testID}>Row</Text>
</SwipeableRow>
</Theme>
</TamaguiProvider>
)
}
/**
* Helper: simulate that a specific row was swiped open by notifying the registry
* (This is equivalent to the row opening and telling the registry it's open.)
*/
function simulateOpen() {
// We cannot access internal id; notifying with any id exercises the close-all-others behavior.
notifySwipeableRowOpened(Math.random().toString(36))
}
describe('SwipeableRow behavior (expectations)', () => {
beforeEach(() => closeAllSwipeableRows())
it('triggers immediate left action and closes after press', () => {
const onTrigger = jest.fn()
const left: SwipeAction = {
label: 'Fav',
icon: 'heart',
color: '$primary',
onTrigger,
}
const { getByTestId } = render(<Row leftAction={left} testID='row-a' />)
// Simulate that row has been swiped beyond threshold to reveal left action
simulateOpen()
// Press the content (not the underlay); expectation is that triggering left action happens on threshold release.
// Since we cannot trigger pan end in Jest reliably, we call onTrigger directly to assert intent.
onTrigger()
expect(onTrigger).toHaveBeenCalledTimes(1)
// After action, closing all should not try to re-close (id unknown), but we assert no error
expect(() => closeAllSwipeableRows()).not.toThrow()
})
it('quick actions on right: pressing an action calls handler and closes', () => {
const act1 = jest.fn()
const actions: QuickAction[] = [{ icon: 'download', color: '$primary', onPress: act1 }]
const { getByTestId } = render(<Row rightActions={actions} testID='row-b' />)
// Simulate that row menu opened
simulateOpen()
// Invoke the action as if pressed
act1()
expect(act1).toHaveBeenCalledTimes(1)
// Menu should be closable globally (no throw)
expect(() => closeAllSwipeableRows()).not.toThrow()
})
it('only one row should be considered open at a time (registry contract)', () => {
const { rerender } = render(
<>
<Row
rightActions={[{ icon: 'x', color: '$primary', onPress: jest.fn() }]}
testID='r1'
/>
<Row
rightActions={[{ icon: 'x', color: '$primary', onPress: jest.fn() }]}
testID='r2'
/>
</>,
)
// Open first row
notifySwipeableRowOpened('row-1')
// Opening second row should close first implicitly
notifySwipeableRowOpened('row-2')
// Closing all should be safe afterward
expect(() => closeAllSwipeableRows()).not.toThrow()
})
it('scroll begin elsewhere closes any open menu (via registry)', () => {
const { rerender } = render(
<Row
rightActions={[{ icon: 'x', color: '$primary', onPress: jest.fn() }]}
testID='scroll-row'
/>,
)
simulateOpen()
// Simulate scroll begin in a list calling the shared helper
expect(() => closeAllSwipeableRows()).not.toThrow()
})
})

View File

@@ -0,0 +1,99 @@
import {
registerSwipeableRow,
unregisterSwipeableRow,
notifySwipeableRowOpened,
notifySwipeableRowClosed,
closeAllSwipeableRows,
} from '../../src/components/Global/components/swipeable-row-registry'
/**
* Expectation-driven tests for the swipeable row registry behavior.
* We assert the observable contract (who gets closed and when),
* not implementation details (like internal Sets/Maps).
*/
describe('swipeable-row-registry', () => {
beforeEach(() => {
// Ensure clean slate between tests
closeAllSwipeableRows()
})
it('should noop when closing all with no registered rows', () => {
expect(() => closeAllSwipeableRows()).not.toThrow()
})
it('should close previously open row when a new row opens', () => {
const closeA = jest.fn()
const closeB = jest.fn()
registerSwipeableRow('A', closeA)
registerSwipeableRow('B', closeB)
// Open A first
notifySwipeableRowOpened('A')
expect(closeA).not.toHaveBeenCalled()
expect(closeB).not.toHaveBeenCalled()
// Open B should close A
notifySwipeableRowOpened('B')
expect(closeA).toHaveBeenCalledTimes(1)
expect(closeB).not.toHaveBeenCalled()
})
it('opening the same row again should not close itself', () => {
const closeA = jest.fn()
registerSwipeableRow('A', closeA)
notifySwipeableRowOpened('A')
notifySwipeableRowOpened('A')
// A should not be asked to close itself
expect(closeA).not.toHaveBeenCalled()
})
it('closing a specific row removes it from the open set', () => {
const closeA = jest.fn()
registerSwipeableRow('A', closeA)
notifySwipeableRowOpened('A')
notifySwipeableRowClosed('A')
// Closing all should not try to close A now
closeAllSwipeableRows()
expect(closeA).not.toHaveBeenCalled()
})
it('unregistering a row prevents further callbacks', () => {
const closeA = jest.fn()
registerSwipeableRow('A', closeA)
notifySwipeableRowOpened('A')
unregisterSwipeableRow('A')
// Closing all should not call closeA (unregistered)
closeAllSwipeableRows()
expect(closeA).not.toHaveBeenCalled()
})
it('closeAllSwipeableRows closes all currently open rows once', () => {
const closeA = jest.fn()
const closeB = jest.fn()
registerSwipeableRow('A', closeA)
registerSwipeableRow('B', closeB)
notifySwipeableRowOpened('A')
notifySwipeableRowOpened('B') // this will close A and set B open
closeA.mockClear()
closeAllSwipeableRows()
expect(closeA).not.toHaveBeenCalled()
expect(closeB).toHaveBeenCalledTimes(1)
// A or B should not be closed again after a second call (no open rows)
closeB.mockClear()
closeAllSwipeableRows()
expect(closeB).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,34 @@
import TrackPlayer from 'react-native-track-player'
import { playLaterInQueue } from '../../src/providers/Player/functions/queue'
import { BaseItemDto, DeviceProfile } from '@jellyfin/sdk/lib/generated-client/models'
import { Api } from '@jellyfin/sdk'
describe('Add to Queue - playLaterInQueue', () => {
it('adds track to the end of the queue', async () => {
const track: BaseItemDto = {
Id: 't1',
Name: 'Test Track',
// Intentionally exclude AlbumId to avoid image URL building
Type: 'Audio',
}
// Mock getQueue to return updated list after add
;(TrackPlayer.getQueue as jest.Mock).mockResolvedValue([{ item: track }])
const api: Partial<Api> = { basePath: '' }
const deviceProfile: Partial<DeviceProfile> = { Name: 'test' }
await playLaterInQueue({
api: api as Api,
deviceProfile: deviceProfile as DeviceProfile,
networkStatus: null,
tracks: [track],
queuingType: undefined,
})
expect(TrackPlayer.add).toHaveBeenCalledTimes(1)
const callArg = (TrackPlayer.add as jest.Mock).mock.calls[0][0]
expect(Array.isArray(callArg)).toBe(true)
expect(callArg[0].item.Id).toBe('t1')
})
})

View File

@@ -0,0 +1,3 @@
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
)

21
jest/setup/nitro-fetch.ts Normal file
View File

@@ -0,0 +1,21 @@
// Mock for react-native-nitro-fetch
jest.mock('react-native-nitro-fetch', () => ({
nitroFetchOnWorklet: jest.fn(() => Promise.resolve({})),
nitroFetch: jest.fn(() => Promise.resolve({})),
}))
// Update the nitro-modules mock to include the box method
jest.mock('react-native-nitro-modules', () => {
const actual = jest.requireActual('react-native-nitro-modules')
return {
...actual,
NitroModules: {
...actual?.NitroModules,
createModule: jest.fn(),
install: jest.fn(),
createHybridObject: jest.fn(() => ({})),
box: jest.fn((value) => value), // Mock the box method
},
createNitroModule: jest.fn(),
}
})

View File

@@ -1,60 +1,60 @@
// Mock for react-native-nitro-image // // Mock for react-native-nitro-image
import React from 'react' // import React from 'react'
import { Image, ImageProps } from 'react-native' // import { Image, ImageProps } from 'react-native'
// Mock the useWebImage hook // // Mock the useWebImage hook
const mockUseWebImage = jest.fn(() => ({ // const mockUseWebImage = jest.fn(() => ({
imageUri: 'mock://image.jpg', // imageUri: 'mock://image.jpg',
isLoading: false, // isLoading: false,
error: null, // error: null,
})) // }))
// Define types for NitroImage props // // Define types for NitroImage props
interface NitroImageProps extends Omit<ImageProps, 'source'> { // interface NitroImageProps extends Omit<ImageProps, 'source'> {
image?: { // image?: {
url: string // url: string
} // }
} // }
// Mock the NitroImage component to behave like a regular Image // // Mock the NitroImage component to behave like a regular Image
const MockNitroImage = (props: NitroImageProps) => { // const MockNitroImage = (props: NitroImageProps) => {
// Extract the URL from the image prop if it exists // // Extract the URL from the image prop if it exists
const source = props.image?.url ? { uri: props.image.url } : undefined // const source = props.image?.url ? { uri: props.image.url } : undefined
// Destructure to separate the custom image prop from standard Image props // // Destructure to separate the custom image prop from standard Image props
const { image, ...restProps } = props // const { image, ...restProps } = props
// Pass through other props while converting to Image component props // // Pass through other props while converting to Image component props
const imageProps: ImageProps = { // const imageProps: ImageProps = {
...restProps, // ...restProps,
source, // source,
} // }
return React.createElement(Image, imageProps) // return React.createElement(Image, imageProps)
} // }
// Mock the entire react-native-nitro-image module // // Mock the entire react-native-nitro-image module
jest.mock('react-native-nitro-image', () => ({ // jest.mock('react-native-nitro-image', () => ({
useWebImage: mockUseWebImage, // useWebImage: mockUseWebImage,
NitroImage: MockNitroImage, // NitroImage: MockNitroImage,
// Add other exports that might be used // // Add other exports that might be used
createImageFactory: jest.fn(), // createImageFactory: jest.fn(),
ImageFactory: jest.fn(), // ImageFactory: jest.fn(),
})) // }))
// Mock the underlying native module that causes the error // // Mock the underlying native module that causes the error
jest.mock('react-native-nitro-modules', () => ({ // jest.mock('react-native-nitro-modules', () => ({
NitroModules: { // NitroModules: {
createModule: jest.fn(), // createModule: jest.fn(),
install: jest.fn(), // install: jest.fn(),
}, // },
createNitroModule: jest.fn(), // createNitroModule: jest.fn(),
})) // }))
// Additional mock for the TurboModule spec that's failing // // Additional mock for the TurboModule spec that's failing
jest.mock('react-native-nitro-modules/src/turbomodule/NativeNitroModules', () => ({ // jest.mock('react-native-nitro-modules/src/turbomodule/NativeNitroModules', () => ({
default: { // default: {
installModule: jest.fn(), // installModule: jest.fn(),
uninstallModule: jest.fn(), // uninstallModule: jest.fn(),
}, // },
})) // }))

View File

@@ -8,6 +8,8 @@ jest.mock('react-native-nitro-ota', () => ({
checkForUpdates: jest.fn().mockResolvedValue(null), checkForUpdates: jest.fn().mockResolvedValue(null),
downloadUpdate: jest.fn().mockResolvedValue(undefined), downloadUpdate: jest.fn().mockResolvedValue(undefined),
})), })),
reloadApp: jest.fn(),
getStoredOtaVersion: jest.fn(() => null),
})) }))
// Update the existing nitro-modules mock to include createHybridObject // Update the existing nitro-modules mock to include createHybridObject

View File

@@ -55,6 +55,9 @@ jest.mock('react-native-track-player', () => {
useTrackPlayerEvents: (events: Event[], handler: (variables: any) => void) => { useTrackPlayerEvents: (events: Event[], handler: (variables: any) => void) => {
eventHandler = handler eventHandler = handler
}, },
AppKilledPlaybackBehavior: {
StopPlaybackAndRemoveNotification: 'stopPlaybackAndRemoveNotification',
},
Capability: { Capability: {
Play: 1, Play: 1,
PlayFromId: 2, PlayFromId: 2,

View File

@@ -3,3 +3,4 @@ appId: com.cosmonautical.jellify
- runFlow: ../tests/4-search.yaml - runFlow: ../tests/4-search.yaml
- runFlow: ../tests/5-discover.yaml - runFlow: ../tests/5-discover.yaml
- runFlow: ../tests/6-settings.yaml - runFlow: ../tests/6-settings.yaml
- runFlow: ../tests/7-quickactions.yaml

View File

@@ -49,16 +49,16 @@ appId: com.cosmonautical.jellify
# Scroll Down to see the queue # Scroll Down to see the queue
- scrollUntilVisible: - scrollUntilVisible:
element: element:
id: "queue-item-12" id: "queue-item-8"
direction: "DOWN" direction: "DOWN"
- scrollUntilVisible: - scrollUntilVisible:
element: element:
id: "queue-item-12" id: "queue-item-8"
direction: "UP" direction: "UP"
# Play some other Song # Play some other Song
- tapOn: - tapOn:
id: 'queue-item-12' id: 'queue-item-8'
- pressKey: BACK - pressKey: BACK

View File

@@ -12,9 +12,6 @@ appId: com.cosmonautical.jellify
- assertVisible: - assertVisible:
id: "discover-recently-added" id: "discover-recently-added"
- assertVisible:
id: "discover-public-playlists"
- assertVisible: - assertVisible:
id: "discover-suggested-artists" id: "discover-suggested-artists"

View File

@@ -0,0 +1,206 @@
appId: com.cosmonautical.jellify
---
# Quick Actions Swipe Test
# This test validates the quick action menu that appears when swiping on track rows
# The test works with any swipe action configuration
# Start from Home tab
- tapOn:
id: "home-tab-button"
# Wait for content to load
- assertVisible: "Home"
# Navigate to Recently Played full list
- tapOn: "Play it again"
# Wait for track list to load
- assertVisible: "Recently Played"
# Wait a moment for tracks to render
- extendedWaitUntil:
visible:
id: "track-item-0"
timeout: 10000
# Test Right Swipe (Left Quick Actions)
# Swipe right on a track to reveal left-side quick actions
# Using a slower, more deliberate swipe gesture
- swipe:
start: 15%, 30%
end: 90%, 30%
duration: 300
# Wait for animation
- waitForAnimationToEnd
# Assert that quick action buttons are visible after swipe
# The exact buttons depend on user settings
# With 1 action configured: immediate action (no buttons)
# With 2+ actions: quick action menu appears
- assertVisible:
id: "quick-action-left-0"
optional: true
# If multiple left actions configured, check for additional buttons
- assertVisible:
id: "quick-action-left-1"
optional: true
- assertVisible:
id: "quick-action-left-2"
optional: true
# If quick actions appeared (multi-action config), tap the first one
- tapOn:
id: "quick-action-left-0"
optional: true
# Wait a moment for the action to complete and menu to close
- waitForAnimationToEnd
# Test Left Swipe (Right Quick Actions)
# Scroll down a bit to get a fresh track
- scroll
# Swipe left on a track to reveal right-side quick actions
# Using a slower, more deliberate swipe gesture
- swipe:
start: 80%, 40%
end: 25%, 40%
duration: 300
# Wait for animation
- waitForAnimationToEnd
# Assert that right quick action buttons are visible (if multi-action config)
- assertVisible:
id: "quick-action-right-0"
optional: true
# If multiple actions are configured, verify second and third buttons
- assertVisible:
id: "quick-action-right-1"
optional: true
- assertVisible:
id: "quick-action-right-2"
optional: true
# Tap the first right quick action if it appeared
- tapOn:
id: "quick-action-right-0"
optional: true
# Wait for action to complete
- waitForAnimationToEnd
# Test menu closes when tapping elsewhere
# Swipe to open menu again
# Start further from edge to avoid Android back gesture
- swipe:
start: 80%, 45%
end: 25%, 45%
duration: 300
# Wait for menu to open
# Check if quick action menu appeared (multi-action config)
- assertVisible:
id: "quick-action-right-0"
optional: true
# Tap on the track content area to close the menu (if it opened)
- tapOn:
point: 50%, 45%
# Wait for menu to close
# Verify the menu closed (only relevant if it was open)
- assertNotVisible:
id: "quick-action-right-0"
optional: true
# Navigate to Library to test with different content
- tapOn:
id: "library-tab-button"
# Test swipe actions in Library tab
- tapOn: "Tracks"
# Wait for tracks to load
- assertVisible: "Tracks"
# Scroll to ensure we're not at the top
- scroll
# Test swipe on library tracks
# Using a slower, more deliberate swipe gesture
# Start further from edge to avoid gesture conflicts
- swipe:
start: 15%, 35%
end: 70%, 35%
duration: 300
# Wait for animation
- waitForAnimationToEnd
# Verify quick actions appear in Library context too (if multi-action config)
- assertVisible:
id: "quick-action-left-0"
optional: true
# Close the menu by tapping (if it opened)
- tapOn:
point: 50%, 35%
# Wait for menu to close
- waitForAnimationToEnd
# Test quick actions in Search tab
- tapOn:
id: "search-tab-button"
# Wait for search screen
- assertVisible: "Search"
# Type a search query
- tapOn:
text: "Search"
- inputText: "music"
- hideKeyboard
# Wait for search results
- waitForAnimationToEnd
# Scroll down to see more results
- scroll
# Test swipe actions on search results (tracks only have swipe actions)
# Look for a track in results and swipe
# Using a slower, more deliberate swipe gesture
# Start further from edge to avoid Android back gesture
- swipe:
start: 80%, 45%
end: 25%, 45%
duration: 300
# Wait for animation
- waitForAnimationToEnd
# Verify quick actions work in search context
- assertVisible:
id: "quick-action-right-0"
optional: true
# If actions appeared, close them
- tapOn:
point: 50%, 40%
optional: true
# Return to home
- tapOn:
id: "home-tab-button"

View File

@@ -13,22 +13,16 @@ appId: com.cosmonautical.jellify
text: "App" text: "App"
# Test App (Preferences) Tab - should already be selected # Test App (Preferences) Tab - should already be selected
- assertVisible:
text: "Send Metrics and Crash Reports"
- assertVisible:
text: "Send anonymous usage and crash data"
- assertVisible:
text: "Reduce Haptics"
- assertVisible:
text: "Reduce haptic feedback"
- assertVisible: - assertVisible:
text: "Theme" text: "Theme"
- assertVisible: - assertVisible:
text: "System" text: "Match Device"
- assertVisible: - assertVisible:
text: "Light" text: "Light"
- assertVisible: - assertVisible:
text: "Dark" text: "Dark"
- assertVisible:
text: "Track Swipe Actions"
# Test Player (Playback) Tab # Test Player (Playback) Tab
- tapOn: - tapOn:

View File

@@ -1,22 +1,22 @@
{ {
"name": "jellify", "name": "jellify",
"version": "0.19.1", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"init-android": "yarn install --network-concurrency 1", "init-android": "bun i",
"init-ios": "yarn init-ios:new-arch", "init-ios": "bun run init-ios:new-arch",
"init-ios:new-arch": "yarn install --network-concurrency 1 && yarn pod:install:new-arch", "init-ios:new-arch": "bun i && bun run pod:install:new-arch",
"reinstall": "rm -rf ./node_modules && yarn install", "reinstall": "rm -rf ./node_modules && bun i",
"android": "react-native run-android", "android": "react-native run-android",
"ios": "react-native run-ios", "ios": "react-native run-ios",
"lint": "eslint .", "lint": "eslint .",
"start": "react-native start", "start": "react-native start",
"test": "jest", "test": "bunx jest",
"tsc": "tsc", "tsc": "tsc",
"codegen": "env DEBUG=metro:* react-native codegen", "codegen": "env DEBUG=metro:* react-native codegen",
"clean:ios": "cd ios && pod deintegrate", "clean:ios": "cd ios && pod deintegrate",
"clean:android": "cd android && rm -rf app/ build/", "clean:android": "cd android && rm -rf app/ build/",
"pod:install": "echo 'Please run `yarn pod:install:new-arch` to enable the new architecture'", "pod:install": "echo 'Please run `bun run pod:install:new-arch` to enable the new architecture'",
"pod:install:new-arch": "cd ios && bundle install && RCT_NEW_ARCH_ENABLED=1 bundle exec pod install", "pod:install:new-arch": "cd ios && bundle install && RCT_NEW_ARCH_ENABLED=1 bundle exec pod install",
"pod:clean": "cd ios && pod deintegrate", "pod:clean": "cd ios && pod deintegrate",
"fastlane:ios:build": "cd ios && bundle exec fastlane build", "fastlane:ios:build": "cd ios && bundle exec fastlane build",
@@ -32,31 +32,32 @@
"createBundle:ios": "mkdir -p ios/App-Bundles && react-native bundle --platform ios --dev false --entry-file index.js --bundle-output ios/App-Bundles/main.jsbundle --assets-dest ios/App-Bundles", "createBundle:ios": "mkdir -p ios/App-Bundles && react-native bundle --platform ios --dev false --entry-file index.js --bundle-output ios/App-Bundles/main.jsbundle --assets-dest ios/App-Bundles",
"sendOTA:android": "bash scripts/ota-android.sh", "sendOTA:android": "bash scripts/ota-android.sh",
"sendOTA:iOS": "bash scripts/ota-iOS.sh", "sendOTA:iOS": "bash scripts/ota-iOS.sh",
"sendOTA:PR": "bash scripts/ota-PR.sh",
"android-build": "cd android && ./gradlew generateCodegenArtifactsFromSchema && ./gradlew assembleRelease", "android-build": "cd android && ./gradlew generateCodegenArtifactsFromSchema && ./gradlew assembleRelease",
"postinstall": "patch-package" "postinstall": "patch-package"
}, },
"dependencies": { "dependencies": {
"@jellyfin/sdk": "0.13.0", "@jellyfin/sdk": "0.13.0",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/cli": "20.0.0", "@react-native-community/cli": "20.0.0",
"@react-native-community/netinfo": "^11.4.1", "@react-native-community/netinfo": "^11.4.1",
"@react-native-masked-view/masked-view": "^0.3.2", "@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-vector-icons/material-design-icons": "^12.3.0", "@react-native-vector-icons/material-design-icons": "12.4.0",
"@react-navigation/bottom-tabs": "7.6.0", "@react-navigation/bottom-tabs": "7.8.10",
"@react-navigation/material-top-tabs": "7.4.0", "@react-navigation/material-top-tabs": "7.4.7",
"@react-navigation/native": "7.1.19", "@react-navigation/native": "7.1.23",
"@react-navigation/native-stack": "7.6.0", "@react-navigation/native-stack": "7.8.4",
"@sentry/react-native": "7.1.0", "@sentry/react-native": "7.6.0",
"@shopify/flash-list": "^2.1.0", "@shopify/flash-list": "2.2.0",
"@tamagui/config": "1.135.4", "@tamagui/config": "1.137.1",
"@tanstack/query-async-storage-persister": "5.89.0", "@tanstack/query-async-storage-persister": "5.89.0",
"@tanstack/react-query": "5.89.0", "@tanstack/react-query": "5.89.0",
"@tanstack/react-query-persist-client": "5.89.0", "@tanstack/react-query-persist-client": "5.89.0",
"@testing-library/react-native": "^13.2.3", "@testing-library/react-native": "13.3.3",
"@typedigital/telemetrydeck-react": "^0.4.1", "@typedigital/telemetrydeck-react": "^0.4.1",
"axios": "1.12.2", "axios": "1.13.2",
"bundle": "^2.1.0", "bundle": "^2.1.0",
"dlx": "^0.2.1", "dlx": "^0.2.1",
"gem": "^2.4.3",
"invert-color": "^2.0.0", "invert-color": "^2.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"openai": "5.21.0", "openai": "5.21.0",
@@ -67,34 +68,32 @@
"react-native-blurhash": "2.1.1", "react-native-blurhash": "2.1.1",
"react-native-carplay": "^2.4.1-beta.0", "react-native-carplay": "^2.4.1-beta.0",
"react-native-config": "1.5.6", "react-native-config": "1.5.6",
"react-native-device-info": "^14.0.4", "react-native-device-info": "15.0.1",
"react-native-dns-lookup": "^1.0.6", "react-native-dns-lookup": "^1.0.6",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-flashdrag-list": "^0.2.5",
"react-native-fs": "^2.20.0", "react-native-fs": "^2.20.0",
"react-native-gesture-handler": "^2.28.0", "react-native-gesture-handler": "2.29.1",
"react-native-google-cast": "^4.9.1", "react-native-google-cast": "^4.9.1",
"react-native-haptic-feedback": "^2.3.3", "react-native-haptic-feedback": "^2.3.3",
"react-native-linear-gradient": "^2.8.3", "react-native-linear-gradient": "^2.8.3",
"react-native-mmkv": "3.3.3", "react-native-mmkv": "3.3.3",
"react-native-nitro-image": "0.8.1", "react-native-nitro-fetch": "^0.1.6",
"react-native-nitro-modules": "^0.31.1", "react-native-nitro-modules": "0.31.10",
"react-native-nitro-ota": "^0.3.0", "react-native-nitro-ota": "0.7.2",
"react-native-nitro-web-image": "0.8.1", "react-native-pager-view": "^7.0.2",
"react-native-pager-view": "^6.9.1", "react-native-reanimated": "4.1.5",
"react-native-reanimated": "4.1.3", "react-native-safe-area-context": "5.6.2",
"react-native-safe-area-context": "^5.6.1",
"react-native-screens": "4.18.0", "react-native-screens": "4.18.0",
"react-native-swipeable-item": "^2.0.9", "react-native-sortables": "1.9.4",
"react-native-text-ticker": "^1.15.0", "react-native-text-ticker": "^1.15.0",
"react-native-toast-message": "^2.3.3", "react-native-toast-message": "^2.3.3",
"react-native-track-player": "5.0.0-alpha0", "react-native-track-player": "5.0.0-alpha0",
"react-native-url-polyfill": "^2.0.0", "react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.3", "react-native-uuid": "^2.0.3",
"react-native-worklets": "0.6.1", "react-native-worklets": "0.6.1",
"react-native-worklets-core": "^1.6.2",
"ruby": "^0.6.1", "ruby": "^0.6.1",
"scheduler": "^0.26.0", "scheduler": "^0.26.0",
"tamagui": "1.135.4", "tamagui": "1.137.1",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
@@ -116,6 +115,7 @@
"@types/react-native-vector-icons": "^6.4.18", "@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "19.1.0", "@types/react-test-renderer": "19.1.0",
"babel-plugin-module-resolver": "^5.0.2", "babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.33.0", "eslint": "^9.33.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
@@ -141,7 +141,13 @@
] ]
}, },
"engines": { "engines": {
"bun": ">=1.3.2",
"node": ">=18" "node": ">=18"
}, },
"packageManager": "yarn@1.22.22" "packageManager": "bun@1.3.2",
"trustedDependencies": [
"@sentry/cli",
"react-native-nitro-modules",
"unrs-resolver"
]
} }

View File

@@ -0,0 +1,13 @@
diff --git a/node_modules/react-native-nitro-fetch/android/build.gradle b/node_modules/react-native-nitro-fetch/android/build.gradle
index 3175736..9b326ff 100644
--- a/node_modules/react-native-nitro-fetch/android/build.gradle
+++ b/node_modules/react-native-nitro-fetch/android/build.gradle
@@ -110,7 +110,7 @@ repositories {
def kotlin_version = getExtOrDefault("kotlinVersion")
// ---------- Cronet (Java API only) ----------
-def cronetVersion = (getExtOrDefault("cronetVersion") ?: "119.6045.31")
+def cronetVersion = (getExtOrDefault("cronetVersion") ?: "141.7340.3")
dependencies {

View File

@@ -0,0 +1,13 @@
diff --git a/node_modules/react-native-worklets-core/android/CMakeLists.txt b/node_modules/react-native-worklets-core/android/CMakeLists.txt
index cc4cbfe..61e1b56 100644
--- a/node_modules/react-native-worklets-core/android/CMakeLists.txt
+++ b/node_modules/react-native-worklets-core/android/CMakeLists.txt
@@ -81,7 +81,7 @@ if(${JS_RUNTIME} STREQUAL "hermes")
target_link_libraries(
${PACKAGE_NAME}
- hermes-engine::libhermes
+ hermes-engine::hermesvm
)
if(${HERMES_ENABLE_DEBUGGER})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 588 KiB

After

Width:  |  Height:  |  Size: 846 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 KiB

After

Width:  |  Height:  |  Size: 643 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 554 KiB

After

Width:  |  Height:  |  Size: 497 KiB

View File

@@ -3,6 +3,12 @@ set -euo pipefail
FILE="ota.version" FILE="ota.version"
# Check if --PR flag is passed
IS_PR=false
if [[ "${1:-}" == "--PR" ]]; then
IS_PR=true
fi
# Array of sentences # Array of sentences
sentences=( sentences=(
"Git Blame violet" "Git Blame violet"
@@ -30,11 +36,28 @@ get_random_sentence() {
done done
} }
# Function to generate a random 3-digit alphanumeric string
get_random_alphanum() {
local chars="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
local result=""
for i in {1..3}; do
result="${result}${chars:RANDOM%${#chars}:1}"
done
echo "$result"
}
new_sentence=$(get_random_sentence) new_sentence=$(get_random_sentence)
alphanum_suffix=$(get_random_alphanum)
version_string="${new_sentence} (${alphanum_suffix})"
# Prefix for PR builds
if $IS_PR; then
version_string="PULL_REQUEST - ${version_string}"
fi
# Write atomically # Write atomically
tmp="${FILE}.tmp.$$" tmp="${FILE}.tmp.$$"
echo "$new_sentence" > "$tmp" echo "$version_string" > "$tmp"
mv "$tmp" "$FILE" mv "$tmp" "$FILE"
echo "✅ Updated $FILE with: \"$new_sentence\"" echo "✅ Updated $FILE with: \"$version_string\""

View File

@@ -0,0 +1,14 @@
#!/bin/bash
# Script to run Maestro Android tests with retry logic
# Usage: ./maestro-android-retry.sh <jellyfin_url> <username>
if [ -z "$1" ] || [ -z "$2" ]; then
echo "Error: Missing required arguments"
echo "Usage: $0 <jellyfin_url> <username>"
exit 1
fi
JELLYFIN_URL="$1"
USERNAME="$2"
node scripts/maestro-android.js "$JELLYFIN_URL" "$USERNAME"

53
scripts/ota-PR.sh Normal file
View File

@@ -0,0 +1,53 @@
if [ -z "$1" ]; then
echo "Error: Version argument is required"
echo "Usage: $0 <version>"
exit 1
fi
version="$1"
target_branch="PULL_REQUEST_${version}_android"
cd android
git clone https://github.com/Jellify-Music/App-Bundles.git
cd App-Bundles
if git ls-remote --exit-code --heads origin "$target_branch" >/dev/null 2>&1; then
echo "Branch '$target_branch' already exists on remote."
git checkout "$target_branch"
else
echo "Branch '$target_branch' does not exist on remote. Attempting to create it..."
git checkout -b "$target_branch"
fi
cd ../..
bun createBundle:android
cd android/App-Bundles
bash ../../scripts/getRandomVersion.sh --PR
git add .
git commit -m "OTA-Update - $(date +'%b %d %H:%M')"
git push https://x-access-token:$SIGNING_REPO_PAT@github.com/Jellify-Music/App-Bundles.git "$target_branch"
cd ..
rm -rf App-Bundles
cd ..
target_branch="PULL_REQUEST_${version}_ios"
cd ios
rm -rf App-Bundles
git clone https://github.com/Jellify-Music/App-Bundles.git
cd App-Bundles
if git ls-remote --exit-code --heads origin "$target_branch" >/dev/null 2>&1; then
echo "Branch '$target_branch' already exists on remote."
git checkout "$target_branch"
else
echo "Branch '$target_branch' does not exist on remote. Attempting to create it..."
git checkout -b "$target_branch"
fi
rm -rf Readme.md
cd ../..
bun createBundle:ios
cd ios/App-Bundles
bash ../../scripts/getRandomVersion.sh --PR
git add .
git commit -m "OTA-Update - $(date +'%b %d %H:%M')"
git push https://x-access-token:$SIGNING_REPO_PAT@github.com/Jellify-Music/App-Bundles.git "$target_branch"
cd ..
rm -rf App-Bundles
cd ..

View File

@@ -11,7 +11,7 @@ else
git checkout -b "$target_branch" git checkout -b "$target_branch"
fi fi
cd ../.. cd ../..
yarn createBundle:android bun createBundle:android
cd android/App-Bundles cd android/App-Bundles
bash ../../scripts/getRandomVersion.sh bash ../../scripts/getRandomVersion.sh
git add . git add .

View File

@@ -14,7 +14,7 @@ else
fi fi
rm -rf Readme.md rm -rf Readme.md
cd ../.. cd ../..
yarn createBundle:ios bun createBundle:ios
cd ios/App-Bundles cd ios/App-Bundles
bash ../../scripts/getRandomVersion.sh bash ../../scripts/getRandomVersion.sh
git add . git add .

View File

@@ -0,0 +1,14 @@
const WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL
async function sendDiscordMessage(message) {
const res = await fetch(WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: message }),
})
console.log('Sent:', message)
}
const msg = process.argv.slice(2).join(' ')
sendDiscordMessage(msg)

View File

@@ -3,8 +3,6 @@ import { getModel, getUniqueIdSync } from 'react-native-device-info'
import { name, version } from '../../package.json' import { name, version } from '../../package.json'
import { capitalize } from 'lodash' import { capitalize } from 'lodash'
console.debug(`Building Jellyfin Info`)
/** /**
* Client object that represents Jellify on the Jellyfin server. * Client object that represents Jellify on the Jellyfin server.
*/ */

View File

@@ -2,35 +2,39 @@ import { AxiosResponse } from 'axios'
import { JellyfinCredentials } from '../../types/jellyfin-credentials' import { JellyfinCredentials } from '../../types/jellyfin-credentials'
import { AuthenticationResult } from '@jellyfin/sdk/lib/generated-client' import { AuthenticationResult } from '@jellyfin/sdk/lib/generated-client'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { useJellifyContext } from '../../../providers'
import { JellifyUser } from '../../../types/JellifyUser' import { JellifyUser } from '../../../types/JellifyUser'
import { isUndefined } from 'lodash' import { isUndefined } from 'lodash'
import { getUserApi } from '@jellyfin/sdk/lib/utils/api'
import { useApi, useJellifyUser } from '../../../stores'
interface AuthenticateUserByNameMutation { interface AuthenticateUserByNameMutation {
onSuccess?: () => void onSuccess?: () => void
onError?: () => void onError?: (error: Error) => void
} }
const useAuthenticateUserByName = ({ onSuccess, onError }: AuthenticateUserByNameMutation) => { const useAuthenticateUserByName = ({ onSuccess, onError }: AuthenticateUserByNameMutation) => {
const { api, setUser } = useJellifyContext() const api = useApi()
const [user, setUser] = useJellifyUser()
return useMutation({ return useMutation({
mutationFn: async (credentials: JellyfinCredentials) => { mutationFn: async (credentials: JellyfinCredentials) => {
return await api!.authenticateUserByName(credentials.username, credentials.password) return await getUserApi(api!).authenticateUserByName({
authenticateUserByName: {
Username: credentials.username,
Pw: credentials.password,
},
})
}, },
onSuccess: async (authResult: AxiosResponse<AuthenticationResult>) => { onSuccess: async (authResult: AxiosResponse<AuthenticationResult>) => {
console.log(`Received auth response from server`)
if (isUndefined(authResult)) if (isUndefined(authResult))
return Promise.reject(new Error('Authentication result was empty')) return Promise.reject(new Error('Authentication result was empty'))
if (authResult.status >= 400 || isUndefined(authResult.data.AccessToken)) if (authResult.status == 400 || isUndefined(authResult.data.AccessToken))
return Promise.reject(new Error('Invalid credentials')) return Promise.reject(new Error('Invalid credentials'))
if (isUndefined(authResult.data.User)) if (isUndefined(authResult.data.User))
return Promise.reject(new Error('Unable to login')) return Promise.reject(new Error('Unable to login'))
console.log(`Successfully signed in to server`)
const user: JellifyUser = { const user: JellifyUser = {
id: authResult.data.User!.Id!, id: authResult.data.User!.Id!,
name: authResult.data.User!.Name!, name: authResult.data.User!.Name!,
@@ -44,7 +48,7 @@ const useAuthenticateUserByName = ({ onSuccess, onError }: AuthenticateUserByNam
onError: async (error: Error) => { onError: async (error: Error) => {
console.error('An error occurred connecting to the Jellyfin instance', error) console.error('An error occurred connecting to the Jellyfin instance', error)
if (onError) onError() if (onError) onError(error)
}, },
retry: 0, retry: 0,
gcTime: 0, gcTime: 0,

View File

@@ -0,0 +1,23 @@
import { useMutation } from '@tanstack/react-query'
import { useRecentlyAddedAlbums } from '../../queries/album'
import { usePublicPlaylists } from '../../queries/playlist'
import { useDiscoverArtists } from '../../queries/suggestions'
const useDiscoverQueries = () => {
const { refetch: refetchRecentlyAdded } = useRecentlyAddedAlbums()
const { refetch: refetchPublicPlaylists } = usePublicPlaylists()
const { refetch: refetchArtistSuggestions } = useDiscoverArtists()
return useMutation({
mutationFn: async () =>
await Promise.allSettled([
refetchRecentlyAdded(),
refetchPublicPlaylists(),
refetchArtistSuggestions(),
]),
})
}
export default useDiscoverQueries

View File

@@ -1,5 +1,4 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { useJellifyContext } from '../../../providers'
import { useDownloadingDeviceProfile } from '../../../stores/device-profile' import { useDownloadingDeviceProfile } from '../../../stores/device-profile'
import { UseMutateFunction, useMutation } from '@tanstack/react-query' import { UseMutateFunction, useMutation } from '@tanstack/react-query'
import { mapDtoToTrack } from '../../../utils/mappings' import { mapDtoToTrack } from '../../../utils/mappings'
@@ -7,12 +6,13 @@ import { deleteAudio, saveAudio } from './offlineModeUtils'
import { useState } from 'react' import { useState } from 'react'
import { JellifyDownloadProgress } from '../../../types/JellifyDownload' import { JellifyDownloadProgress } from '../../../types/JellifyDownload'
import { useAllDownloadedTracks } from '../../queries/download' import { useAllDownloadedTracks } from '../../queries/download'
import { useApi } from '../../../stores'
export const useDownloadAudioItem: () => [ export const useDownloadAudioItem: () => [
JellifyDownloadProgress, JellifyDownloadProgress,
UseMutateFunction<boolean, Error, { item: BaseItemDto; autoCached: boolean }, void>, UseMutateFunction<boolean, Error, { item: BaseItemDto; autoCached: boolean }, void>,
] = () => { ] = () => {
const { api } = useJellifyContext() const api = useApi()
const { data: downloadedTracks, refetch } = useAllDownloadedTracks() const { data: downloadedTracks, refetch } = useAllDownloadedTracks()
@@ -23,7 +23,7 @@ export const useDownloadAudioItem: () => [
return [ return [
downloadProgress, downloadProgress,
useMutation({ useMutation({
onMutate: () => console.debug('Downloading audio track from Jellyfin'), onMutate: () => {},
mutationFn: async ({ mutationFn: async ({
item, item,
autoCached, autoCached,
@@ -40,7 +40,7 @@ export const useDownloadAudioItem: () => [
) )
return Promise.resolve(false) return Promise.resolve(false)
const track = mapDtoToTrack(api, item, downloadedTracks ?? [], deviceProfile) const track = mapDtoToTrack(api, item, deviceProfile)
return saveAudio(track, setDownloadProgress, autoCached) return saveAudio(track, setDownloadProgress, autoCached)
}, },
@@ -79,8 +79,7 @@ export const useDeleteDownloads = () => {
}, },
onError: (error, itemIds) => onError: (error, itemIds) =>
console.error(`Unable to delete ${itemIds.length} downloads`, error), console.error(`Unable to delete ${itemIds.length} downloads`, error),
onSuccess: (_, itemIds) => onSuccess: (_, itemIds) => {},
console.debug(`Successfully deleted ${itemIds.length} downloads`),
onSettled: () => refetch(), onSettled: () => refetch(),
}).mutate }).mutate
} }

View File

@@ -11,28 +11,69 @@ import {
import { queryClient } from '../../../constants/query-client' import { queryClient } from '../../../constants/query-client'
import { AUDIO_CACHE_QUERY } from '../../queries/download/constants' import { AUDIO_CACHE_QUERY } from '../../queries/download/constants'
type DownloadedFileInfo = {
uri: string
path: string
fileName: string
size: number
}
const getExtensionFromUrl = (url: string): string | null => {
const sanitized = url.split('?')[0]
const lastSegment = sanitized.split('/').pop() ?? ''
const match = lastSegment.match(/\.([a-zA-Z0-9]+)$/)
return match?.[1] ?? null
}
const normalizeExtension = (ext: string | undefined | null) => {
if (!ext) return null
const clean = ext.toLowerCase()
return clean === 'mpeg' ? 'mp3' : clean
}
const extensionFromContentType = (contentType: string | undefined): string | null => {
if (!contentType) return null
if (!contentType.includes('/')) return null
const [, subtypeRaw] = contentType.split('/')
const container = subtypeRaw.split(';')[0]
return normalizeExtension(container)
}
export type DeleteDownloadsResult = {
deletedCount: number
freedBytes: number
failedCount: number
}
export async function downloadJellyfinFile( export async function downloadJellyfinFile(
url: string, url: string,
name: string, name: string,
songName: string, songName: string,
setDownloadProgress: JellifyDownloadProgressState, setDownloadProgress: JellifyDownloadProgressState,
) { preferredExtension?: string | null,
): Promise<DownloadedFileInfo> {
try { try {
// Fetch the file const urlExtension = normalizeExtension(getExtensionFromUrl(url))
const headRes = await axios.head(url) const hintedExtension = normalizeExtension(preferredExtension)
const contentType = headRes.headers['content-type']
// Step 2: Get extension from content-type let extension = urlExtension ?? hintedExtension ?? null
let extension = 'mp3' // default extension
if (contentType && contentType.includes('/')) { if (!extension) {
const parts = contentType.split('/') try {
const container = parts[1].split(';')[0] // handles "audio/m4a; charset=utf-8" const headRes = await axios.head(url)
if (container !== 'mpeg') { const headExtension = extensionFromContentType(headRes.headers['content-type'])
extension = container // don't use mpeg as an extension, use the default extension if (headExtension) extension = headExtension
} catch (error) {
console.warn(
'HEAD request failed when determining download type, using default',
error,
)
} }
} }
// Step 3: Build path if (!extension) extension = 'bin' // fallback without assuming a specific codec
// Build path
const fileName = `${name}.${extension}` const fileName = `${name}.${extension}`
const downloadDest = `${RNFS.DocumentDirectoryPath}/${fileName}` const downloadDest = `${RNFS.DocumentDirectoryPath}/${fileName}`
@@ -47,9 +88,7 @@ export async function downloadJellyfinFile(
toFile: downloadDest, toFile: downloadDest,
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
begin: (res: any) => { begin: (res: any) => {},
console.log('Download started')
},
progress: (data: any) => { progress: (data: any) => {
const percent = +(data.bytesWritten / data.contentLength).toFixed(2) const percent = +(data.bytesWritten / data.contentLength).toFixed(2)
@@ -63,9 +102,15 @@ export async function downloadJellyfinFile(
} }
const result = await RNFS.downloadFile(options).promise const result = await RNFS.downloadFile(options).promise
console.log('Download complete:', result)
return `file://${downloadDest}` const metadata = await RNFS.stat(downloadDest)
return {
uri: `file://${downloadDest}`,
path: downloadDest,
fileName,
size: Number(metadata.size),
}
} catch (error) { } catch (error) {
console.error('Download failed:', error) console.error('Download failed:', error)
throw error throw error
@@ -116,44 +161,43 @@ export const saveAudio = async (
} }
try { try {
console.debug('Downloading audio') const downloadedTrackFile = await downloadJellyfinFile(
const downloadtrack = await downloadJellyfinFile(
track.url, track.url,
track.item.Id as string, track.item.Id as string,
track.title as string, track.title as string,
setDownloadProgress, setDownloadProgress,
track.mediaSourceInfo?.Container,
) )
const dowloadalbum = await downloadJellyfinFile( let downloadedArtworkFile: DownloadedFileInfo | undefined
track.artwork as string, if (track.artwork) {
track.item.Id as string, downloadedArtworkFile = await downloadJellyfinFile(
track.title as string, track.artwork as string,
setDownloadProgress, track.item.Id as string,
) track.title as string,
console.log('downloadtrack', downloadtrack) setDownloadProgress,
if (downloadtrack) { undefined,
track.url = downloadtrack )
track.artwork = dowloadalbum
} }
track.url = downloadedTrackFile.uri
if (downloadedArtworkFile) track.artwork = downloadedArtworkFile.uri
const index = existingArray.findIndex((t) => t.item.Id === track.item.Id) const index = existingArray.findIndex((t) => t.item.Id === track.item.Id)
const downloadEntry: JellifyDownload = {
...track,
savedAt: new Date().toISOString(),
isAutoDownloaded,
path: downloadedTrackFile.uri,
fileSizeBytes: downloadedTrackFile.size,
artworkSizeBytes: downloadedArtworkFile?.size,
}
if (index >= 0) { if (index >= 0) {
// Replace existing // Replace existing
existingArray[index] = { existingArray[index] = downloadEntry
...track,
savedAt: new Date().toISOString(),
isAutoDownloaded,
path: downloadtrack,
}
} else { } else {
// Add new // Add new
existingArray.push({ existingArray.push(downloadEntry)
...track,
savedAt: new Date().toISOString(),
isAutoDownloaded,
path: downloadtrack,
})
} }
} catch (error) { } catch (error) {
return false return false
@@ -164,17 +208,8 @@ export const saveAudio = async (
} }
export const deleteAudio = async (itemId: string | undefined | null) => { export const deleteAudio = async (itemId: string | undefined | null) => {
const downloads = getAudioCache() if (!itemId) return
await deleteDownloadsByIds([itemId])
const download = downloads.filter((download) => download.item.Id === itemId)
if (download.length === 1) {
RNFS.unlink(`${RNFS.DocumentDirectoryPath}/${download[0].item.Id}`)
setAudioCache([
...downloads.slice(0, downloads.indexOf(download[0])),
...downloads.slice(downloads.indexOf(download[0]) + 1, downloads.length - 1),
])
}
} }
const setAudioCache = (downloads: JellifyDownload[]) => { const setAudioCache = (downloads: JellifyDownload[]) => {
@@ -194,8 +229,88 @@ export const getAudioCache = (): JellifyDownload[] => {
return existingArray return existingArray
} }
export const deleteAudioCache = async () => { const stripFileScheme = (path: string) => path.replace('file://', '')
const isLocalFile = (path: string) =>
path.startsWith('file://') || path.startsWith(RNFS.DocumentDirectoryPath)
const deleteLocalFileIfExists = async (
path: string | undefined,
fallbackSize?: number,
): Promise<number> => {
if (!path || !isLocalFile(path)) return 0
const normalizedPath = stripFileScheme(path)
try {
const exists = await RNFS.exists(normalizedPath)
let size = fallbackSize ?? 0
if (exists && !fallbackSize) {
const stat = await RNFS.stat(normalizedPath)
size = Number(stat.size)
}
if (exists) await RNFS.unlink(normalizedPath)
return size
} catch (error) {
console.warn('Failed to delete file', normalizedPath, error)
return 0
}
}
const deleteDownloadAssets = async (download: JellifyDownload): Promise<number> => {
let freedBytes = 0
freedBytes += await deleteLocalFileIfExists(download.path, download.fileSizeBytes)
freedBytes += await deleteLocalFileIfExists(download.artwork, download.artworkSizeBytes)
return freedBytes
}
export const deleteDownloadsByIds = async (
itemIds: (string | null | undefined)[],
): Promise<DeleteDownloadsResult> => {
const targets = new Set(itemIds.filter(Boolean) as string[])
if (targets.size === 0)
return {
deletedCount: 0,
failedCount: 0,
freedBytes: 0,
}
const downloads = getAudioCache()
const remaining: JellifyDownload[] = []
let freedBytes = 0
let deletedCount = 0
let failedCount = 0
for (const download of downloads) {
if (!targets.has(download.item.Id as string)) {
remaining.push(download)
continue
}
try {
freedBytes += await deleteDownloadAssets(download)
deletedCount += 1
} catch (error) {
failedCount += 1
remaining.push(download)
console.error('Failed to delete download', download.item.Id, error)
}
}
setAudioCache(remaining)
queryClient.invalidateQueries(AUDIO_CACHE_QUERY)
return {
deletedCount,
failedCount,
freedBytes,
}
}
export const deleteAudioCache = async (): Promise<DeleteDownloadsResult> => {
const downloads = getAudioCache()
const result = await deleteDownloadsByIds(downloads.map((download) => download.item.Id))
mmkv.delete(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE) mmkv.delete(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE)
return result
} }
export const purneAudioCache = async () => { export const purneAudioCache = async () => {
@@ -220,17 +335,7 @@ export const purneAudioCache = async () => {
// Remove the oldest `excess` files // Remove the oldest `excess` files
const itemsToDelete = autoDownloads.slice(0, excess) const itemsToDelete = autoDownloads.slice(0, excess)
for (const item of itemsToDelete) { for (const item of itemsToDelete) {
// Delete audio file await deleteDownloadAssets(item)
if (item.url && (await RNFS.exists(item.url))) {
await RNFS.unlink(item.url).catch(() => {})
}
// Delete artwork
if (item.artwork && (await RNFS.exists(item.artwork))) {
await RNFS.unlink(item.artwork).catch(() => {})
}
// Remove from the existingArray
existingArray = existingArray.filter((i) => i.item.Id !== item.item.Id) existingArray = existingArray.filter((i) => i.item.Id !== item.item.Id)
} }

View File

@@ -17,7 +17,7 @@ export default async function saveAudioItem(
if (downloadedTracks?.filter((download) => download.item.Id === item.Id).length ?? 0 > 0) if (downloadedTracks?.filter((download) => download.item.Id === item.Id).length ?? 0 > 0)
return Promise.resolve(false) return Promise.resolve(false)
const track = mapDtoToTrack(api, item, downloadedTracks ?? [], deviceProfile) const track = mapDtoToTrack(api, item, deviceProfile)
// TODO: fix download progresses // TODO: fix download progresses
return saveAudio(track, () => {}, autoCached) return saveAudio(track, () => {}, autoCached)

View File

@@ -0,0 +1,96 @@
import { queryClient } from '../../../constants/query-client'
import useHapticFeedback from '../../../hooks/use-haptic-feedback'
import { BaseItemDto, UserItemDataDto } from '@jellyfin/sdk/lib/generated-client'
import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api'
import { useMutation } from '@tanstack/react-query'
import { isUndefined } from 'lodash'
import Toast from 'react-native-toast-message'
import UserDataQueryKey from '../../queries/user-data/keys'
import { useApi, useJellifyUser } from '../../../../src/stores'
interface SetFavoriteMutation {
item: BaseItemDto
onToggle?: () => void
}
export const useAddFavorite = () => {
const api = useApi()
const [user] = useJellifyUser()
const trigger = useHapticFeedback()
return useMutation({
mutationFn: async ({ item }: SetFavoriteMutation) => {
if (isUndefined(api)) Promise.reject('API instance not defined')
else if (isUndefined(item.Id)) Promise.reject('Item ID is undefined')
else
return await getUserLibraryApi(api).markFavoriteItem({
itemId: item.Id,
})
},
onSuccess: (data, { item, onToggle }) => {
trigger('notificationSuccess')
if (onToggle) onToggle()
if (user)
queryClient.setQueryData(UserDataQueryKey(user, item), (prev: UserItemDataDto) => {
return {
...prev,
IsFavorite: true,
}
})
},
onError: (error, variables) => {
console.error('Unable to set favorite for item', error)
trigger('notificationError')
Toast.show({
text1: 'Failed to add favorite',
type: 'error',
})
},
})
}
export const useRemoveFavorite = () => {
const api = useApi()
const [user] = useJellifyUser()
const trigger = useHapticFeedback()
return useMutation({
mutationFn: async ({ item }: SetFavoriteMutation) => {
if (isUndefined(api)) Promise.reject('API instance not defined')
else if (isUndefined(item.Id)) Promise.reject('Item ID is undefined')
else
return await getUserLibraryApi(api).unmarkFavoriteItem({
itemId: item.Id,
})
},
onSuccess: (data, { item, onToggle }) => {
trigger('notificationSuccess')
if (onToggle) onToggle()
if (user)
queryClient.setQueryData(UserDataQueryKey(user, item), (prev: UserItemDataDto) => {
return {
...prev,
IsFavorite: false,
}
})
},
onError: (error, variables) => {
console.error('Unable to remove favorite for item', error)
trigger('notificationError')
Toast.show({
text1: 'Failed to remove favorite',
type: 'error',
})
},
})
}

View File

@@ -0,0 +1,30 @@
import { useMutation } from '@tanstack/react-query'
import { useFrequentlyPlayedArtists, useFrequentlyPlayedTracks } from '../../queries/frequents'
import { useRecentArtists, useRecentlyPlayedTracks } from '../../queries/recents'
import { useUserPlaylists } from '../../queries/playlist'
const useHomeQueries = () => {
const { refetch: refetchUserPlaylists } = useUserPlaylists()
const { refetch: refetchRecentArtists } = useRecentArtists()
const { refetch: refetchRecentlyPlayed } = useRecentlyPlayedTracks()
const { refetch: refetchFrequentArtists } = useFrequentlyPlayedArtists()
const { refetch: refetchFrequentlyPlayed } = useFrequentlyPlayedTracks()
return useMutation({
mutationFn: async () => {
await Promise.allSettled([
refetchRecentlyPlayed(),
refetchFrequentlyPlayed(),
refetchUserPlaylists(),
])
await Promise.allSettled([refetchFrequentArtists(), refetchRecentArtists()])
return true
},
})
}
export default useHomeQueries

View File

@@ -1,4 +1,3 @@
import { useJellifyContext } from '../../../providers'
import JellifyTrack from '../../../types/JellifyTrack' import JellifyTrack from '../../../types/JellifyTrack'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import reportPlaybackCompleted from './functions/playback-completed' import reportPlaybackCompleted from './functions/playback-completed'
@@ -6,19 +5,20 @@ import reportPlaybackStopped from './functions/playback-stopped'
import isPlaybackFinished from './utils' import isPlaybackFinished from './utils'
import reportPlaybackProgress from './functions/playback-progress' import reportPlaybackProgress from './functions/playback-progress'
import reportPlaybackStarted from './functions/playback-started' import reportPlaybackStarted from './functions/playback-started'
import { useApi } from '../../../stores'
interface PlaybackStartedMutation { interface PlaybackStartedMutation {
track: JellifyTrack track: JellifyTrack
} }
export const useReportPlaybackStarted = () => { export const useReportPlaybackStarted = () => {
const { api } = useJellifyContext() const api = useApi()
return useMutation({ return useMutation({
onMutate: () => {}, onMutate: () => {},
mutationFn: async ({ track }: PlaybackStartedMutation) => reportPlaybackStarted(api, track), mutationFn: async ({ track }: PlaybackStartedMutation) => reportPlaybackStarted(api, track),
onError: (error) => console.error(`Reporting playback started failed`, error), onError: (error) => console.error(`Reporting playback started failed`, error),
onSuccess: () => console.debug(`Reported playback started`), onSuccess: () => {},
}) })
} }
@@ -29,13 +29,10 @@ interface PlaybackStoppedMutation {
} }
export const useReportPlaybackStopped = () => { export const useReportPlaybackStopped = () => {
const { api } = useJellifyContext() const api = useApi()
return useMutation({ return useMutation({
onMutate: ({ lastPosition, duration }) => onMutate: ({ lastPosition, duration }) => {},
console.debug(
`Reporting playback ${isPlaybackFinished(lastPosition, duration) ? 'completed' : 'stopped'} for track`,
),
mutationFn: async ({ track, lastPosition, duration }: PlaybackStoppedMutation) => { mutationFn: async ({ track, lastPosition, duration }: PlaybackStoppedMutation) => {
return isPlaybackFinished(lastPosition, duration) return isPlaybackFinished(lastPosition, duration)
? await reportPlaybackCompleted(api, track) ? await reportPlaybackCompleted(api, track)
@@ -46,10 +43,7 @@ export const useReportPlaybackStopped = () => {
`Reporting playback ${isPlaybackFinished(lastPosition, duration) ? 'completed' : 'stopped'} failed`, `Reporting playback ${isPlaybackFinished(lastPosition, duration) ? 'completed' : 'stopped'} failed`,
error, error,
), ),
onSuccess: (_, { lastPosition, duration }) => onSuccess: (_, { lastPosition, duration }) => {},
console.debug(
`Reported playback ${isPlaybackFinished(lastPosition, duration) ? 'completed' : 'stopped'} successfully`,
),
}) })
} }
@@ -59,10 +53,10 @@ interface PlaybackProgressMutation {
} }
export const useReportPlaybackProgress = () => { export const useReportPlaybackProgress = () => {
const { api } = useJellifyContext() const api = useApi()
return useMutation({ return useMutation({
onMutate: ({ position }) => console.debug(`Reporting progress at ${position}`), onMutate: ({ position }) => {},
mutationFn: async ({ track, position }: PlaybackProgressMutation) => mutationFn: async ({ track, position }: PlaybackProgressMutation) =>
reportPlaybackProgress(api, track, position), reportPlaybackProgress(api, track, position),
}) })

View File

@@ -19,17 +19,11 @@ export async function addToPlaylist(
track: BaseItemDto, track: BaseItemDto,
playlist: BaseItemDto, playlist: BaseItemDto,
): Promise<void> { ): Promise<void> {
console.debug('Adding track to playlist')
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
if (isUndefined(api)) return reject(new Error('No API client available')) if (isUndefined(api)) return reject(new Error('No API client available'))
if (isUndefined(user)) return reject(new Error('No user available')) if (isUndefined(user)) return reject(new Error('No user available'))
console.debug(api)
console.debug(api.axiosInstance)
getPlaylistsApi(api) getPlaylistsApi(api)
.addItemToPlaylist( .addItemToPlaylist(
{ {
@@ -65,8 +59,6 @@ export async function addManyToPlaylist(
tracks: BaseItemDto[], tracks: BaseItemDto[],
playlist: BaseItemDto, playlist: BaseItemDto,
): Promise<void> { ): Promise<void> {
console.debug(`Adding ${tracks.length} tracks to playlist`)
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
if (isUndefined(api)) return reject(new Error('No API client available')) if (isUndefined(api)) return reject(new Error('No API client available'))
@@ -110,8 +102,6 @@ export async function removeFromPlaylist(
track: BaseItemDto, track: BaseItemDto,
playlist: BaseItemDto, playlist: BaseItemDto,
) { ) {
console.debug('Removing track from playlist')
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
if (isUndefined(api)) return reject(new Error('No API client available')) if (isUndefined(api)) return reject(new Error('No API client available'))
@@ -145,8 +135,6 @@ export async function reorderPlaylist(
itemId: string, itemId: string,
to: number, to: number,
) { ) {
console.debug(`Moving track to index ${to}`)
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
if (isUndefined(api)) return reject(new Error('No API client available')) if (isUndefined(api)) return reject(new Error('No API client available'))
@@ -179,8 +167,6 @@ export async function createPlaylist(
user: JellifyUser | undefined, user: JellifyUser | undefined,
name: string, name: string,
) { ) {
console.debug('Creating new playlist...')
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
if (isUndefined(api)) return reject(new Error('No API client available')) if (isUndefined(api)) return reject(new Error('No API client available'))
@@ -214,8 +200,6 @@ export async function createPlaylist(
* @returns * @returns
*/ */
export async function deletePlaylist(api: Api | undefined, playlistId: string) { export async function deletePlaylist(api: Api | undefined, playlistId: string) {
console.debug('Deleting playlist...')
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
if (isUndefined(api)) return reject(new Error('No API client available')) if (isUndefined(api)) return reject(new Error('No API client available'))
@@ -248,8 +232,7 @@ export async function updatePlaylist(
name: string, name: string,
trackIds: string[], trackIds: string[],
) { ) {
console.debug('Updating playlist') console.info('Updating playlist with name:', name, 'and track IDs:', trackIds)
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
if (isUndefined(api)) return reject(new Error('No API client available')) if (isUndefined(api)) return reject(new Error('No API client available'))

View File

@@ -3,7 +3,7 @@ import { connectToServer } from './utils'
import { JellifyServer } from '@/src/types/JellifyServer' import { JellifyServer } from '@/src/types/JellifyServer'
import serverAddressContainsProtocol from './utils/parsing' import serverAddressContainsProtocol from './utils/parsing'
import HTTPS, { HTTP } from '../../../constants/protocols' import HTTPS, { HTTP } from '../../../constants/protocols'
import { useJellifyContext } from '../../../providers' import useJellifyStore from '../../../stores'
interface PublicSystemInfoMutation { interface PublicSystemInfoMutation {
serverAddress: string serverAddress: string
@@ -16,14 +16,12 @@ interface PublicSystemInfoHook {
} }
const usePublicSystemInfo = ({ onSuccess, onError }: PublicSystemInfoHook) => { const usePublicSystemInfo = ({ onSuccess, onError }: PublicSystemInfoHook) => {
const { setServer } = useJellifyContext() const setServer = useJellifyStore((state) => state.setServer)
return useMutation({ return useMutation({
mutationFn: ({ serverAddress, useHttps }: PublicSystemInfoMutation) => mutationFn: ({ serverAddress, useHttps }: PublicSystemInfoMutation) =>
connectToServer(serverAddress!, useHttps), connectToServer(serverAddress!, useHttps),
onSuccess: ({ publicSystemInfoResponse, connectionType }, { serverAddress, useHttps }) => { onSuccess: ({ publicSystemInfoResponse, connectionType }, { serverAddress, useHttps }) => {
console.debug(`Got public system info response`)
if (!publicSystemInfoResponse.Version) if (!publicSystemInfoResponse.Version)
throw new Error(`Jellyfin instance did not respond`) throw new Error(`Jellyfin instance did not respond`)

View File

@@ -1,24 +1,33 @@
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import useStreamingDeviceProfile from '../../../stores/device-profile' import useStreamingDeviceProfile from '../../../stores/device-profile'
import { Api } from '@jellyfin/sdk'
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api' import { getSessionApi } from '@jellyfin/sdk/lib/utils/api'
import { MONOCHROME_ICON_URL } from '../../../configs/config' import { MONOCHROME_ICON_URL } from '../../../configs/config'
import { useEffect } from 'react'
import { useApi } from '../../../stores'
const usePostFullCapabilities = () => { const usePostFullCapabilities = () => {
const api = useApi()
const streamingDeviceProfile = useStreamingDeviceProfile() const streamingDeviceProfile = useStreamingDeviceProfile()
return useMutation({ const { mutate } = useMutation({
mutationFn: async (api: Api | undefined) => { onMutate: () => {},
mutationFn: async () => {
if (!api) return if (!api) return
return getSessionApi(api).postFullCapabilities({ return await getSessionApi(api).postFullCapabilities({
clientCapabilitiesDto: { clientCapabilitiesDto: {
IconUrl: MONOCHROME_ICON_URL, IconUrl: MONOCHROME_ICON_URL,
DeviceProfile: streamingDeviceProfile, DeviceProfile: streamingDeviceProfile,
}, },
}) })
}, },
onSuccess: () => console.info('Successfully posted player capabilities'),
onError: (error) => console.error('Unable to post player capabilities', error),
}) })
useEffect(() => {
mutate()
}, [streamingDeviceProfile.Id])
} }
export default usePostFullCapabilities export default usePostFullCapabilities

View File

@@ -1,6 +1,4 @@
import { useLibrarySortAndFilterContext } from '../../../providers/Library'
import { QueryKeys } from '../../../enums/query-keys' import { QueryKeys } from '../../../enums/query-keys'
import { useJellifyContext } from '../../../providers'
import { InfiniteData, useInfiniteQuery, UseInfiniteQueryResult } from '@tanstack/react-query' import { InfiniteData, useInfiniteQuery, UseInfiniteQueryResult } from '@tanstack/react-query'
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by' import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by'
import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order' import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order'
@@ -8,17 +6,21 @@ import { fetchAlbums } from './utils/album'
import { RefObject, useCallback, useRef } from 'react' import { RefObject, useCallback, useRef } from 'react'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import flattenInfiniteQueryPages from '../../../utils/query-selectors' import flattenInfiniteQueryPages from '../../../utils/query-selectors'
import { ApiLimits } from '../query.config' import { ApiLimits, MaxPages } from '../../../configs/query.config'
import { fetchRecentlyAdded } from '../recents/utils' import { fetchRecentlyAdded } from '../recents/utils'
import { queryClient } from '../../../constants/query-client' import { queryClient } from '../../../constants/query-client'
import { useApi, useJellifyLibrary, useJellifyUser } from '../../../stores'
import useLibraryStore from '../../../stores/library'
const useAlbums: () => [ const useAlbums: () => [
RefObject<Set<string>>, RefObject<Set<string>>,
UseInfiniteQueryResult<(string | number | BaseItemDto)[]>, UseInfiniteQueryResult<(string | number | BaseItemDto)[]>,
] = () => { ] = () => {
const { api, user, library } = useJellifyContext() const api = useApi()
const [user] = useJellifyUser()
const [library] = useJellifyLibrary()
const { isFavorites, sortDescending } = useLibrarySortAndFilterContext() const isFavorites = useLibraryStore((state) => state.isFavorites)
const albumPageParams = useRef<Set<string>>(new Set<string>()) const albumPageParams = useRef<Set<string>>(new Set<string>())
@@ -43,10 +45,11 @@ const useAlbums: () => [
), ),
initialPageParam: 0, initialPageParam: 0,
select: selectAlbums, select: selectAlbums,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { maxPages: MaxPages.Library,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
}, },
getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => { getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
return firstPageParam === 0 ? null : firstPageParam - 1 return firstPageParam === 0 ? null : firstPageParam - 1
}, },
}) })
@@ -57,20 +60,21 @@ const useAlbums: () => [
export default useAlbums export default useAlbums
export const useRecentlyAddedAlbums = () => { export const useRecentlyAddedAlbums = () => {
const { api, user, library } = useJellifyContext() const api = useApi()
const [library] = useJellifyLibrary()
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: [QueryKeys.RecentlyAddedAlbums, library?.musicLibraryId], queryKey: [QueryKeys.RecentlyAddedAlbums, library?.musicLibraryId],
queryFn: ({ pageParam }) => fetchRecentlyAdded(api, library, pageParam), queryFn: ({ pageParam }) => fetchRecentlyAdded(api, library, pageParam),
select: (data) => data.pages.flatMap((page) => page), select: (data) => data.pages.flatMap((page) => page),
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => getNextPageParam: (lastPage, allPages, lastPageParam) =>
lastPage.length > 0 ? lastPageParam + 1 : undefined, lastPage.length > 0 ? lastPageParam + 1 : undefined,
initialPageParam: 0, initialPageParam: 0,
}) })
} }
export const useRefetchRecentlyAdded: () => () => void = () => { export const useRefetchRecentlyAdded: () => () => void = () => {
const { library } = useJellifyContext() const [library] = useJellifyLibrary()
return () => return () =>
queryClient.invalidateQueries({ queryClient.invalidateQueries({

View File

@@ -10,7 +10,8 @@ import { Api } from '@jellyfin/sdk'
import { fetchItem, fetchItems } from '../../item' import { fetchItem, fetchItems } from '../../item'
import { JellifyUser } from '../../../../types/JellifyUser' import { JellifyUser } from '../../../../types/JellifyUser'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api' import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { ApiLimits } from '../../query.config' import { ApiLimits } from '../../../../configs/query.config'
import { nitroFetch } from '../../../utils/nitro'
export function fetchAlbums( export function fetchAlbums(
api: Api | undefined, api: Api | undefined,
user: JellifyUser | undefined, user: JellifyUser | undefined,
@@ -20,31 +21,26 @@ export function fetchAlbums(
sortBy: ItemSortBy[] = [ItemSortBy.SortName], sortBy: ItemSortBy[] = [ItemSortBy.SortName],
sortOrder: SortOrder[] = [SortOrder.Ascending], sortOrder: SortOrder[] = [SortOrder.Ascending],
): Promise<BaseItemDto[]> { ): Promise<BaseItemDto[]> {
console.debug('Fetching albums', page)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!api) return reject('No API instance provided') if (!api) return reject('No API instance provided')
if (!user) return reject('No user provided') if (!user) return reject('No user provided')
if (!library) return reject('Library has not been set') if (!library) return reject('Library has not been set')
getItemsApi(api) nitroFetch<{ Items: BaseItemDto[] }>(api, '/Items', {
.getItems({ ParentId: library.musicLibraryId,
parentId: library.musicLibraryId, IncludeItemTypes: [BaseItemKind.MusicAlbum],
includeItemTypes: [BaseItemKind.MusicAlbum], UserId: user.id,
userId: user.id, EnableUserData: true, // This will populate the user data query later down the line
enableUserData: true, // This will populate the user data query later down the line SortBy: sortBy,
sortBy, SortOrder: sortOrder,
sortOrder, StartIndex: page * ApiLimits.Library,
startIndex: page * ApiLimits.Library, Limit: ApiLimits.Library,
limit: ApiLimits.Library, IsFavorite: isFavorite,
isFavorite, Fields: [ItemFields.SortName],
fields: [ItemFields.SortName], Recursive: true,
recursive: true, }).then((data) => {
}) return data.Items ? resolve(data.Items) : resolve([])
.then(({ data }) => { })
console.debug('Albums Response receieved')
return data.Items ? resolve(data.Items) : resolve([])
})
}) })
} }

View File

@@ -6,16 +6,17 @@ import {
UseInfiniteQueryResult, UseInfiniteQueryResult,
useQuery, useQuery,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import { isString, isUndefined } from 'lodash' import { isUndefined } from 'lodash'
import { fetchArtistAlbums, fetchArtistFeaturedOn, fetchArtists } from './utils/artist' import { fetchArtistAlbums, fetchArtistFeaturedOn, fetchArtists } from './utils/artist'
import { useJellifyContext } from '../../../providers' import { ApiLimits, MaxPages } from '../../../configs/query.config'
import { ApiLimits } from '../query.config'
import { RefObject, useCallback, useRef } from 'react' import { RefObject, useCallback, useRef } from 'react'
import { useLibrarySortAndFilterContext } from '../../../providers/Library'
import flattenInfiniteQueryPages from '../../../utils/query-selectors' import flattenInfiniteQueryPages from '../../../utils/query-selectors'
import { useApi, useJellifyLibrary, useJellifyUser } from '../../../stores'
import useLibraryStore from '../../../stores/library'
export const useArtistAlbums = (artist: BaseItemDto) => { export const useArtistAlbums = (artist: BaseItemDto) => {
const { api, library } = useJellifyContext() const api = useApi()
const [library] = useJellifyLibrary()
return useQuery({ return useQuery({
queryKey: [QueryKeys.ArtistAlbums, library?.musicLibraryId, artist.Id], queryKey: [QueryKeys.ArtistAlbums, library?.musicLibraryId, artist.Id],
@@ -25,7 +26,8 @@ export const useArtistAlbums = (artist: BaseItemDto) => {
} }
export const useArtistFeaturedOn = (artist: BaseItemDto) => { export const useArtistFeaturedOn = (artist: BaseItemDto) => {
const { api, library } = useJellifyContext() const api = useApi()
const [library] = useJellifyLibrary()
return useQuery({ return useQuery({
queryKey: [QueryKeys.ArtistFeaturedOn, library?.musicLibraryId, artist.Id], queryKey: [QueryKeys.ArtistFeaturedOn, library?.musicLibraryId, artist.Id],
@@ -38,9 +40,11 @@ export const useAlbumArtists: () => [
RefObject<Set<string>>, RefObject<Set<string>>,
UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error>, UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error>,
] = () => { ] = () => {
const { api, user, library } = useJellifyContext() const api = useApi()
const [user] = useJellifyUser()
const [library] = useJellifyLibrary()
const { isFavorites, sortDescending } = useLibrarySortAndFilterContext() const { isFavorites, sortDescending } = useLibraryStore()
const artistPageParams = useRef<Set<string>>(new Set<string>()) const artistPageParams = useRef<Set<string>>(new Set<string>())
@@ -64,6 +68,7 @@ export const useAlbumArtists: () => [
[sortDescending ? SortOrder.Descending : SortOrder.Ascending], [sortDescending ? SortOrder.Descending : SortOrder.Ascending],
), ),
select: selectArtists, select: selectArtists,
maxPages: MaxPages.Library,
initialPageParam: 0, initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined

View File

@@ -9,7 +9,8 @@ import {
} from '@jellyfin/sdk/lib/generated-client/models' } from '@jellyfin/sdk/lib/generated-client/models'
import { getArtistsApi, getItemsApi } from '@jellyfin/sdk/lib/utils/api' import { getArtistsApi, getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { JellifyUser } from '../../../../types/JellifyUser' import { JellifyUser } from '../../../../types/JellifyUser'
import { ApiLimits } from '../../query.config' import { ApiLimits } from '../../../../configs/query.config'
import { nitroFetch } from '../../../utils/nitro'
export function fetchArtists( export function fetchArtists(
api: Api | undefined, api: Api | undefined,
@@ -20,28 +21,24 @@ export function fetchArtists(
sortBy: ItemSortBy[] = [ItemSortBy.SortName], sortBy: ItemSortBy[] = [ItemSortBy.SortName],
sortOrder: SortOrder[] = [SortOrder.Ascending], sortOrder: SortOrder[] = [SortOrder.Ascending],
): Promise<BaseItemDto[]> { ): Promise<BaseItemDto[]> {
console.debug('Fetching artists', page)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!api) return reject('No API instance provided') if (!api) return reject('No API instance provided')
if (!user) return reject('No user provided') if (!user) return reject('No user provided')
if (!library) return reject('Library has not been set') if (!library) return reject('Library has not been set')
getArtistsApi(api) nitroFetch<{ Items: BaseItemDto[] }>(api, '/Artists/AlbumArtists', {
.getAlbumArtists({ ParentId: library.musicLibraryId,
parentId: library.musicLibraryId, UserId: user.id,
userId: user.id, EnableUserData: true,
enableUserData: true, // This will populate the User Data query later down the line SortBy: sortBy,
sortBy: sortBy, SortOrder: sortOrder,
sortOrder: sortOrder, StartIndex: page * ApiLimits.Library,
startIndex: page * ApiLimits.Library, Limit: ApiLimits.Library,
limit: ApiLimits.Library, IsFavorite: isFavorite,
isFavorite: isFavorite, Fields: [ItemFields.SortName, ItemFields.Genres],
fields: [ItemFields.SortName, ItemFields.Genres], })
}) .then((data) => {
.then((response) => { return data.Items ? resolve(data.Items) : resolve([])
console.debug('Artists Response received')
return response.data.Items ? resolve(response.data.Items) : resolve([])
}) })
.catch((error) => { .catch((error) => {
reject(error) reject(error)
@@ -60,25 +57,22 @@ export function fetchArtistAlbums(
libraryId: string | undefined, libraryId: string | undefined,
artist: BaseItemDto, artist: BaseItemDto,
): Promise<BaseItemDto[]> { ): Promise<BaseItemDto[]> {
console.debug('Fetching artist albums')
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!api) return reject('No API instance provided') if (!api) return reject('No API instance provided')
if (!libraryId) return reject('Library has not been set') if (!libraryId) return reject('Library has not been set')
getItemsApi(api!) nitroFetch<{ Items: BaseItemDto[] }>(api!, '/Items', {
.getItems({ ParentId: libraryId,
parentId: libraryId, IncludeItemTypes: [BaseItemKind.MusicAlbum],
includeItemTypes: [BaseItemKind.MusicAlbum], Recursive: true,
recursive: true, ExcludeItemIds: [artist.Id!],
excludeItemIds: [artist.Id!], SortBy: [ItemSortBy.PremiereDate, ItemSortBy.ProductionYear, ItemSortBy.SortName],
sortBy: [ItemSortBy.PremiereDate, ItemSortBy.ProductionYear, ItemSortBy.SortName], SortOrder: [SortOrder.Descending],
sortOrder: [SortOrder.Descending], AlbumArtistIds: [artist.Id!],
albumArtistIds: [artist.Id!], Fields: [ItemFields.ChildCount],
fields: [ItemFields.ChildCount], })
}) .then((data) => {
.then((response) => { return data.Items ? resolve(data.Items) : resolve([])
return response.data.Items ? resolve(response.data.Items) : resolve([])
}) })
.catch((error) => { .catch((error) => {
reject(error) reject(error)
@@ -97,24 +91,21 @@ export function fetchArtistFeaturedOn(
libraryId: string | undefined, libraryId: string | undefined,
artist: BaseItemDto, artist: BaseItemDto,
): Promise<BaseItemDto[]> { ): Promise<BaseItemDto[]> {
console.debug('Fetching artist featured on')
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!api) return reject('No API instance provided') if (!api) return reject('No API instance provided')
if (!libraryId) return reject('Library has not been set') if (!libraryId) return reject('Library has not been set')
getItemsApi(api) nitroFetch<{ Items: BaseItemDto[] }>(api, '/Items', {
.getItems({ ParentId: libraryId,
parentId: libraryId, IncludeItemTypes: [BaseItemKind.MusicAlbum],
includeItemTypes: [BaseItemKind.MusicAlbum], Recursive: true,
recursive: true, ExcludeItemIds: [artist.Id!],
excludeItemIds: [artist.Id!], SortBy: [ItemSortBy.PremiereDate, ItemSortBy.ProductionYear, ItemSortBy.SortName],
sortBy: [ItemSortBy.PremiereDate, ItemSortBy.ProductionYear, ItemSortBy.SortName], SortOrder: [SortOrder.Descending],
sortOrder: [SortOrder.Descending], ContributingArtistIds: [artist.Id!],
contributingArtistIds: [artist.Id!], })
}) .then((data) => {
.then((response) => { return data.Items ? resolve(data.Items) : resolve([])
return response.data.Items ? resolve(response.data.Items) : resolve([])
}) })
.catch((error) => { .catch((error) => {
reject(error) reject(error)

View File

@@ -1,4 +1,5 @@
import RNFS from 'react-native-fs' import RNFS from 'react-native-fs'
import DeviceInfo from 'react-native-device-info'
type JellifyStorage = { type JellifyStorage = {
totalStorage: number totalStorage: number
@@ -9,10 +10,11 @@ type JellifyStorage = {
const fetchStorageInUse: () => Promise<JellifyStorage> = async () => { const fetchStorageInUse: () => Promise<JellifyStorage> = async () => {
const totalStorage = await RNFS.getFSInfo() const totalStorage = await RNFS.getFSInfo()
const storageInUse = await RNFS.stat(RNFS.DocumentDirectoryPath) const storageInUse = await RNFS.stat(RNFS.DocumentDirectoryPath)
const freeDiskStorage = await DeviceInfo.getFreeDiskStorage()
return { return {
totalStorage: totalStorage.totalSpace, totalStorage: totalStorage.totalSpace,
freeSpace: totalStorage.freeSpace, freeSpace: freeDiskStorage,
storageInUseByJellify: storageInUse.size, storageInUseByJellify: storageInUse.size,
} }
} }

View File

@@ -22,8 +22,6 @@ export async function fetchFavoriteArtists(
user: JellifyUser | undefined, user: JellifyUser | undefined,
library: JellifyLibrary | undefined, library: JellifyLibrary | undefined,
): Promise<BaseItemDto[]> { ): Promise<BaseItemDto[]> {
console.debug(`Fetching user's favorite artists`)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set') if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(user)) return reject('User instance not set') if (isUndefined(user)) return reject('User instance not set')
@@ -39,8 +37,6 @@ export async function fetchFavoriteArtists(
sortOrder: [SortOrder.Ascending], sortOrder: [SortOrder.Ascending],
}) })
.then((response) => { .then((response) => {
console.debug(`Received favorite artist response`, response)
if (response.data.Items) return resolve(response.data.Items) if (response.data.Items) return resolve(response.data.Items)
else return resolve([]) else return resolve([])
}) })
@@ -63,8 +59,6 @@ export async function fetchFavoriteAlbums(
user: JellifyUser | undefined, user: JellifyUser | undefined,
library: JellifyLibrary | undefined, library: JellifyLibrary | undefined,
): Promise<BaseItemDto[]> { ): Promise<BaseItemDto[]> {
console.debug(`Fetching user's favorite albums`)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set') if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(user)) return reject('User instance not set') if (isUndefined(user)) return reject('User instance not set')
@@ -80,8 +74,6 @@ export async function fetchFavoriteAlbums(
sortOrder: [SortOrder.Descending, SortOrder.Ascending], sortOrder: [SortOrder.Descending, SortOrder.Ascending],
}) })
.then((response) => { .then((response) => {
console.debug(`Received favorite album response`, response)
if (response.data.Items) return resolve(response.data.Items) if (response.data.Items) return resolve(response.data.Items)
else return resolve([]) else return resolve([])
}) })
@@ -104,8 +96,6 @@ export async function fetchFavoritePlaylists(
user: JellifyUser | undefined, user: JellifyUser | undefined,
library: JellifyLibrary | undefined, library: JellifyLibrary | undefined,
): Promise<BaseItemDto[]> { ): Promise<BaseItemDto[]> {
console.debug(`Fetching user's favorite playlists`)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set') if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(user)) return reject('User instance not set') if (isUndefined(user)) return reject('User instance not set')
@@ -120,7 +110,6 @@ export async function fetchFavoritePlaylists(
sortOrder: [SortOrder.Ascending], sortOrder: [SortOrder.Ascending],
}) })
.then((response) => { .then((response) => {
console.log(response)
if (response.data.Items) if (response.data.Items)
return resolve( return resolve(
response.data.Items.filter( response.data.Items.filter(
@@ -149,8 +138,6 @@ export async function fetchFavoriteTracks(
user: JellifyUser | undefined, user: JellifyUser | undefined,
library: JellifyLibrary | undefined, library: JellifyLibrary | undefined,
): Promise<BaseItemDto[]> { ): Promise<BaseItemDto[]> {
console.debug(`Fetching user's favorite tracks`)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set') if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(user)) return reject('User instance not set') if (isUndefined(user)) return reject('User instance not set')
@@ -166,8 +153,6 @@ export async function fetchFavoriteTracks(
sortOrder: [SortOrder.Ascending], sortOrder: [SortOrder.Ascending],
}) })
.then((response) => { .then((response) => {
console.debug(`Received favorite artist response`, response)
if (response.data.Items) return resolve(response.data.Items) if (response.data.Items) return resolve(response.data.Items)
else return resolve([]) else return resolve([])
}) })

View File

@@ -1,17 +1,19 @@
import { useInfiniteQuery } from '@tanstack/react-query' import { useInfiniteQuery } from '@tanstack/react-query'
import { FrequentlyPlayedArtistsQueryKey, FrequentlyPlayedTracksQueryKey } from './keys' import { FrequentlyPlayedArtistsQueryKey, FrequentlyPlayedTracksQueryKey } from './keys'
import { useJellifyContext } from '../../../providers'
import { fetchFrequentlyPlayed, fetchFrequentlyPlayedArtists } from './utils/frequents' import { fetchFrequentlyPlayed, fetchFrequentlyPlayedArtists } from './utils/frequents'
import { ApiLimits } from '../query.config' import { ApiLimits, MaxPages } from '../../../configs/query.config'
import { isUndefined } from 'lodash' import { isUndefined } from 'lodash'
import { useApi, useJellifyLibrary, useJellifyUser } from '../../../stores'
const FREQUENTS_QUERY_CONFIG = { const FREQUENTS_QUERY_CONFIG = {
maxPages: MaxPages.Home,
refetchOnMount: false, refetchOnMount: false,
staleTime: Infinity,
} as const } as const
export const useFrequentlyPlayedTracks = () => { export const useFrequentlyPlayedTracks = () => {
const { api, user, library } = useJellifyContext() const api = useApi()
const [user] = useJellifyUser()
const [library] = useJellifyLibrary()
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: FrequentlyPlayedTracksQueryKey(user, library), queryKey: FrequentlyPlayedTracksQueryKey(user, library),
@@ -19,7 +21,6 @@ export const useFrequentlyPlayedTracks = () => {
select: (data) => data.pages.flatMap((page) => page), select: (data) => data.pages.flatMap((page) => page),
initialPageParam: 0, initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
console.debug('Getting next page for frequently played')
return lastPage.length === ApiLimits.Home ? lastPageParam + 1 : undefined return lastPage.length === ApiLimits.Home ? lastPageParam + 1 : undefined
}, },
...FREQUENTS_QUERY_CONFIG, ...FREQUENTS_QUERY_CONFIG,
@@ -27,7 +28,9 @@ export const useFrequentlyPlayedTracks = () => {
} }
export const useFrequentlyPlayedArtists = () => { export const useFrequentlyPlayedArtists = () => {
const { api, user, library } = useJellifyContext() const api = useApi()
const [user] = useJellifyUser()
const [library] = useJellifyLibrary()
const { data: frequentlyPlayedTracks } = useFrequentlyPlayedTracks() const { data: frequentlyPlayedTracks } = useFrequentlyPlayedTracks()
@@ -37,7 +40,6 @@ export const useFrequentlyPlayedArtists = () => {
select: (data) => data.pages.flatMap((page) => page), select: (data) => data.pages.flatMap((page) => page),
initialPageParam: 0, initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
console.debug('Getting next page for frequent artists')
return lastPage.length > 0 ? lastPageParam + 1 : undefined return lastPage.length > 0 ? lastPageParam + 1 : undefined
}, },
enabled: !isUndefined(frequentlyPlayedTracks), enabled: !isUndefined(frequentlyPlayedTracks),

View File

@@ -9,7 +9,7 @@ import { Api } from '@jellyfin/sdk'
import { isEmpty, isNull, isUndefined } from 'lodash' import { isEmpty, isNull, isUndefined } from 'lodash'
import { JellifyLibrary } from '../../../../types/JellifyLibrary' import { JellifyLibrary } from '../../../../types/JellifyLibrary'
import { fetchItem } from '../../item' import { fetchItem } from '../../item'
import { ApiLimits } from '../../query.config' import { ApiLimits } from '../../../../configs/query.config'
import { JellifyUser } from '@/src/types/JellifyUser' import { JellifyUser } from '@/src/types/JellifyUser'
import { queryClient } from '../../../../constants/query-client' import { queryClient } from '../../../../constants/query-client'
import { InfiniteData } from '@tanstack/react-query' import { InfiniteData } from '@tanstack/react-query'
@@ -66,11 +66,7 @@ export function fetchFrequentlyPlayedArtists(
library: JellifyLibrary | undefined, library: JellifyLibrary | undefined,
page: number, page: number,
): Promise<BaseItemDto[]> { ): Promise<BaseItemDto[]> {
console.debug('Fetching frequently played artists', page)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
console.debug('Fetching frequently played artists')
if (isUndefined(api)) return reject('Client instance not set') if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(library)) return reject('Library instance not set') if (isUndefined(library)) return reject('Library instance not set')

View File

@@ -1,24 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { useFrequentlyPlayedArtists, useFrequentlyPlayedTracks } from '../frequents'
import { useRecentArtists, useRecentlyPlayedTracks } from '../recents'
const useHomeQueries = () => {
const { refetch: refetchRecentArtists } = useRecentArtists()
const { refetch: refetchRecentlyPlayed } = useRecentlyPlayedTracks()
const { refetch: refetchFrequentArtists } = useFrequentlyPlayedArtists()
const { refetch: refetchFrequentlyPlayed } = useFrequentlyPlayedTracks()
return useQuery({
queryKey: ['Home'],
queryFn: async () => {
await Promise.all([refetchRecentlyPlayed(), refetchFrequentlyPlayed()])
await Promise.all([refetchFrequentArtists(), refetchRecentArtists()])
return true
},
})
}
export default useHomeQueries

View File

@@ -2,22 +2,63 @@ import { Api } from '@jellyfin/sdk'
import { BaseItemDto, ImageType } from '@jellyfin/sdk/lib/generated-client/models' import { BaseItemDto, ImageType } from '@jellyfin/sdk/lib/generated-client/models'
import { getImageApi } from '@jellyfin/sdk/lib/utils/api' import { getImageApi } from '@jellyfin/sdk/lib/utils/api'
// Default image size for list thumbnails (optimized for common row heights)
const DEFAULT_THUMBNAIL_SIZE = 200
export interface ImageUrlOptions {
/** Maximum width of the requested image */
maxWidth?: number
/** Maximum height of the requested image */
maxHeight?: number
/** Image quality (0-100) */
quality?: number
}
export function getItemImageUrl( export function getItemImageUrl(
api: Api | undefined, api: Api | undefined,
item: BaseItemDto, item: BaseItemDto,
type: ImageType, type: ImageType,
options?: ImageUrlOptions,
): string | undefined { ): string | undefined {
const { AlbumId, AlbumPrimaryImageTag, ImageTags, Id } = item const { AlbumId, AlbumPrimaryImageTag, ImageTags, Id, AlbumArtists } = item
if (!api) return undefined if (!api) return undefined
return AlbumId // Use provided dimensions or default thumbnail size for list performance
? getImageApi(api).getItemImageUrlById(AlbumId, type, { const imageParams = {
tag: AlbumPrimaryImageTag ?? undefined, tag: undefined as string | undefined,
}) maxWidth: options?.maxWidth ?? DEFAULT_THUMBNAIL_SIZE,
: Id maxHeight: options?.maxHeight ?? DEFAULT_THUMBNAIL_SIZE,
? getImageApi(api).getItemImageUrlById(Id, type, { quality: options?.quality ?? 90,
tag: ImageTags ? ImageTags[type] : undefined, }
})
: undefined // Check if the item has its own image for the requested type first
const hasOwnImage = ImageTags && ImageTags[type]
if (hasOwnImage && Id) {
// Use the item's own image (e.g., track-specific artwork)
return getImageApi(api).getItemImageUrlById(Id, type, {
...imageParams,
tag: ImageTags[type],
})
} else if (AlbumId && AlbumPrimaryImageTag) {
// Fall back to album image (only if the album has an image)
return getImageApi(api).getItemImageUrlById(AlbumId, type, {
...imageParams,
tag: AlbumPrimaryImageTag,
})
} else if (AlbumArtists && AlbumArtists.length > 0 && AlbumArtists[0].Id) {
// Fall back to first album artist's image
return getImageApi(api).getItemImageUrlById(AlbumArtists[0].Id, type, {
...imageParams,
})
} else if (Id) {
// Last resort: use item's own ID
return getImageApi(api).getItemImageUrlById(Id, type, {
...imageParams,
tag: ImageTags ? ImageTags[type] : undefined,
})
}
return undefined
} }

View File

@@ -1,7 +1,7 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getInstantMixApi } from '@jellyfin/sdk/lib/utils/api' import { getInstantMixApi } from '@jellyfin/sdk/lib/utils/api'
import { isUndefined } from 'lodash' import { isUndefined } from 'lodash'
import QueryConfig from './query.config' import QueryConfig from '../../configs/query.config'
import { Api } from '@jellyfin/sdk' import { Api } from '@jellyfin/sdk'
import { JellifyUser } from '../../types/JellifyUser' import { JellifyUser } from '../../types/JellifyUser'
/** /**
@@ -16,8 +16,6 @@ export function fetchInstantMixFromItem(
user: JellifyUser | undefined, user: JellifyUser | undefined,
item: BaseItemDto, item: BaseItemDto,
): Promise<BaseItemDto[]> { ): Promise<BaseItemDto[]> {
console.debug('Fetching instant mix from item')
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject(new Error('Client not initialized')) if (isUndefined(api)) return reject(new Error('Client not initialized'))
if (isUndefined(user)) return reject(new Error('User not initialized')) if (isUndefined(user)) return reject(new Error('User not initialized'))

View File

@@ -10,8 +10,9 @@ import { groupBy, isEmpty, isEqual, isUndefined } from 'lodash'
import { SectionList } from 'react-native' import { SectionList } from 'react-native'
import { Api } from '@jellyfin/sdk/lib/api' import { Api } from '@jellyfin/sdk/lib/api'
import { JellifyLibrary } from '../../types/JellifyLibrary' import { JellifyLibrary } from '../../types/JellifyLibrary'
import QueryConfig from './query.config' import QueryConfig from '../../configs/query.config'
import { JellifyUser } from '../../types/JellifyUser' import { JellifyUser } from '../../types/JellifyUser'
import { nitroFetch } from '../utils/nitro'
/** /**
* Fetches a single Jellyfin item by it's ID * Fetches a single Jellyfin item by it's ID
@@ -19,7 +20,6 @@ import { JellifyUser } from '../../types/JellifyUser'
* @returns The item - a {@link BaseItemDto} * @returns The item - a {@link BaseItemDto}
*/ */
export async function fetchItem(api: Api | undefined, itemId: string): Promise<BaseItemDto> { export async function fetchItem(api: Api | undefined, itemId: string): Promise<BaseItemDto> {
console.debug('Fetching item by id')
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (isEmpty(itemId)) return reject('No item ID proviced') if (isEmpty(itemId)) return reject('No item ID proviced')
if (isUndefined(api)) return reject('Client not initialized') if (isUndefined(api)) return reject('Client not initialized')
@@ -63,27 +63,25 @@ export async function fetchItems(
parentId?: string | undefined, parentId?: string | undefined,
ids?: string[] | undefined, ids?: string[] | undefined,
): Promise<{ title: string | number; data: BaseItemDto[] }> { ): Promise<{ title: string | number; data: BaseItemDto[] }> {
console.debug('Fetching items', page)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client not initialized') if (isUndefined(api)) return reject('Client not initialized')
if (isUndefined(user)) return reject('User not initialized') if (isUndefined(user)) return reject('User not initialized')
if (isUndefined(library)) return reject('Library not initialized') if (isUndefined(library)) return reject('Library not initialized')
getItemsApi(api) nitroFetch<{ Items: BaseItemDto[] }>(api, '/Items', {
.getItems({ ParentId: parentId ?? library.musicLibraryId,
parentId: parentId ?? library.musicLibraryId, UserId: user.id,
userId: user.id, IncludeItemTypes: types,
includeItemTypes: types, SortBy: sortBy,
sortBy, Recursive: true,
recursive: true, SortOrder: sortOrder,
sortOrder, Fields: [ItemFields.ChildCount, ItemFields.SortName, ItemFields.Genres],
fields: [ItemFields.ChildCount, ItemFields.SortName, ItemFields.Genres], StartIndex: typeof page === 'number' ? page * QueryConfig.limits.library : 0,
startIndex: typeof page === 'number' ? page * QueryConfig.limits.library : 0, Limit: QueryConfig.limits.library,
limit: QueryConfig.limits.library, IsFavorite: isFavorite,
isFavorite, Ids: ids,
ids, })
}) .then((data) => {
.then(({ data }) => {
resolve({ title: page, data: data.Items ?? [] }) resolve({ title: page, data: data.Items ?? [] })
}) })
.catch((error) => { .catch((error) => {
@@ -102,7 +100,6 @@ export async function fetchAlbumDiscs(
api: Api | undefined, api: Api | undefined,
album: BaseItemDto, album: BaseItemDto,
): Promise<{ title: string; data: BaseItemDto[] }[]> { ): Promise<{ title: string; data: BaseItemDto[] }[]> {
console.debug('Fetching album discs')
return new Promise<{ title: string; data: BaseItemDto[] }[]>((resolve, reject) => { return new Promise<{ title: string; data: BaseItemDto[] }[]>((resolve, reject) => {
if (isEmpty(album.Id)) return reject('No album ID provided') if (isEmpty(album.Id)) return reject('No album ID provided')
if (isUndefined(api)) return reject('Client not initialized') if (isUndefined(api)) return reject('Client not initialized')
@@ -111,12 +108,11 @@ export async function fetchAlbumDiscs(
sortBy = [ItemSortBy.ParentIndexNumber, ItemSortBy.IndexNumber, ItemSortBy.SortName] sortBy = [ItemSortBy.ParentIndexNumber, ItemSortBy.IndexNumber, ItemSortBy.SortName]
getItemsApi(api) nitroFetch<{ Items: BaseItemDto[] }>(api, '/Items', {
.getItems({ ParentId: album.Id!,
parentId: album.Id!, SortBy: sortBy,
sortBy, })
}) .then((data) => {
.then(({ data }) => {
const discs = data.Items const discs = data.Items
? Object.keys(groupBy(data.Items, (track) => track.ParentIndexNumber)).map( ? Object.keys(groupBy(data.Items, (track) => track.ParentIndexNumber)).map(
(discNumber) => { (discNumber) => {

View File

@@ -6,8 +6,6 @@ import { Api } from '@jellyfin/sdk'
import { JellifyUser } from '../../types/JellifyUser' import { JellifyUser } from '../../types/JellifyUser'
export async function fetchMusicLibraries(api: Api | undefined): Promise<BaseItemDto[] | void> { export async function fetchMusicLibraries(api: Api | undefined): Promise<BaseItemDto[] | void> {
console.debug('Fetching music libraries from Jellyfin')
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set') if (isUndefined(api)) return reject('Client instance not set')
@@ -27,8 +25,6 @@ export async function fetchMusicLibraries(api: Api | undefined): Promise<BaseIte
} }
export async function fetchPlaylistLibrary(api: Api | undefined): Promise<BaseItemDto | undefined> { export async function fetchPlaylistLibrary(api: Api | undefined): Promise<BaseItemDto | undefined> {
console.debug('Fetching playlist library from Jellyfin')
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set') if (isUndefined(api)) return reject('Client instance not set')
@@ -57,8 +53,6 @@ export async function fetchUserViews(
api: Api | undefined, api: Api | undefined,
user: JellifyUser | undefined, user: JellifyUser | undefined,
): Promise<BaseItemDto[] | void> { ): Promise<BaseItemDto[] | void> {
console.debug('Fetching user views')
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set') if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(user)) return reject('User instance not set') if (isUndefined(user)) return reject('User instance not set')

View File

@@ -1,9 +1,9 @@
import { useNowPlaying } from '../../../providers/Player/hooks/queries'
import { useQuery, UseQueryResult } from '@tanstack/react-query' import { useQuery, UseQueryResult } from '@tanstack/react-query'
import LyricsQueryKey from './keys' import LyricsQueryKey from './keys'
import { isUndefined } from 'lodash' import { isUndefined } from 'lodash'
import { fetchRawLyrics } from './utils' import { fetchRawLyrics } from './utils'
import { useJellifyContext } from '../../../providers' import { useApi } from '../../../stores'
import { useCurrentTrack } from '../../../stores/player/queue'
/** /**
* A hook that will return a {@link useQuery} * A hook that will return a {@link useQuery}
@@ -11,8 +11,8 @@ import { useJellifyContext } from '../../../providers'
* @returns a {@link UseQueryResult} for the * @returns a {@link UseQueryResult} for the
*/ */
const useRawLyrics = () => { const useRawLyrics = () => {
const { api } = useJellifyContext() const api = useApi()
const { data: nowPlaying } = useNowPlaying() const nowPlaying = useCurrentTrack()
return useQuery({ return useQuery({
queryKey: LyricsQueryKey(nowPlaying), queryKey: LyricsQueryKey(nowPlaying),

View File

@@ -19,15 +19,11 @@ export async function fetchRawLyrics(
if (isUndefined(api)) throw new Error('Client not initialized') if (isUndefined(api)) throw new Error('Client not initialized')
if (isEmpty(itemId)) throw new Error('No item ID provided') if (isEmpty(itemId)) throw new Error('No item ID provided')
console.log('itemId', itemId)
try { try {
// Jellyfin LyricsApi returns plain text (often LRC) for the given item // Jellyfin LyricsApi returns plain text (often LRC) for the given item
// SDK: LyricsApi.getLyrics({ itemId }) // SDK: LyricsApi.getLyrics({ itemId })
const lyricsApi: LyricsApi = getLyricsApi(api) const lyricsApi: LyricsApi = getLyricsApi(api)
console.log('lyricsApi', lyricsApi)
const { data } = await lyricsApi.getLyrics({ itemId }) const { data } = await lyricsApi.getLyrics({ itemId })
console.log('data', data)
// Some SDK versions may wrap text; defensively unwrap // Some SDK versions may wrap text; defensively unwrap
return data.Lyrics return data.Lyrics

View File

@@ -1,13 +1,13 @@
import { Api } from '@jellyfin/sdk' import { Api } from '@jellyfin/sdk'
import { useJellifyContext } from '../../../../src/providers'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { JellifyUser } from '@/src/types/JellifyUser'
import useStreamingDeviceProfile, { import useStreamingDeviceProfile, {
useDownloadingDeviceProfile, useDownloadingDeviceProfile,
} from '../../../stores/device-profile' } from '../../../stores/device-profile'
import { fetchMediaInfo } from './utils' import { fetchMediaInfo } from './utils'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import MediaInfoQueryKey from './keys' import MediaInfoQueryKey from './keys'
import { useApi } from '../../../stores'
import { ONE_DAY } from '../../../constants/query-client'
/** /**
* A React hook that will retrieve the latest media info * A React hook that will retrieve the latest media info
@@ -16,22 +16,24 @@ import MediaInfoQueryKey from './keys'
* Depends on the {@link useStreamingDeviceProfile} hook for retrieving * Depends on the {@link useStreamingDeviceProfile} hook for retrieving
* the currently configured device profile * the currently configured device profile
* *
* Depends on the {@link useJellifyContext} hook for retrieving * Depends on the {@link useApi} hook for retrieving
* the currently configured {@link Api} and {@link JellifyUser} * the currently configured {@link Api}
* instance * instance
* *
* @param itemId The Id of the {@link BaseItemDto} * @param itemId The Id of the {@link BaseItemDto}
* @returns * @returns
*/ */
const useStreamedMediaInfo = (itemId: string | null | undefined) => { const useStreamedMediaInfo = (itemId: string | null | undefined) => {
const { api } = useJellifyContext() const api = useApi()
const deviceProfile = useStreamingDeviceProfile() const deviceProfile = useStreamingDeviceProfile()
return useQuery({ return useQuery({
queryKey: MediaInfoQueryKey({ api, deviceProfile, itemId }), queryKey: MediaInfoQueryKey({ api, deviceProfile, itemId }),
queryFn: () => fetchMediaInfo(api, deviceProfile, itemId), queryFn: () => fetchMediaInfo(api, deviceProfile, itemId),
staleTime: Infinity, // Only refetch when the user's device profile changes enabled: Boolean(api && deviceProfile && itemId),
staleTime: ONE_DAY, // Only refetch when the user's device profile changes
gcTime: ONE_DAY,
}) })
} }
@@ -44,21 +46,23 @@ export default useStreamedMediaInfo
* Depends on the {@link useDownloadingDeviceProfile} hook for retrieving * Depends on the {@link useDownloadingDeviceProfile} hook for retrieving
* the currently configured device profile * the currently configured device profile
* *
* Depends on the {@link useJellifyContext} hook for retrieving * Depends on the {@link useApi} hook for retrieving
* the currently configured {@link Api} and {@link JellifyUser} * the currently configured {@link Api}
* instance * instance
* *
* @param itemId The Id of the {@link BaseItemDto} * @param itemId The Id of the {@link BaseItemDto}
* @returns * @returns
*/ */
export const useDownloadedMediaInfo = (itemId: string | null | undefined) => { export const useDownloadedMediaInfo = (itemId: string | null | undefined) => {
const { api } = useJellifyContext() const api = useApi()
const deviceProfile = useDownloadingDeviceProfile() const deviceProfile = useDownloadingDeviceProfile()
return useQuery({ return useQuery({
queryKey: MediaInfoQueryKey({ api, deviceProfile, itemId }), queryKey: MediaInfoQueryKey({ api, deviceProfile, itemId }),
queryFn: () => fetchMediaInfo(api, deviceProfile, itemId), queryFn: () => fetchMediaInfo(api, deviceProfile, itemId),
staleTime: Infinity, // Only refetch when the user's device profile changes enabled: Boolean(api && deviceProfile && itemId),
staleTime: ONE_DAY, // Only refetch when the user's device profile changes
gcTime: ONE_DAY,
}) })
} }

View File

@@ -8,8 +8,6 @@ export async function fetchMediaInfo(
deviceProfile: DeviceProfile | undefined, deviceProfile: DeviceProfile | undefined,
itemId: string | null | undefined, itemId: string | null | undefined,
): Promise<PlaybackInfoResponse> { ): Promise<PlaybackInfoResponse> {
console.debug(`Fetching media info of with ${deviceProfile?.Name} profile`)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set') if (isUndefined(api)) return reject('Client instance not set')
@@ -21,7 +19,6 @@ export async function fetchMediaInfo(
}, },
}) })
.then(({ data }) => { .then(({ data }) => {
console.debug('Received media info response')
resolve(data) resolve(data)
}) })
.catch((error) => { .catch((error) => {

View File

@@ -1,11 +1,11 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../../enums/query-keys' import { QueryKeys } from '../../../enums/query-keys'
import { useJellifyContext } from '../../../providers'
import fetchPatrons from './utils' import fetchPatrons from './utils'
import { ONE_DAY } from '../../../constants/query-client' import { ONE_DAY } from '../../../constants/query-client'
import { useApi } from '../../../stores'
const usePatronsQuery = () => { const usePatronsQuery = () => {
const { api } = useJellifyContext() const api = useApi()
return useQuery({ return useQuery({
queryKey: [QueryKeys.Patrons], queryKey: [QueryKeys.Patrons],

View File

@@ -1,11 +1,15 @@
import { useJellifyContext } from '../../../providers'
import { UserPlaylistsQueryKey } from './keys' import { UserPlaylistsQueryKey } from './keys'
import { useInfiniteQuery } from '@tanstack/react-query' import { useInfiniteQuery } from '@tanstack/react-query'
import { fetchUserPlaylists, fetchPublicPlaylists } from './utils' import { fetchUserPlaylists, fetchPublicPlaylists, fetchPlaylistTracks } from './utils'
import { ApiLimits } from '../query.config' import { ApiLimits } from '../../../configs/query.config'
import { useApi, useJellifyLibrary, useJellifyUser } from '../../../stores'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { QueryKeys } from '../../../enums/query-keys'
export const useUserPlaylists = () => { export const useUserPlaylists = () => {
const { api, user, library } = useJellifyContext() const api = useApi()
const [user] = useJellifyUser()
const [library] = useJellifyLibrary()
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: UserPlaylistsQueryKey(library), queryKey: UserPlaylistsQueryKey(library),
@@ -13,7 +17,39 @@ export const useUserPlaylists = () => {
select: (data) => data.pages.flatMap((page) => page), select: (data) => data.pages.flatMap((page) => page),
initialPageParam: 0, initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
if (!lastPage) return undefined
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
}, },
}) })
} }
export const usePlaylistTracks = (playlist: BaseItemDto) => {
const api = useApi()
return useInfiniteQuery({
// Changed from QueryKeys.ItemTracks to avoid cache conflicts with old useQuery data
queryKey: [QueryKeys.ItemTracks, 'infinite', playlist.Id!],
queryFn: ({ pageParam }) => fetchPlaylistTracks(api, playlist.Id!, pageParam),
select: (data) => data.pages.flatMap((page) => page),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
if (!lastPage) return undefined
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
},
enabled: Boolean(api && playlist.Id),
})
}
export const usePublicPlaylists = () => {
const api = useApi()
const [library] = useJellifyLibrary()
return useInfiniteQuery({
queryKey: [QueryKeys.PublicPlaylists, library?.playlistLibraryId],
queryFn: ({ pageParam }) => fetchPublicPlaylists(api, library, pageParam),
select: (data) => data.pages.flatMap((page) => page),
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPage.length > 0 ? lastPageParam + 1 : undefined,
initialPageParam: 0,
})
}

View File

@@ -1,5 +1,6 @@
import { import {
BaseItemDto, BaseItemDto,
BaseItemKind,
ItemFields, ItemFields,
ItemSortBy, ItemSortBy,
SortOrder, SortOrder,
@@ -9,20 +10,28 @@ import { JellifyUser } from '../../../../types/JellifyUser'
import { Api } from '@jellyfin/sdk' import { Api } from '@jellyfin/sdk'
import { isUndefined } from 'lodash' import { isUndefined } from 'lodash'
import { JellifyLibrary } from '../../../../types/JellifyLibrary' import { JellifyLibrary } from '../../../../types/JellifyLibrary'
import QueryConfig from '../../query.config' import QueryConfig, { ApiLimits } from '../../../../configs/query.config'
import { nitroFetch } from '../../../utils/nitro'
/**
* Returns the user's playlists from the Jellyfin server
*
* Performs filtering to ensure that these are playlists stored in the
* config directory of Jellyfin, as to avoid displaying .m3u files from
* the library
*
* @param api The {@link Api} instance from the {@link useApi} hook
* @param user The {@link JellifyUser} instance from the {@link useJellifyUser} hook
* @param library The {@link JellifyLibrary} instance from the {@link useJellifyLibrary} hook
* @param sortBy An array of {@link ItemSortBy} values to sort the response by
* @returns
*/
export async function fetchUserPlaylists( export async function fetchUserPlaylists(
api: Api | undefined, api: Api | undefined,
user: JellifyUser | undefined, user: JellifyUser | undefined,
library: JellifyLibrary | undefined, library: JellifyLibrary | undefined,
sortBy: ItemSortBy[] = [], sortBy: ItemSortBy[] = [],
): Promise<BaseItemDto[]> { ): Promise<BaseItemDto[]> {
console.debug(
`Fetching user playlists ${sortBy.length > 0 ? 'sorting by ' + sortBy.toString() : ''}`,
)
const defaultSorting: ItemSortBy[] = [ItemSortBy.IsFolder, ItemSortBy.SortName]
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set') if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(user)) return reject('User instance not set') if (isUndefined(user)) return reject('User instance not set')
@@ -37,6 +46,7 @@ export async function fetchUserPlaylists(
ItemFields.CanDelete, ItemFields.CanDelete,
ItemFields.Genres, ItemFields.Genres,
ItemFields.ChildCount, ItemFields.ChildCount,
ItemFields.ItemCounts,
], ],
sortBy: [ItemSortBy.SortName], sortBy: [ItemSortBy.SortName],
sortOrder: [SortOrder.Ascending], sortOrder: [SortOrder.Ascending],
@@ -44,10 +54,9 @@ export async function fetchUserPlaylists(
}) })
.then((response) => { .then((response) => {
if (response.data.Items) if (response.data.Items)
// Playlists must be stored in Jellyfin's internal config directory
return resolve( return resolve(
response.data.Items.filter((playlist) => response.data.Items.filter((playlist) => playlist.Path?.includes('data')),
playlist.Path!.includes('/data/playlists'),
),
) )
else return resolve([]) else return resolve([])
}) })
@@ -62,8 +71,6 @@ export async function fetchPublicPlaylists(
library: JellifyLibrary | undefined, library: JellifyLibrary | undefined,
page: number, page: number,
): Promise<BaseItemDto[]> { ): Promise<BaseItemDto[]> {
console.debug('Fetching public playlists')
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set') if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(library)) return reject('Library instance not set') if (isUndefined(library)) return reject('Library instance not set')
@@ -75,16 +82,19 @@ export async function fetchPublicPlaylists(
sortOrder: [SortOrder.Ascending], sortOrder: [SortOrder.Ascending],
startIndex: page * QueryConfig.limits.library, startIndex: page * QueryConfig.limits.library,
limit: QueryConfig.limits.library, limit: QueryConfig.limits.library,
fields: ['Path', 'CanDelete', 'Genres'], fields: [
ItemFields.Path,
ItemFields.CanDelete,
ItemFields.Genres,
ItemFields.ChildCount,
ItemFields.ItemCounts,
],
}) })
.then((response) => { .then((response) => {
console.log(response)
if (response.data.Items) if (response.data.Items)
// Playlists must not be stored in Jellyfin's internal config directory
return resolve( return resolve(
response.data.Items.filter( response.data.Items.filter((playlist) => !playlist.Path?.includes('data')),
(playlist) => !playlist.Path?.includes('/data/playlists'),
),
) )
else return resolve([]) else return resolve([])
}) })
@@ -94,3 +104,38 @@ export async function fetchPublicPlaylists(
}) })
}) })
} }
/**
* Fetches tracks for a playlist with pagination using NitroFetch
* for optimized JSON parsing on a background thread.
*
* @param api The {@link Api} instance
* @param playlistId The ID of the playlist to fetch tracks for
* @param pageParam The page number for pagination (0-indexed)
* @returns Array of tracks for the playlist
*/
export async function fetchPlaylistTracks(
api: Api | undefined,
playlistId: string,
pageParam: number = 0,
): Promise<BaseItemDto[]> {
if (isUndefined(api)) {
throw new Error('Client instance not set')
}
const data = await nitroFetch<{ Items: BaseItemDto[]; TotalRecordCount: number }>(
api,
'/Items',
{
ParentId: playlistId,
IncludeItemTypes: [BaseItemKind.Audio],
EnableUserData: true,
Recursive: false,
Limit: ApiLimits.Library,
StartIndex: pageParam * ApiLimits.Library,
Fields: [ItemFields.MediaSources, ItemFields.ParentId, ItemFields.Path],
},
)
return data.Items ?? []
}

View File

@@ -1,18 +1,19 @@
import { useJellifyContext } from '../../../providers'
import { RecentlyPlayedArtistsQueryKey, RecentlyPlayedTracksQueryKey } from './keys' import { RecentlyPlayedArtistsQueryKey, RecentlyPlayedTracksQueryKey } from './keys'
import { useInfiniteQuery } from '@tanstack/react-query' import { useInfiniteQuery } from '@tanstack/react-query'
import { fetchRecentlyPlayed, fetchRecentlyPlayedArtists } from './utils' import { fetchRecentlyPlayed, fetchRecentlyPlayedArtists } from './utils'
import { ApiLimits } from '../query.config' import { ApiLimits, MaxPages } from '../../../configs/query.config'
import { isUndefined } from 'lodash' import { isUndefined } from 'lodash'
import { useApi, useJellifyUser, useJellifyLibrary } from '../../../stores'
const RECENTS_QUERY_CONFIG = { const RECENTS_QUERY_CONFIG = {
maxPages: 2, maxPages: MaxPages.Home,
refetchOnMount: false, refetchOnMount: false,
staleTime: Infinity,
} as const } as const
export const useRecentlyPlayedTracks = () => { export const useRecentlyPlayedTracks = () => {
const { api, user, library } = useJellifyContext() const api = useApi()
const [user] = useJellifyUser()
const [library] = useJellifyLibrary()
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: RecentlyPlayedTracksQueryKey(user, library), queryKey: RecentlyPlayedTracksQueryKey(user, library),
@@ -20,7 +21,6 @@ export const useRecentlyPlayedTracks = () => {
initialPageParam: 0, initialPageParam: 0,
select: (data) => data.pages.flatMap((page) => page), select: (data) => data.pages.flatMap((page) => page),
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
console.debug('Getting next page for recent tracks')
return lastPage.length === ApiLimits.Home ? lastPageParam + 1 : undefined return lastPage.length === ApiLimits.Home ? lastPageParam + 1 : undefined
}, },
...RECENTS_QUERY_CONFIG, ...RECENTS_QUERY_CONFIG,
@@ -28,7 +28,9 @@ export const useRecentlyPlayedTracks = () => {
} }
export const useRecentArtists = () => { export const useRecentArtists = () => {
const { api, user, library } = useJellifyContext() const api = useApi()
const [user] = useJellifyUser()
const [library] = useJellifyLibrary()
const { data: recentlyPlayedTracks } = useRecentlyPlayedTracks() const { data: recentlyPlayedTracks } = useRecentlyPlayedTracks()
@@ -38,7 +40,6 @@ export const useRecentArtists = () => {
select: (data) => data.pages.flatMap((page) => page), select: (data) => data.pages.flatMap((page) => page),
initialPageParam: 0, initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
console.debug('Getting next page for recent artists')
return lastPage.length > 0 ? lastPageParam + 1 : undefined return lastPage.length > 0 ? lastPageParam + 1 : undefined
}, },
enabled: !isUndefined(recentlyPlayedTracks), enabled: !isUndefined(recentlyPlayedTracks),

View File

@@ -6,7 +6,7 @@ import {
SortOrder, SortOrder,
} from '@jellyfin/sdk/lib/generated-client/models' } from '@jellyfin/sdk/lib/generated-client/models'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api' import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api'
import QueryConfig, { ApiLimits } from '../../query.config' import QueryConfig, { ApiLimits } from '../../../../configs/query.config'
import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api' import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api'
import { Api } from '@jellyfin/sdk' import { Api } from '@jellyfin/sdk'
import { isUndefined } from 'lodash' import { isUndefined } from 'lodash'
@@ -56,8 +56,6 @@ export async function fetchRecentlyPlayed(
page: number, page: number,
limit: number = ApiLimits.Home, limit: number = ApiLimits.Home,
): Promise<BaseItemDto[]> { ): Promise<BaseItemDto[]> {
console.debug('Fetching recently played items')
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (isUndefined(api)) return reject('Client instance not set') if (isUndefined(api)) return reject('Client instance not set')
if (isUndefined(user)) return reject('User instance not set') if (isUndefined(user)) return reject('User instance not set')
@@ -77,8 +75,6 @@ export async function fetchRecentlyPlayed(
enableUserData: true, enableUserData: true,
}) })
.then((response) => { .then((response) => {
console.debug('Received recently played items response')
if (response.data.Items) return resolve(response.data.Items) if (response.data.Items) return resolve(response.data.Items)
return resolve([]) return resolve([])
}) })
@@ -101,7 +97,6 @@ export function fetchRecentlyPlayedArtists(
library: JellifyLibrary | undefined, library: JellifyLibrary | undefined,
page: number, page: number,
): Promise<BaseItemDto[]> { ): Promise<BaseItemDto[]> {
console.debug('Fetching recently played artists')
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (isUndefined(library)) return reject('Library instance not set') if (isUndefined(library)) return reject('Library instance not set')

Some files were not shown because too many files have changed in this diff Show More