Merge branch 'main' of github.com:Jellify-Music/App into 466-feature-add-quick-connect-support
0
.env.devrelease
Normal file
2
.github/FUNDING.yml
vendored
@@ -3,7 +3,7 @@
|
||||
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
|
||||
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
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
|
||||
121
.github/ISSUE_TEMPLATE/01_report_issue.yaml
vendored
Normal 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
|
||||
64
.github/ISSUE_TEMPLATE/02_request_feature.yaml
vendored
Normal 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
|
||||
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -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
@@ -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.
|
||||
21
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -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
@@ -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
|
||||
37
.github/workflows/build-android.yml
vendored
@@ -1,6 +1,12 @@
|
||||
name: Build Android APK
|
||||
on:
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-android.yml'
|
||||
- 'android/**'
|
||||
- 'package.json'
|
||||
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -12,12 +18,11 @@ jobs:
|
||||
- name: 🛒 Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🖥 Setup Node 20
|
||||
uses: actions/setup-node@v4
|
||||
- name: 🖥 Setup Bun 1.3.2
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'yarn'
|
||||
|
||||
bun-version: 1.3.2
|
||||
|
||||
- name: 💎 Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
@@ -25,8 +30,8 @@ jobs:
|
||||
bundler-cache: true
|
||||
|
||||
- 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
|
||||
continue-on-error: true
|
||||
run: |
|
||||
@@ -36,31 +41,31 @@ jobs:
|
||||
else
|
||||
echo "No keystore secret found, will use debug keystore"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
path: |
|
||||
node_modules
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
~/.cache/turbo
|
||||
android/.gradle
|
||||
android/app/build
|
||||
key: ${{ runner.os }}-gradle-turbo-${{ hashFiles('**/yarn.lock', '**/build.gradle') }}
|
||||
key: ${{ runner.os }}-gradle-turbo-${{ hashFiles('**/build.gradle') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-turbo-
|
||||
|
||||
- name: 🤖 Run yarn init-android
|
||||
run: yarn install --network-concurrency 1
|
||||
|
||||
- name: 🤖 Run bun init-android
|
||||
run: bun i
|
||||
|
||||
- name: 🚀 Run turbo build
|
||||
run: yarn android-build
|
||||
run: bun android-build
|
||||
env:
|
||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
|
||||
|
||||
|
||||
- name: 📦 Upload APK for testing
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
16
.github/workflows/build-bundle.yml
vendored
@@ -16,20 +16,19 @@ jobs:
|
||||
- name: 🧾 Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: ⚙️ Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: 🖥 Setup Bun 1.3.2
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'yarn'
|
||||
bun-version: 1.3.2
|
||||
|
||||
- name: 📦 Install dependencies
|
||||
run: yarn install --network-concurrency 1
|
||||
run: bun i
|
||||
|
||||
|
||||
- name: 🧩 Build JS bundle for iOS
|
||||
run: |
|
||||
mkdir -p ios/build
|
||||
npx react-native bundle \
|
||||
bun x react-native bundle \
|
||||
--platform ios \
|
||||
--dev false \
|
||||
--entry-file index.js \
|
||||
@@ -40,12 +39,9 @@ jobs:
|
||||
run: |
|
||||
mkdir -p android/app/src/main/assets
|
||||
mkdir -p android/app/src/main/res
|
||||
npx react-native bundle \
|
||||
bun x react-native bundle \
|
||||
--platform android \
|
||||
--dev false \
|
||||
--entry-file index.js \
|
||||
--bundle-output android/app/src/main/assets/index.android.bundle \
|
||||
--assets-dest android/app/src/main/res
|
||||
|
||||
|
||||
|
||||
24
.github/workflows/build-ios.yml
vendored
@@ -1,7 +1,11 @@
|
||||
name: Build iOS IPA
|
||||
on:
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-ios.yml'
|
||||
- 'ios/**'
|
||||
- 'package.json'
|
||||
|
||||
|
||||
concurrency:
|
||||
@@ -15,21 +19,21 @@ jobs:
|
||||
- name: 🛒 Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🖥 Setup Node 20
|
||||
uses: actions/setup-node@v4
|
||||
- name: 🖥 Setup Bun 1.3.2
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 20
|
||||
bun-version: 1.3.2
|
||||
|
||||
- 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
|
||||
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
|
||||
run: |
|
||||
cd ios
|
||||
@@ -61,4 +65,4 @@ jobs:
|
||||
*.zip
|
||||
ios/build/Build/Products/Release-iphonesimulator/Jellify-Release-Simulator.zip
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: warn
|
||||
|
||||
78
.github/workflows/maestro-test.yml
vendored
@@ -1,13 +1,14 @@
|
||||
name: Run Maestro Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
runs-on: macos-15
|
||||
@@ -16,19 +17,18 @@ jobs:
|
||||
steps:
|
||||
- name: 🛒 Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🖥 Setup Node 20
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
- name: 🖥 Setup Bun 1.3.2
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'yarn'
|
||||
|
||||
bun-version: 1.3.2
|
||||
|
||||
- name: 💎 Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.0'
|
||||
ruby-version: '3.0'
|
||||
bundler-cache: true
|
||||
|
||||
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
@@ -38,25 +38,25 @@ jobs:
|
||||
~/.cache/turbo
|
||||
android/.gradle
|
||||
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: |
|
||||
${{ runner.os }}-gradle-turbo-
|
||||
|
||||
- name: 🍎 Run yarn init-android
|
||||
run: yarn install --network-concurrency 1
|
||||
- name: 🍎 Run bun init-android
|
||||
run: bun i
|
||||
|
||||
- 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
|
||||
run: |
|
||||
node -e "JSON.parse(require('fs').readFileSync('telemetrydeck.json'))"
|
||||
node -e "JSON.parse(require('fs').readFileSync('glitchtip.json'))"
|
||||
|
||||
bun -p "JSON.parse(require('fs').readFileSync('telemetrydeck.json'))"
|
||||
bun -p "JSON.parse(require('fs').readFileSync('glitchtip.json'))"
|
||||
|
||||
- name: 🚀 Run Android fastlane build
|
||||
run: yarn android-build
|
||||
|
||||
run: bun run android-build
|
||||
|
||||
- name: 📤 Upload Android Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -73,10 +73,10 @@ jobs:
|
||||
- name: 🛒 Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🖥 Setup Node 20
|
||||
uses: actions/setup-node@v4
|
||||
- name: 🖥 Setup Bun 1.3.2
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 20
|
||||
bun-version: 1.3.2
|
||||
|
||||
- name: Installing Maestro
|
||||
shell: bash
|
||||
@@ -88,17 +88,15 @@ jobs:
|
||||
java-version: '17'
|
||||
distribution: 'zulu'
|
||||
|
||||
- name: 💎 Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.0'
|
||||
bundler-cache: true
|
||||
|
||||
- name: ⬇️ Download Android Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: android-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
|
||||
shell: bash
|
||||
@@ -121,8 +119,22 @@ jobs:
|
||||
cores: '4'
|
||||
disable-animations: false
|
||||
avd-name: e2e_emulator
|
||||
script: |
|
||||
node scripts/maestro-android.js "https://jellyfin.jellify.app" "jerry"
|
||||
script: bash scripts/maestro-android-retry.sh "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
|
||||
uses: actions/upload-artifact@v4.3.4
|
||||
if: always()
|
||||
|
||||
129
.github/workflows/publish-beta.yml
vendored
@@ -22,7 +22,7 @@ on:
|
||||
- minor
|
||||
- patch
|
||||
- major
|
||||
|
||||
|
||||
jobs:
|
||||
generate-release-notes:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -33,8 +33,12 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
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
|
||||
id: commits
|
||||
run: |
|
||||
@@ -54,10 +58,10 @@ jobs:
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
- name: 📜 Generate release notes using Node.js
|
||||
- name: 📜 Generate release notes using Bun
|
||||
run: |
|
||||
yarn install --network-concurrency 1
|
||||
node scripts/generate-release-notes.js "${{ steps.commits.outputs.messages }}"
|
||||
bun i
|
||||
bun scripts/generate-release-notes.js "${{ steps.commits.outputs.messages }}"
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
@@ -79,32 +83,32 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.SIGNING_REPO_PAT }}
|
||||
|
||||
- name: 🖥 Setup Node 20
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
- name: 🖥 Setup Bun 1.3.2
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
bun-version: 1.3.2
|
||||
|
||||
- name: 💎 Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.0'
|
||||
ruby-version: '3.0'
|
||||
bundler-cache: true
|
||||
|
||||
- name: 🍎 Run yarn init-android
|
||||
run: yarn install --network-concurrency 1
|
||||
- name: 🍎 Run bun init-android # you never actually run yarn init-android, so I kept the same
|
||||
run: bun i
|
||||
|
||||
- name: ➕ Version Up
|
||||
if: ${{ github.event.inputs['version-bump'] != 'No Bump' }}
|
||||
run: yarn react-native bump-version --type ${{ github.event.inputs['version-bump'] }}
|
||||
|
||||
- id: setver
|
||||
run: echo "version=$(node -p -e "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
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
|
||||
run: echo "version=$(bun -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
|
||||
- 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
|
||||
run: |
|
||||
if [ -n "${{ secrets.ANDROID_SIGNING_BASE64 }}" ]; then
|
||||
@@ -114,7 +118,7 @@ jobs:
|
||||
echo "ERROR: No keystore secret found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
- name: 🔑 Setup Play Store credentials
|
||||
run: |
|
||||
if [ -n "$PLAY_STORE_CREDENTIALS" ]; then
|
||||
@@ -126,8 +130,8 @@ jobs:
|
||||
fi
|
||||
env:
|
||||
PLAY_STORE_CREDENTIALS: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT }}
|
||||
|
||||
- name: 🤫 Output TelemetryDeck Secrets to TelemetryDeck.json
|
||||
|
||||
- name: 🤫 Output TelemetryDeck Secrets to TelemetryDeck.json
|
||||
run: |
|
||||
echo "{" > telemetrydeck.json
|
||||
echo "\"appID\": \"${{ secrets.TELEMETRYDECK_APPID }}\"," >> telemetrydeck.json
|
||||
@@ -135,25 +139,29 @@ jobs:
|
||||
echo "\"app\": \"Jellify\"" >> telemetrydeck.json
|
||||
echo "}" >> telemetrydeck.json
|
||||
|
||||
|
||||
|
||||
- name: 🤫 Output Glitchtip Secrets to Glitchtip.json
|
||||
run: |
|
||||
echo "{" > glitchtip.json
|
||||
echo "\"dsn\": \"${{ secrets.GLITCHTIP_DSN }}\"" >> glitchtip.json
|
||||
echo "}" >> glitchtip.json
|
||||
|
||||
|
||||
- name: 📝 Output Glitchip secrets to .env
|
||||
run: |
|
||||
echo "GLITCHTIP_DSN=${{ secrets.GLITCHTIP_DSN }}" >> .env
|
||||
|
||||
- name: ✅ Validate Config Files
|
||||
run: |
|
||||
node -e "JSON.parse(require('fs').readFileSync('telemetrydeck.json'))"
|
||||
node -e "JSON.parse(require('fs').readFileSync('glitchtip.json'))"
|
||||
|
||||
bun -p "JSON.parse(require('fs').readFileSync('telemetrydeck.json'))"
|
||||
bun -p "JSON.parse(require('fs').readFileSync('glitchtip.json'))"
|
||||
|
||||
- name: 🚀 Run Android fastlane deploy
|
||||
run: yarn fastlane:android:deploy
|
||||
run: bun run fastlane:android:deploy
|
||||
env:
|
||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
|
||||
|
||||
- name: 📤 Upload Android Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -168,38 +176,38 @@ jobs:
|
||||
version: ${{ steps.setver.outputs.version }}
|
||||
needs: [generate-release-notes]
|
||||
steps:
|
||||
|
||||
|
||||
- name: 🛒 Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.SIGNING_REPO_PAT }}
|
||||
|
||||
- name: 🖥 Setup Node 20
|
||||
uses: actions/setup-node@v4
|
||||
- name: 🖥 Setup Bun 1.3.2
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 20
|
||||
bun-version: 1.3.2
|
||||
|
||||
- name: 🍎 Setup Xcode
|
||||
uses: ./.github/actions/setup-xcode
|
||||
|
||||
- name: 🍎 Run yarn init-ios:new-arch
|
||||
run: yarn init-ios:new-arch
|
||||
|
||||
- name: 🍎 Run run init-ios:new-arch
|
||||
run: bun run init-ios:new-arch
|
||||
|
||||
- name: ➕ Version Up
|
||||
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
|
||||
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
|
||||
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
|
||||
run: echo -e '${{ secrets.APPSTORE_CONNECT_API_KEY_JSON }}' > appstore_connect_api_key.json
|
||||
working-directory: ./ios/fastlane
|
||||
|
||||
- name: 🤫 Output TelemetryDeck Secrets to TelemetryDeck.json
|
||||
- name: 🤫 Output TelemetryDeck Secrets to TelemetryDeck.json
|
||||
run: |
|
||||
echo "{" > telemetrydeck.json
|
||||
echo "\"appID\": \"${{ secrets.TELEMETRYDECK_APPID }}\"," >> telemetrydeck.json
|
||||
@@ -207,20 +215,24 @@ jobs:
|
||||
echo "\"app\": \"Jellify\"" >> telemetrydeck.json
|
||||
echo "}" >> telemetrydeck.json
|
||||
|
||||
|
||||
|
||||
- name: 🤫 Output Glitchtip Secrets to Glitchtip.json
|
||||
run: |
|
||||
echo "{" > glitchtip.json
|
||||
echo "\"dsn\": \"${{ secrets.GLITCHTIP_DSN }}\"" >> glitchtip.json
|
||||
echo "}" >> glitchtip.json
|
||||
|
||||
|
||||
- name: 📝 Output Glitchip secrets to .env
|
||||
run: |
|
||||
echo "GLITCHTIP_DSN=${{ secrets.GLITCHTIP_DSN }}" >> .env
|
||||
|
||||
- name: ✅ Validate Config Files
|
||||
run: |
|
||||
node -e "JSON.parse(require('fs').readFileSync('telemetrydeck.json'))"
|
||||
node -e "JSON.parse(require('fs').readFileSync('glitchtip.json'))"
|
||||
|
||||
bun -p "JSON.parse(require('fs').readFileSync('telemetrydeck.json'))"
|
||||
bun -p "JSON.parse(require('fs').readFileSync('glitchtip.json'))"
|
||||
|
||||
- name: 🚀 Run iOS fastlane build and publish to TestFlight
|
||||
run: yarn fastlane:ios:beta
|
||||
run: bun run fastlane:ios:beta
|
||||
env:
|
||||
APPSTORE_CONNECT_API_KEY_JSON: ${{ secrets.APPSTORE_CONNECT_API_KEY_JSON }}
|
||||
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
|
||||
@@ -241,7 +253,7 @@ jobs:
|
||||
- name: 🛒 Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.SIGNING_REPO_PAT }}
|
||||
token: ${{ secrets.SIGNING_REPO_PAT }}
|
||||
|
||||
- name: ❌ Fail if selected job failed
|
||||
run: |
|
||||
@@ -268,8 +280,13 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 🖥 Setup Bun 1.3.2
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.2
|
||||
|
||||
- name: 📦 Install dependencies
|
||||
run: yarn install --network-concurrency 1
|
||||
run: bun i
|
||||
|
||||
- name: ⬇️ Download Android Artifacts
|
||||
if: ${{ github.event.inputs['build-platform'] == 'Android' || github.event.inputs['build-platform'] == 'Both' }}
|
||||
@@ -287,8 +304,8 @@ jobs:
|
||||
|
||||
- name: ➕ Version Up
|
||||
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
|
||||
run: |
|
||||
VERSION=${{ needs.publish-ios.outputs.version || needs.publish-android.outputs.version }}
|
||||
@@ -331,10 +348,10 @@ jobs:
|
||||
- name: 🗣️ Notify on Discord
|
||||
run: |
|
||||
cd ios
|
||||
bundle install && bundle exec fastlane notifyOnDiscord
|
||||
bundle install && bundle exec fastlane notifyOnDiscordForRelease
|
||||
cd ..
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
APP_VERSION: ${{ needs.publish-ios.outputs.version || needs.publish-android.outputs.version }}
|
||||
release_url: ${{ steps.githubRelease.outputs.html_url }}
|
||||
RELEASE_NOTES: ${{ needs.generate-release-notes.outputs.release_notes }}
|
||||
RELEASE_NOTES: ${{ needs.generate-release-notes.outputs.release_notes }}
|
||||
|
||||
35
.github/workflows/publish-ota-update-pr.yml
vendored
Normal 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 }}
|
||||
18
.github/workflows/publish-ota-update.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Publish Over-the-Air Update
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
jobs:
|
||||
publish-ota-update:
|
||||
runs-on: macos-15
|
||||
@@ -11,25 +11,25 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.SIGNING_REPO_PAT }}
|
||||
|
||||
- name: 🖥 Setup Node 20
|
||||
uses: actions/setup-node@v4
|
||||
- name: 🖥 Setup Bun 1.3.2
|
||||
uses: oven-sh/setup-bun@v2
|
||||
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
|
||||
run: |
|
||||
git config --global user.email "violet@cosmonautical.cloud"
|
||||
git config --global user.name "anultravioletaurora"
|
||||
|
||||
- name: 🤖 Publish Android Update
|
||||
run: yarn sendOTA:android
|
||||
run: bun run sendOTA:android
|
||||
env:
|
||||
SIGNING_REPO_PAT: ${{ secrets.SIGNING_REPO_PAT }}
|
||||
|
||||
- name: 🍎 Publish iOS Update
|
||||
run: yarn sendOTA:iOS
|
||||
run: bun run sendOTA:iOS
|
||||
env:
|
||||
SIGNING_REPO_PAT: ${{ secrets.SIGNING_REPO_PAT }}
|
||||
|
||||
31
.github/workflows/run-jest-test-suite.yml
vendored
@@ -16,22 +16,33 @@ jobs:
|
||||
- name: 🛒 Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🖥 Setup Node 20
|
||||
uses: actions/setup-node@v4
|
||||
- name: 🖥 Setup Bun 1.3.2
|
||||
uses: oven-sh/setup-bun@v2
|
||||
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
|
||||
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
|
||||
run: yarn install --network-concurrency 1
|
||||
|
||||
run: bun i
|
||||
|
||||
- name: 🔍 Run yarn tsc
|
||||
run: yarn tsc
|
||||
run: bun tsc
|
||||
|
||||
- name: 🧪 Run yarn test
|
||||
run: yarn test
|
||||
run: CI=true bun run test
|
||||
|
||||
- name: 🦋 Check Styling
|
||||
run: yarn format:check
|
||||
- name: 🦋 Check Styling
|
||||
run: bun run format:check
|
||||
|
||||
21
.gitignore
vendored
@@ -37,8 +37,10 @@ local.properties
|
||||
# node.js
|
||||
#
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Don't think these will exist anymore!
|
||||
# npm-debug.log
|
||||
# yarn-error.log
|
||||
|
||||
# fastlane
|
||||
#
|
||||
@@ -64,14 +66,15 @@ yarn-error.log
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
.jest-cache
|
||||
|
||||
# Yarn
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
#.yarn/*
|
||||
#!.yarn/patches
|
||||
#!.yarn/plugins
|
||||
#!.yarn/releases
|
||||
#!.yarn/sdks
|
||||
#!.yarn/versions
|
||||
|
||||
# Expo
|
||||
.expo
|
||||
@@ -80,4 +83,4 @@ web-build/
|
||||
|
||||
# Maestro Output
|
||||
video.mp4
|
||||
.github/copilot-instructions.md
|
||||
.github/copilot-instructions.md
|
||||
|
||||
@@ -1 +1 @@
|
||||
yarn lint-staged
|
||||
bun lint-staged
|
||||
|
||||
86
App.tsx
@@ -1,72 +1,80 @@
|
||||
import './gesture-handler'
|
||||
import React, { useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import 'react-native-url-polyfill/auto'
|
||||
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
|
||||
import Jellify from './src/components/jellify'
|
||||
import { TamaguiProvider } from 'tamagui'
|
||||
import { Platform, useColorScheme } from 'react-native'
|
||||
import { LogBox, Platform, useColorScheme } from 'react-native'
|
||||
import jellifyConfig from './tamagui.config'
|
||||
import { queryClientPersister } from './src/constants/storage'
|
||||
import { ONE_DAY, queryClient } from './src/constants/query-client'
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||
import TrackPlayer, {
|
||||
AndroidAudioContentType,
|
||||
AppKilledPlaybackBehavior,
|
||||
IOSCategory,
|
||||
IOSCategoryOptions,
|
||||
} from 'react-native-track-player'
|
||||
import { CAPABILITIES } from './src/player/constants'
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
||||
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 ErrorBoundary from './src/components/ErrorBoundary'
|
||||
import OTAUpdateScreen from './src/components/OtaUpdates'
|
||||
import { usePerformanceMonitor } from './src/hooks/use-performance-monitor'
|
||||
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'
|
||||
|
||||
LogBox.ignoreAllLogs()
|
||||
|
||||
export default function App(): React.JSX.Element {
|
||||
// Add performance monitoring to track app-level re-renders
|
||||
const performanceMetrics = usePerformanceMonitor('App', 3)
|
||||
|
||||
const [playerIsReady, setPlayerIsReady] = useState<boolean>(false)
|
||||
const playerInitializedRef = useRef<boolean>(false)
|
||||
|
||||
/**
|
||||
* Enhanced Android buffer settings for gapless playback
|
||||
*
|
||||
* @see
|
||||
*/
|
||||
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
|
||||
}
|
||||
: {}
|
||||
useEffect(() => {
|
||||
// Guard against double initialization (React StrictMode, hot reload)
|
||||
if (playerInitializedRef.current) return
|
||||
playerInitializedRef.current = true
|
||||
|
||||
TrackPlayer.setupPlayer({
|
||||
autoHandleInterruptions: true,
|
||||
iosCategory: IOSCategory.Playback,
|
||||
iosCategoryOptions: [IOSCategoryOptions.AllowAirPlay, IOSCategoryOptions.AllowBluetooth],
|
||||
androidAudioContentType: AndroidAudioContentType.Music,
|
||||
minBuffer: 30, // 30 seconds minimum buffer
|
||||
...buffers,
|
||||
})
|
||||
.then(() =>
|
||||
TrackPlayer.updateOptions({
|
||||
capabilities: CAPABILITIES,
|
||||
notificationCapabilities: CAPABILITIES,
|
||||
// Reduced interval for smoother progress tracking and earlier prefetch detection
|
||||
progressUpdateEventInterval: PROGRESS_UPDATE_EVENT_INTERVAL,
|
||||
}),
|
||||
)
|
||||
.finally(() => {
|
||||
setPlayerIsReady(true)
|
||||
requestStoragePermission()
|
||||
TrackPlayer.setupPlayer({
|
||||
autoHandleInterruptions: true,
|
||||
iosCategory: IOSCategory.Playback,
|
||||
iosCategoryOptions: [
|
||||
IOSCategoryOptions.AllowAirPlay,
|
||||
IOSCategoryOptions.AllowBluetooth,
|
||||
],
|
||||
androidAudioContentType: AndroidAudioContentType.Music,
|
||||
minBuffer: 30, // 30 seconds minimum buffer
|
||||
...BUFFERS,
|
||||
})
|
||||
.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)
|
||||
|
||||
@@ -111,7 +119,9 @@ function Container({ playerIsReady }: { playerIsReady: boolean }): React.JSX.Ele
|
||||
: JellifyLightTheme
|
||||
: theme === 'dark'
|
||||
? JellifyDarkTheme
|
||||
: JellifyLightTheme
|
||||
: theme === 'oled'
|
||||
? JellifyOLEDTheme
|
||||
: JellifyLightTheme
|
||||
}
|
||||
>
|
||||
<GestureHandlerRootView>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -12,13 +12,13 @@ Here's the best way to get started:
|
||||
- Submit a Pull Request to sync the main repository with your fork
|
||||
- Profit! 🎉
|
||||
|
||||
## 🏃♀️Running Locally
|
||||
## Running Locally
|
||||
|
||||
### ⚛️ Universal Dependencies
|
||||
### Universal Dependencies
|
||||
|
||||
- [Ruby](https://www.ruby-lang.org/en/documentation/installation/) for Fastlane
|
||||
- [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
|
||||
|
||||
@@ -31,21 +31,21 @@ Here's the best way to get started:
|
||||
##### Setup
|
||||
|
||||
- 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)
|
||||
- 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_
|
||||
|
||||
##### 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`
|
||||
- Run either on a device or in the simulator
|
||||
- _You will need to wait for Xcode to finish it's "Indexing" step_
|
||||
|
||||
##### 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
|
||||
|
||||
@@ -59,21 +59,22 @@ Here's the best way to get started:
|
||||
##### Setup
|
||||
|
||||
- Clone this repository
|
||||
- Run `yarn install` to install `npm` packages
|
||||
- Run `bun install` to install `npm` packages
|
||||
|
||||
##### Running
|
||||
|
||||
- Run `yarn start` to start the dev server
|
||||
- Run `bun start` to start the dev server
|
||||
- Open the `android` folder with Android Studio
|
||||
- _Android Studio should automatically grab the "Run Configurations" and initialize Gradle_
|
||||
- Run either on a device or in the simulator
|
||||
|
||||
##### 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
|
||||
|
||||
- [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 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)
|
||||
|
||||
71
README.md
@@ -2,8 +2,8 @@
|
||||
<img alt='Jellify logo' src='assets/transparent-banner.png' width="600" height="300" />
|
||||
</p>
|
||||
|
||||
[](https://github.com/anultravioletaurora/Jellify/releases)
|
||||
[](https://github.com/anultravioletaurora/Jellify/actions/workflows/publish-beta.yml) [](https://github.com/Jellify-Music/App/actions/workflows/publish-ota-update.yml)
|
||||
[](https://github.com/anultravioletaurora/Jellify/releases) [](https://apps.apple.com/us/app/jellify/id6736884612) [](https://play.google.com/store/apps/details?id=com.cosmonautical.jellify)
|
||||
|
||||
|
||||
[](https://github.com/sponsors/anultravioletaurora) [](https://patreon.com/anultravioletaurora?utm_medium=unknown&utm_source=join_link&utm_campaign=creatorshare_creator&utm_content=copyLink)
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
- [Info](#info)
|
||||
- [Downloading](#downloading)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Features](#features)
|
||||
- [Features and Roadmap](#features)
|
||||
- [Built with](#built-with-good-stuff)
|
||||
- [Support](#support-the-project)
|
||||
- [Special Thanks](#special-thanks)
|
||||
@@ -65,6 +65,10 @@ These projects are **not** required to use _Jellify_, but are recommended by us
|
||||
|
||||
### Android
|
||||
|
||||
[](https://play.google.com/store/apps/details?id=com.cosmonautical.jellify)
|
||||
|
||||
#### Direct .APK Download
|
||||
|
||||
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.
|
||||
@@ -73,6 +77,8 @@ For Obtanium, click "Add App", put "https://github.com/Jellify-Music/App" as the
|
||||
|
||||
### iOS
|
||||
|
||||
[](https://apps.apple.com/us/app/jellify/id6736884612)
|
||||
|
||||
#### The TestFlight Way
|
||||
|
||||
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**
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/library_artists.png" alt="Library Artists" width="275" height="600">
|
||||
</p>
|
||||
|
||||
**Downloaded Tracks**
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/library_downloaded_tracks.PNG" alt="Library Tracks" width="275" height="600">
|
||||
<img src="screenshots/library_artists.png" alt="Library Artists" width="275" height="600" />
|
||||
<img src="screenshots/library_albums.PNG" alt="Library Albums" width="275" height="600" />
|
||||
<img src="screenshots/library_downloaded_tracks.PNG" alt="Library Tracks" width="275" height="600" />
|
||||
</p>
|
||||
|
||||
**Artist View**
|
||||
@@ -171,7 +173,7 @@ Install via [Altstore](https://altstore.io) or your favorite sideloading utility
|
||||
|
||||
### 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)
|
||||
- Light and Dark modes
|
||||
- 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
|
||||
- Support for Jellyfin Instant Mixes
|
||||
- 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
|
||||
- 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
|
||||
|
||||
@@ -271,8 +296,6 @@ Paid supporters will be recognized by having their name displayed within the Set
|
||||
- Quality Selection
|
||||
- Many thanks to PDB3D for the logo design!
|
||||
- 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
|
||||
- Cast Support
|
||||
- The friends we made along the way that have been critical in fostering an amazing community around _Jellify_
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<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="DEPLOY" value="true" />
|
||||
<option name="DEPLOY_APK_FROM_BUNDLE" value="false" />
|
||||
|
||||
@@ -91,8 +91,10 @@ android {
|
||||
applicationId "com.cosmonautical.jellify"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 133
|
||||
versionName "0.19.1"
|
||||
versionCode 158
|
||||
versionName "1.0.0"
|
||||
resValue "string", "build_config_package", "com.jellify"
|
||||
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
@@ -146,4 +148,4 @@ dependencies {
|
||||
implementation "com.google.android.gms:play-services-cast-framework:+"
|
||||
implementation("com.facebook.react:hermes-android")
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,7 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<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>
|
||||
@@ -2,4 +2,7 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<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>
|
||||
BIN
assets/banner.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
@@ -1,4 +1,8 @@
|
||||
module.exports = {
|
||||
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',
|
||||
],
|
||||
}
|
||||
|
||||
@@ -46,6 +46,22 @@ module.exports = defineConfig([
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'no-mixed-spaces-and-tabs': 'off',
|
||||
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: {
|
||||
|
||||
@@ -5,13 +5,12 @@ import CarPlay
|
||||
class CarSceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
|
||||
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) {
|
||||
|
||||
RNCarPlay.disconnect()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = Jellify/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
8798FC37A1454014A7B318F9 /* Figtree-SemiBold.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Figtree-SemiBold.otf"; path = "../assets/fonts/Figtree-SemiBold.otf"; sourceTree = "<group>"; };
|
||||
8B91428F7F524687A96EE362 /* Figtree-LightItalic.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Figtree-LightItalic.otf"; path = "../assets/fonts/Figtree-LightItalic.otf"; sourceTree = "<group>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
@@ -219,6 +220,7 @@
|
||||
children = (
|
||||
1EFD74F540EE131CCCC762FE /* Pods-Jellify.debug.xcconfig */,
|
||||
E53A46F6214019C12F016ACB /* Pods-Jellify.release.xcconfig */,
|
||||
940806CC81921C976BDC3779 /* Pods-Jellify.devrelease.xcconfig */,
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
@@ -306,7 +308,7 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 2600;
|
||||
LastUpgradeCheck = 2610;
|
||||
TargetAttributes = {
|
||||
00E356ED1AD99517003FC87E = {
|
||||
CreatedOnToolsVersion = 6.2;
|
||||
@@ -547,7 +549,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 245;
|
||||
CURRENT_PROJECT_VERSION = 267;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -558,7 +560,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.19.1;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
NEW_SETTING = "";
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
@@ -589,7 +591,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 245;
|
||||
CURRENT_PROJECT_VERSION = 267;
|
||||
DEVELOPMENT_TEAM = WAH9CZ8BPG;
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = WAH9CZ8BPG;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -599,7 +601,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.19.1;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
NEW_SETTING = "";
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
@@ -618,6 +620,13 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
47C4374A2EBD5610003A655B /* DevRelease */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
PRODUCT_NAME = JellifyTests;
|
||||
};
|
||||
name = DevRelease;
|
||||
};
|
||||
83CBBA201A601CBA00E9B192 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -801,6 +810,138 @@
|
||||
};
|
||||
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 */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -809,6 +950,7 @@
|
||||
buildConfigurations = (
|
||||
00E356F61AD99517003FC87E /* Debug */,
|
||||
00E356F71AD99517003FC87E /* Release */,
|
||||
47C4374A2EBD5610003A655B /* DevRelease */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
@@ -818,6 +960,7 @@
|
||||
buildConfigurations = (
|
||||
13B07F941A680F5B00A75B9A /* Debug */,
|
||||
13B07F951A680F5B00A75B9A /* Release */,
|
||||
CFDEVREL001 /* DevRelease */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
@@ -827,6 +970,7 @@
|
||||
buildConfigurations = (
|
||||
83CBBA201A601CBA00E9B192 /* Debug */,
|
||||
83CBBA211A601CBA00E9B192 /* Release */,
|
||||
CFDEVRELPROJ001 /* DevRelease */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2600"
|
||||
LastUpgradeVersion = "2610"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2600"
|
||||
LastUpgradeVersion = "2610"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -58,6 +58,13 @@
|
||||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>100.64.0.0/10</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
@@ -66,7 +73,7 @@
|
||||
<string>_CC1AD845._googlecast._tcp</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>${PRODUCT_NAME} uses the local network to connect to one's Jellyfin server for streaming music</string>
|
||||
<string>${PRODUCT_NAME} uses the local network to connect to one's Jellyfin server for streaming music</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string></string>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<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>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
@@ -21,16 +31,6 @@
|
||||
<string>C56D.1</string>
|
||||
</array>
|
||||
</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>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||
|
||||
172
ios/Podfile.lock
@@ -12,7 +12,7 @@ PODS:
|
||||
- hermes-engine (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
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
@@ -42,7 +42,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- NitroModules (0.31.1):
|
||||
- NitroModules (0.31.10):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
@@ -71,7 +71,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- NitroOta (0.3.0):
|
||||
- NitroOta (0.7.2):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
@@ -102,7 +102,7 @@ PODS:
|
||||
- SocketRocket
|
||||
- SSZipArchive
|
||||
- Yoga
|
||||
- NitroOtaBundleManager (0.3.0):
|
||||
- NitroOtaBundleManager (0.7.2):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
@@ -130,38 +130,6 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- SocketRocket
|
||||
- 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)
|
||||
- Protobuf (3.29.5)
|
||||
- RCT-Folly (2024.11.18.00):
|
||||
@@ -2036,7 +2004,7 @@ PODS:
|
||||
- Yoga
|
||||
- react-native-netinfo (11.4.1):
|
||||
- React-Core
|
||||
- react-native-pager-view (6.9.1):
|
||||
- react-native-pager-view (7.0.2):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
@@ -2064,7 +2032,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- react-native-safe-area-context (5.6.1):
|
||||
- react-native-safe-area-context (5.6.2):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
@@ -2082,8 +2050,8 @@ PODS:
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- react-native-safe-area-context/common (= 5.6.1)
|
||||
- react-native-safe-area-context/fabric (= 5.6.1)
|
||||
- react-native-safe-area-context/common (= 5.6.2)
|
||||
- react-native-safe-area-context/fabric (= 5.6.2)
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
@@ -2094,7 +2062,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- react-native-safe-area-context/common (5.6.1):
|
||||
- react-native-safe-area-context/common (5.6.2):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
@@ -2122,7 +2090,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- react-native-safe-area-context/fabric (5.6.1):
|
||||
- react-native-safe-area-context/fabric (5.6.2):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
@@ -2180,7 +2148,35 @@ PODS:
|
||||
- SocketRocket
|
||||
- SwiftAudioEx (= 1.1.0)
|
||||
- Yoga
|
||||
- react-native-vector-icons-material-design-icons (12.3.0)
|
||||
- react-native-vector-icons-material-design-icons (12.4.0)
|
||||
- react-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):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
@@ -2720,6 +2716,34 @@ PODS:
|
||||
- React-perflogger (= 0.82.1)
|
||||
- React-utils (= 0.82.1)
|
||||
- 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):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
@@ -2748,13 +2772,13 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- RNDeviceInfo (14.0.4):
|
||||
- RNDeviceInfo (15.0.1):
|
||||
- React-Core
|
||||
- RNDnsLookup (1.0.6):
|
||||
- React
|
||||
- RNFS (2.20.0):
|
||||
- React-Core
|
||||
- RNGestureHandler (2.28.0):
|
||||
- RNGestureHandler (2.29.1):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
@@ -2810,7 +2834,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- RNReanimated (4.1.3):
|
||||
- RNReanimated (4.1.5):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
@@ -2837,11 +2861,11 @@ PODS:
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- RNReanimated/reanimated (= 4.1.3)
|
||||
- RNReanimated/reanimated (= 4.1.5)
|
||||
- RNWorklets
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- RNReanimated/reanimated (4.1.3):
|
||||
- RNReanimated/reanimated (4.1.5):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
@@ -2868,11 +2892,11 @@ PODS:
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- RNReanimated/reanimated/apple (= 4.1.3)
|
||||
- RNReanimated/reanimated/apple (= 4.1.5)
|
||||
- RNWorklets
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- RNReanimated/reanimated/apple (4.1.3):
|
||||
- RNReanimated/reanimated/apple (4.1.5):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
@@ -2961,7 +2985,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- RNSentry (7.1.0):
|
||||
- RNSentry (7.6.0):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
@@ -2988,7 +3012,7 @@ PODS:
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Sentry/HybridSDK (= 8.56.0)
|
||||
- Sentry/HybridSDK (= 8.57.2)
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- RNWorklets (0.6.1):
|
||||
@@ -3083,7 +3107,7 @@ PODS:
|
||||
- SDWebImage (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)
|
||||
- SSZipArchive (2.4.3)
|
||||
- SwiftAudioEx (1.1.0)
|
||||
@@ -3098,11 +3122,10 @@ DEPENDENCIES:
|
||||
- fmt (from `../node_modules/react-native/third-party-podspecs/fmt.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`)
|
||||
- NitroImage (from `../node_modules/react-native-nitro-image`)
|
||||
- NitroFetch (from `../node_modules/react-native-nitro-fetch`)
|
||||
- NitroModules (from `../node_modules/react-native-nitro-modules`)
|
||||
- NitroOta (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`)
|
||||
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
|
||||
- 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-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-worklets-core (from `../node_modules/react-native-worklets-core`)
|
||||
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
|
||||
- React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`)
|
||||
- React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
|
||||
@@ -3181,6 +3205,7 @@ DEPENDENCIES:
|
||||
- ReactAppDependencyProvider (from `build/generated/ios`)
|
||||
- ReactCodegen (from `build/generated/ios`)
|
||||
- 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`)"
|
||||
- RNDeviceInfo (from `../node_modules/react-native-device-info`)
|
||||
- RNDnsLookup (from `../node_modules/react-native-dns-lookup`)
|
||||
@@ -3224,16 +3249,14 @@ EXTERNAL SOURCES:
|
||||
hermes-engine:
|
||||
:podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
|
||||
:tag: hermes-2025-09-01-RNv0.82.0-265ef62ff3eb7289d17e366664ac0da82303e101
|
||||
NitroImage:
|
||||
:path: "../node_modules/react-native-nitro-image"
|
||||
NitroFetch:
|
||||
:path: "../node_modules/react-native-nitro-fetch"
|
||||
NitroModules:
|
||||
:path: "../node_modules/react-native-nitro-modules"
|
||||
NitroOta:
|
||||
:path: "../node_modules/react-native-nitro-ota"
|
||||
NitroOtaBundleManager:
|
||||
:path: "../node_modules/react-native-nitro-ota"
|
||||
NitroWebImage:
|
||||
:path: "../node_modules/react-native-nitro-web-image"
|
||||
RCT-Folly:
|
||||
:podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec"
|
||||
RCTDeprecation:
|
||||
@@ -3324,6 +3347,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/react-native-track-player"
|
||||
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:
|
||||
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios"
|
||||
React-oscompat:
|
||||
@@ -3388,6 +3413,8 @@ EXTERNAL SOURCES:
|
||||
:path: build/generated/ios
|
||||
ReactCommon:
|
||||
:path: "../node_modules/react-native/ReactCommon"
|
||||
RNCAsyncStorage:
|
||||
:path: "../node_modules/@react-native-async-storage/async-storage"
|
||||
RNCMaskedView:
|
||||
:path: "../node_modules/@react-native-masked-view/masked-view"
|
||||
RNDeviceInfo:
|
||||
@@ -3421,11 +3448,10 @@ SPEC CHECKSUMS:
|
||||
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
|
||||
google-cast-sdk: 1fb6724e94cc5ff23b359176e0cf6360586bb97a
|
||||
hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5
|
||||
NitroImage: 76da8995cc5476111ac5069300a3ec5de0f65e9b
|
||||
NitroModules: 0ba3a58906a86566ea83abc016f8692374c19761
|
||||
NitroOta: 460722ac309996c07ea88134f47101246fe65658
|
||||
NitroOtaBundleManager: 66a5b277368a6c7f977134258663531441e37522
|
||||
NitroWebImage: 5cd76cf34fb1661acc4daf5a6925d5a29448c7c4
|
||||
NitroFetch: 660adfb47f84b28db664f97b50e5dc28506ab6c1
|
||||
NitroModules: 5bc319d441f4983894ea66b1d392c519536e6d23
|
||||
NitroOta: 7755c4728f7348584cebb2d428480b1ed0cd2679
|
||||
NitroOtaBundleManager: 482abb17f0ca629ad551da43f13e76e59dba9568
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
Protobuf: 164aea2ae380c3951abdc3e195220c01d17400e0
|
||||
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
|
||||
@@ -3469,10 +3495,11 @@ SPEC CHECKSUMS:
|
||||
react-native-google-cast: 7be68a5d0b7eeb95a5924c3ecef8d319ef6c0a44
|
||||
react-native-mmkv: ac7507625cd74bac0eb5333604a7cd7b08fe9e3e
|
||||
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
|
||||
react-native-pager-view: a0516effb17ca5120ac2113bfd21b91130ad5748
|
||||
react-native-safe-area-context: c6e2edd1c1da07bdce287fa9d9e60c5f7b514616
|
||||
react-native-pager-view: 5c3098839820aa73d75873e7b1a7eb9f119602b7
|
||||
react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460
|
||||
react-native-track-player: 89d8e641c83a89bea5dee43c381be743282553e9
|
||||
react-native-vector-icons-material-design-icons: c502df5b988ce85d6c7d2b7ee909818315760b82
|
||||
react-native-vector-icons-material-design-icons: 76cd460b3540b80527b4a80fb7f867f7deedb498
|
||||
react-native-worklets-core: 28a6e2121dcf62543b703e81bc4860e9a0150cee
|
||||
React-NativeModulesApple: 46690a0fe94ec28fc6fc686ec797b911d251ded0
|
||||
React-oscompat: 95875e81f5d4b3c7b2c888d5bd2c9d83450d8bdb
|
||||
React-perflogger: 2e229bf33e42c094fd64516d89ec1187a2b79b5b
|
||||
@@ -3505,18 +3532,19 @@ SPEC CHECKSUMS:
|
||||
ReactAppDependencyProvider: a45ef34bb22dc1c9b2ac1f74167d9a28af961176
|
||||
ReactCodegen: 878add6c7d8ff8cea87697c44d29c03b79b6f2d9
|
||||
ReactCommon: 804dc80944fa90b86800b43c871742ec005ca424
|
||||
RNCAsyncStorage: 29f0230e1a25f36c20b05f65e2eb8958d6526e82
|
||||
RNCMaskedView: 5ef8c95cbab95334a32763b72896a7b7d07e6299
|
||||
RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047
|
||||
RNDeviceInfo: 36d7f232bfe7c9b5c494cb7793230424ed32c388
|
||||
RNDnsLookup: db4a89381b80ec1a5153088518d2c4f8e51f2521
|
||||
RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
|
||||
RNGestureHandler: f1dd7f92a0faa2868a919ab53bb9d66eb4ebfcf5
|
||||
RNGestureHandler: e1cf8ef3f11045536eed6bd4f132b003ef5f9a5f
|
||||
RNReactNativeHapticFeedback: be4f1b4bf0398c30b59b76ed92ecb0a2ff3a69c6
|
||||
RNReanimated: 732e7d1662f8cc0e533fa32791800de5b5934726
|
||||
RNReanimated: ac06da53579693ab451941ef89f5a55afeab0dd9
|
||||
RNScreens: d821082c6dd1cb397cc0c98b026eeafaa68be479
|
||||
RNSentry: 60919c9cdac7e4b35e9f5dd0149f551ec12f35cb
|
||||
RNSentry: d48cee794dd35d77930dcf89b983dc8c6498ec0d
|
||||
RNWorklets: ab618bf7d1c7fd2cb793b9f0f39c3e29274b3ebf
|
||||
SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a
|
||||
Sentry: 3d82977434c80381cae856c40b99c39e4be6bc11
|
||||
Sentry: 83a3814c3ca042874b39c5c5bdffb6570d4d760e
|
||||
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
|
||||
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
|
||||
SwiftAudioEx: f6aa653770f3a0d3851edaf8d834a30aee4a7646
|
||||
|
||||
@@ -74,12 +74,13 @@ platform :ios do
|
||||
)
|
||||
end
|
||||
|
||||
lane :notifyOnDiscord do
|
||||
lane :notifyOnDiscordForRelease do
|
||||
app_version = ENV["APP_VERSION"] || "N/A"
|
||||
changelog = sh("git log -n 3 --pretty=format:'• %s (%h) - %an'").split("\n").join("\n")
|
||||
discord_url = ENV["DISCORD_WEBHOOK_URL"] || "N/A"
|
||||
release_url = ENV["release_url"]
|
||||
testflight_url = "https://testflight.apple.com/join/etVSc7ZQ"
|
||||
unix_timestamp = Time.now.to_i
|
||||
discord_notifier(
|
||||
webhook_url: ENV["DISCORD_WEBHOOK_URL"],
|
||||
title: "🎉 App v#{app_version} Released!",
|
||||
@@ -96,7 +97,7 @@ platform :ios do
|
||||
},
|
||||
{
|
||||
name: "🕒 Released On",
|
||||
value: Time.now.strftime("%B %d, %Y at %I:%M %p")
|
||||
value: "<t:#{unix_timestamp}:f>"
|
||||
},
|
||||
{
|
||||
name: "📝 Release Notes",
|
||||
@@ -107,4 +108,17 @@ platform :ios do
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -31,6 +31,14 @@ Push a new beta build to TestFlight
|
||||
|
||||
|
||||
|
||||
### ios notifyOnDiscordForRelease
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios notifyOnDiscordForRelease
|
||||
```
|
||||
|
||||
|
||||
|
||||
### ios notifyOnDiscord
|
||||
|
||||
```sh
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
module.exports = {
|
||||
preset: 'react-native',
|
||||
testTimeout: 10000,
|
||||
|
||||
// Performance optimizations for CI
|
||||
maxWorkers: process.env.CI ? 2 : '50%',
|
||||
cacheDirectory: '.jest-cache',
|
||||
|
||||
setupFiles: ['./node_modules/react-native-gesture-handler/jestSetup.js'],
|
||||
setupFilesAfterEnv: [
|
||||
'./jest/setup/setup.ts',
|
||||
'./jest/setup/async-storage.ts',
|
||||
'./jest/setup/blur.ts',
|
||||
'./jest/setup/carplay.ts',
|
||||
'./jest/setup/device-info.js', // JS to prevent Typescript implicit any warning
|
||||
@@ -12,6 +18,7 @@ module.exports = {
|
||||
'./jest/setup/rnfs.ts',
|
||||
'./jest/setup/rntp.ts',
|
||||
'./jest/setup/sentry.ts',
|
||||
'./jest/setup/nitro-fetch.ts',
|
||||
'./jest/setup/nitro-image.ts',
|
||||
'./jest/setup/nitro-ota.ts',
|
||||
'./tamagui.config.ts',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -4,16 +4,13 @@ import { render } from '@testing-library/react-native'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { PlayerProvider } from '../../src/providers/Player'
|
||||
import { JellifyProvider } from '../../src/providers'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
test(`${PlayerProvider.name} renders correctly`, () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JellifyProvider>
|
||||
<PlayerProvider />
|
||||
</JellifyProvider>
|
||||
<PlayerProvider />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
})
|
||||
|
||||
148
jest/contextual/SwipeableRow.behavior.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
99
jest/contextual/swipeable-row-registry.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
34
jest/functional/AddToQueue.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
3
jest/setup/async-storage.ts
Normal 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
@@ -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(),
|
||||
}
|
||||
})
|
||||
@@ -1,60 +1,60 @@
|
||||
// Mock for react-native-nitro-image
|
||||
import React from 'react'
|
||||
import { Image, ImageProps } from 'react-native'
|
||||
// // Mock for react-native-nitro-image
|
||||
// import React from 'react'
|
||||
// import { Image, ImageProps } from 'react-native'
|
||||
|
||||
// Mock the useWebImage hook
|
||||
const mockUseWebImage = jest.fn(() => ({
|
||||
imageUri: 'mock://image.jpg',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}))
|
||||
// // Mock the useWebImage hook
|
||||
// const mockUseWebImage = jest.fn(() => ({
|
||||
// imageUri: 'mock://image.jpg',
|
||||
// isLoading: false,
|
||||
// error: null,
|
||||
// }))
|
||||
|
||||
// Define types for NitroImage props
|
||||
interface NitroImageProps extends Omit<ImageProps, 'source'> {
|
||||
image?: {
|
||||
url: string
|
||||
}
|
||||
}
|
||||
// // Define types for NitroImage props
|
||||
// interface NitroImageProps extends Omit<ImageProps, 'source'> {
|
||||
// image?: {
|
||||
// url: string
|
||||
// }
|
||||
// }
|
||||
|
||||
// Mock the NitroImage component to behave like a regular Image
|
||||
const MockNitroImage = (props: NitroImageProps) => {
|
||||
// Extract the URL from the image prop if it exists
|
||||
const source = props.image?.url ? { uri: props.image.url } : undefined
|
||||
// // Mock the NitroImage component to behave like a regular Image
|
||||
// const MockNitroImage = (props: NitroImageProps) => {
|
||||
// // Extract the URL from the image prop if it exists
|
||||
// const source = props.image?.url ? { uri: props.image.url } : undefined
|
||||
|
||||
// Destructure to separate the custom image prop from standard Image props
|
||||
const { image, ...restProps } = props
|
||||
// // Destructure to separate the custom image prop from standard Image props
|
||||
// const { image, ...restProps } = props
|
||||
|
||||
// Pass through other props while converting to Image component props
|
||||
const imageProps: ImageProps = {
|
||||
...restProps,
|
||||
source,
|
||||
}
|
||||
// // Pass through other props while converting to Image component props
|
||||
// const imageProps: ImageProps = {
|
||||
// ...restProps,
|
||||
// source,
|
||||
// }
|
||||
|
||||
return React.createElement(Image, imageProps)
|
||||
}
|
||||
// return React.createElement(Image, imageProps)
|
||||
// }
|
||||
|
||||
// Mock the entire react-native-nitro-image module
|
||||
jest.mock('react-native-nitro-image', () => ({
|
||||
useWebImage: mockUseWebImage,
|
||||
NitroImage: MockNitroImage,
|
||||
// Add other exports that might be used
|
||||
createImageFactory: jest.fn(),
|
||||
ImageFactory: jest.fn(),
|
||||
}))
|
||||
// // Mock the entire react-native-nitro-image module
|
||||
// jest.mock('react-native-nitro-image', () => ({
|
||||
// useWebImage: mockUseWebImage,
|
||||
// NitroImage: MockNitroImage,
|
||||
// // Add other exports that might be used
|
||||
// createImageFactory: jest.fn(),
|
||||
// ImageFactory: jest.fn(),
|
||||
// }))
|
||||
|
||||
// Mock the underlying native module that causes the error
|
||||
jest.mock('react-native-nitro-modules', () => ({
|
||||
NitroModules: {
|
||||
createModule: jest.fn(),
|
||||
install: jest.fn(),
|
||||
},
|
||||
createNitroModule: jest.fn(),
|
||||
}))
|
||||
// // Mock the underlying native module that causes the error
|
||||
// jest.mock('react-native-nitro-modules', () => ({
|
||||
// NitroModules: {
|
||||
// createModule: jest.fn(),
|
||||
// install: jest.fn(),
|
||||
// },
|
||||
// createNitroModule: jest.fn(),
|
||||
// }))
|
||||
|
||||
// Additional mock for the TurboModule spec that's failing
|
||||
jest.mock('react-native-nitro-modules/src/turbomodule/NativeNitroModules', () => ({
|
||||
default: {
|
||||
installModule: jest.fn(),
|
||||
uninstallModule: jest.fn(),
|
||||
},
|
||||
}))
|
||||
// // Additional mock for the TurboModule spec that's failing
|
||||
// jest.mock('react-native-nitro-modules/src/turbomodule/NativeNitroModules', () => ({
|
||||
// default: {
|
||||
// installModule: jest.fn(),
|
||||
// uninstallModule: jest.fn(),
|
||||
// },
|
||||
// }))
|
||||
|
||||
@@ -8,6 +8,8 @@ jest.mock('react-native-nitro-ota', () => ({
|
||||
checkForUpdates: jest.fn().mockResolvedValue(null),
|
||||
downloadUpdate: jest.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
reloadApp: jest.fn(),
|
||||
getStoredOtaVersion: jest.fn(() => null),
|
||||
}))
|
||||
|
||||
// Update the existing nitro-modules mock to include createHybridObject
|
||||
|
||||
@@ -55,6 +55,9 @@ jest.mock('react-native-track-player', () => {
|
||||
useTrackPlayerEvents: (events: Event[], handler: (variables: any) => void) => {
|
||||
eventHandler = handler
|
||||
},
|
||||
AppKilledPlaybackBehavior: {
|
||||
StopPlaybackAndRemoveNotification: 'stopPlaybackAndRemoveNotification',
|
||||
},
|
||||
Capability: {
|
||||
Play: 1,
|
||||
PlayFromId: 2,
|
||||
|
||||
@@ -3,3 +3,4 @@ appId: com.cosmonautical.jellify
|
||||
- runFlow: ../tests/4-search.yaml
|
||||
- runFlow: ../tests/5-discover.yaml
|
||||
- runFlow: ../tests/6-settings.yaml
|
||||
- runFlow: ../tests/7-quickactions.yaml
|
||||
@@ -49,16 +49,16 @@ appId: com.cosmonautical.jellify
|
||||
# Scroll Down to see the queue
|
||||
- scrollUntilVisible:
|
||||
element:
|
||||
id: "queue-item-12"
|
||||
id: "queue-item-8"
|
||||
direction: "DOWN"
|
||||
|
||||
- scrollUntilVisible:
|
||||
element:
|
||||
id: "queue-item-12"
|
||||
id: "queue-item-8"
|
||||
direction: "UP"
|
||||
# Play some other Song
|
||||
- tapOn:
|
||||
id: 'queue-item-12'
|
||||
id: 'queue-item-8'
|
||||
|
||||
- pressKey: BACK
|
||||
|
||||
|
||||
@@ -12,9 +12,6 @@ appId: com.cosmonautical.jellify
|
||||
- assertVisible:
|
||||
id: "discover-recently-added"
|
||||
|
||||
- assertVisible:
|
||||
id: "discover-public-playlists"
|
||||
|
||||
- assertVisible:
|
||||
id: "discover-suggested-artists"
|
||||
|
||||
|
||||
206
maestro/tests/6-quickactions.yaml
Normal 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"
|
||||
@@ -13,22 +13,16 @@ appId: com.cosmonautical.jellify
|
||||
text: "App"
|
||||
|
||||
# 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:
|
||||
text: "Theme"
|
||||
- assertVisible:
|
||||
text: "System"
|
||||
text: "Match Device"
|
||||
- assertVisible:
|
||||
text: "Light"
|
||||
- assertVisible:
|
||||
text: "Dark"
|
||||
- assertVisible:
|
||||
text: "Track Swipe Actions"
|
||||
|
||||
# Test Player (Playback) Tab
|
||||
- tapOn:
|
||||
70
package.json
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"name": "jellify",
|
||||
"version": "0.19.1",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"init-android": "yarn install --network-concurrency 1",
|
||||
"init-ios": "yarn init-ios:new-arch",
|
||||
"init-ios:new-arch": "yarn install --network-concurrency 1 && yarn pod:install:new-arch",
|
||||
"reinstall": "rm -rf ./node_modules && yarn install",
|
||||
"init-android": "bun i",
|
||||
"init-ios": "bun run init-ios:new-arch",
|
||||
"init-ios:new-arch": "bun i && bun run pod:install:new-arch",
|
||||
"reinstall": "rm -rf ./node_modules && bun i",
|
||||
"android": "react-native run-android",
|
||||
"ios": "react-native run-ios",
|
||||
"lint": "eslint .",
|
||||
"start": "react-native start",
|
||||
"test": "jest",
|
||||
"test": "bunx jest",
|
||||
"tsc": "tsc",
|
||||
"codegen": "env DEBUG=metro:* react-native codegen",
|
||||
"clean:ios": "cd ios && pod deintegrate",
|
||||
"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:clean": "cd ios && pod deintegrate",
|
||||
"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",
|
||||
"sendOTA:android": "bash scripts/ota-android.sh",
|
||||
"sendOTA:iOS": "bash scripts/ota-iOS.sh",
|
||||
"sendOTA:PR": "bash scripts/ota-PR.sh",
|
||||
"android-build": "cd android && ./gradlew generateCodegenArtifactsFromSchema && ./gradlew assembleRelease",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jellyfin/sdk": "0.13.0",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-native-community/cli": "20.0.0",
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native-vector-icons/material-design-icons": "^12.3.0",
|
||||
"@react-navigation/bottom-tabs": "7.6.0",
|
||||
"@react-navigation/material-top-tabs": "7.4.0",
|
||||
"@react-navigation/native": "7.1.19",
|
||||
"@react-navigation/native-stack": "7.6.0",
|
||||
"@sentry/react-native": "7.1.0",
|
||||
"@shopify/flash-list": "^2.1.0",
|
||||
"@tamagui/config": "1.135.4",
|
||||
"@react-native-vector-icons/material-design-icons": "12.4.0",
|
||||
"@react-navigation/bottom-tabs": "7.8.10",
|
||||
"@react-navigation/material-top-tabs": "7.4.7",
|
||||
"@react-navigation/native": "7.1.23",
|
||||
"@react-navigation/native-stack": "7.8.4",
|
||||
"@sentry/react-native": "7.6.0",
|
||||
"@shopify/flash-list": "2.2.0",
|
||||
"@tamagui/config": "1.137.1",
|
||||
"@tanstack/query-async-storage-persister": "5.89.0",
|
||||
"@tanstack/react-query": "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",
|
||||
"axios": "1.12.2",
|
||||
"axios": "1.13.2",
|
||||
"bundle": "^2.1.0",
|
||||
"dlx": "^0.2.1",
|
||||
"gem": "^2.4.3",
|
||||
"invert-color": "^2.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"openai": "5.21.0",
|
||||
@@ -67,34 +68,32 @@
|
||||
"react-native-blurhash": "2.1.1",
|
||||
"react-native-carplay": "^2.4.1-beta.0",
|
||||
"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-draggable-flatlist": "^4.0.3",
|
||||
"react-native-flashdrag-list": "^0.2.5",
|
||||
"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-haptic-feedback": "^2.3.3",
|
||||
"react-native-linear-gradient": "^2.8.3",
|
||||
"react-native-mmkv": "3.3.3",
|
||||
"react-native-nitro-image": "0.8.1",
|
||||
"react-native-nitro-modules": "^0.31.1",
|
||||
"react-native-nitro-ota": "^0.3.0",
|
||||
"react-native-nitro-web-image": "0.8.1",
|
||||
"react-native-pager-view": "^6.9.1",
|
||||
"react-native-reanimated": "4.1.3",
|
||||
"react-native-safe-area-context": "^5.6.1",
|
||||
"react-native-nitro-fetch": "^0.1.6",
|
||||
"react-native-nitro-modules": "0.31.10",
|
||||
"react-native-nitro-ota": "0.7.2",
|
||||
"react-native-pager-view": "^7.0.2",
|
||||
"react-native-reanimated": "4.1.5",
|
||||
"react-native-safe-area-context": "5.6.2",
|
||||
"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-toast-message": "^2.3.3",
|
||||
"react-native-track-player": "5.0.0-alpha0",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-worklets": "0.6.1",
|
||||
"react-native-worklets-core": "^1.6.2",
|
||||
"ruby": "^0.6.1",
|
||||
"scheduler": "^0.26.0",
|
||||
"tamagui": "1.135.4",
|
||||
"tamagui": "1.137.1",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -116,6 +115,7 @@
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
@@ -141,7 +141,13 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.3.2",
|
||||
"node": ">=18"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
"packageManager": "bun@1.3.2",
|
||||
"trustedDependencies": [
|
||||
"@sentry/cli",
|
||||
"react-native-nitro-modules",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
13
patches/react-native-nitro-fetch+0.1.6.patch
Normal 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 {
|
||||
13
patches/react-native-worklets-core+1.6.2.patch
Normal 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})
|
||||
|
Before Width: | Height: | Size: 588 KiB After Width: | Height: | Size: 846 KiB |
|
Before Width: | Height: | Size: 540 KiB After Width: | Height: | Size: 643 KiB |
|
Before Width: | Height: | Size: 554 KiB After Width: | Height: | Size: 497 KiB |
@@ -3,6 +3,12 @@ set -euo pipefail
|
||||
|
||||
FILE="ota.version"
|
||||
|
||||
# Check if --PR flag is passed
|
||||
IS_PR=false
|
||||
if [[ "${1:-}" == "--PR" ]]; then
|
||||
IS_PR=true
|
||||
fi
|
||||
|
||||
# Array of sentences
|
||||
sentences=(
|
||||
"Git Blame violet"
|
||||
@@ -30,11 +36,28 @@ get_random_sentence() {
|
||||
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)
|
||||
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
|
||||
tmp="${FILE}.tmp.$$"
|
||||
echo "$new_sentence" > "$tmp"
|
||||
echo "$version_string" > "$tmp"
|
||||
mv "$tmp" "$FILE"
|
||||
|
||||
echo "✅ Updated $FILE with: \"$new_sentence\""
|
||||
echo "✅ Updated $FILE with: \"$version_string\""
|
||||
|
||||
14
scripts/maestro-android-retry.sh
Executable 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
@@ -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 ..
|
||||
@@ -11,7 +11,7 @@ else
|
||||
git checkout -b "$target_branch"
|
||||
fi
|
||||
cd ../..
|
||||
yarn createBundle:android
|
||||
bun createBundle:android
|
||||
cd android/App-Bundles
|
||||
bash ../../scripts/getRandomVersion.sh
|
||||
git add .
|
||||
|
||||
@@ -14,7 +14,7 @@ else
|
||||
fi
|
||||
rm -rf Readme.md
|
||||
cd ../..
|
||||
yarn createBundle:ios
|
||||
bun createBundle:ios
|
||||
cd ios/App-Bundles
|
||||
bash ../../scripts/getRandomVersion.sh
|
||||
git add .
|
||||
|
||||
14
scripts/sendDiscordMessage.js
Normal 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)
|
||||
@@ -3,8 +3,6 @@ import { getModel, getUniqueIdSync } from 'react-native-device-info'
|
||||
import { name, version } from '../../package.json'
|
||||
import { capitalize } from 'lodash'
|
||||
|
||||
console.debug(`Building Jellyfin Info`)
|
||||
|
||||
/**
|
||||
* Client object that represents Jellify on the Jellyfin server.
|
||||
*/
|
||||
|
||||
@@ -2,35 +2,39 @@ import { AxiosResponse } from 'axios'
|
||||
import { JellyfinCredentials } from '../../types/jellyfin-credentials'
|
||||
import { AuthenticationResult } from '@jellyfin/sdk/lib/generated-client'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import { JellifyUser } from '../../../types/JellifyUser'
|
||||
import { isUndefined } from 'lodash'
|
||||
import { getUserApi } from '@jellyfin/sdk/lib/utils/api'
|
||||
import { useApi, useJellifyUser } from '../../../stores'
|
||||
|
||||
interface AuthenticateUserByNameMutation {
|
||||
onSuccess?: () => void
|
||||
onError?: () => void
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
const useAuthenticateUserByName = ({ onSuccess, onError }: AuthenticateUserByNameMutation) => {
|
||||
const { api, setUser } = useJellifyContext()
|
||||
const api = useApi()
|
||||
const [user, setUser] = useJellifyUser()
|
||||
|
||||
return useMutation({
|
||||
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>) => {
|
||||
console.log(`Received auth response from server`)
|
||||
if (isUndefined(authResult))
|
||||
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'))
|
||||
|
||||
if (isUndefined(authResult.data.User))
|
||||
return Promise.reject(new Error('Unable to login'))
|
||||
|
||||
console.log(`Successfully signed in to server`)
|
||||
|
||||
const user: JellifyUser = {
|
||||
id: authResult.data.User!.Id!,
|
||||
name: authResult.data.User!.Name!,
|
||||
@@ -44,7 +48,7 @@ const useAuthenticateUserByName = ({ onSuccess, onError }: AuthenticateUserByNam
|
||||
onError: async (error: Error) => {
|
||||
console.error('An error occurred connecting to the Jellyfin instance', error)
|
||||
|
||||
if (onError) onError()
|
||||
if (onError) onError(error)
|
||||
},
|
||||
retry: 0,
|
||||
gcTime: 0,
|
||||
|
||||
23
src/api/mutations/discover/index.ts
Normal 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
|
||||
@@ -1,5 +1,4 @@
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import { useDownloadingDeviceProfile } from '../../../stores/device-profile'
|
||||
import { UseMutateFunction, useMutation } from '@tanstack/react-query'
|
||||
import { mapDtoToTrack } from '../../../utils/mappings'
|
||||
@@ -7,12 +6,13 @@ import { deleteAudio, saveAudio } from './offlineModeUtils'
|
||||
import { useState } from 'react'
|
||||
import { JellifyDownloadProgress } from '../../../types/JellifyDownload'
|
||||
import { useAllDownloadedTracks } from '../../queries/download'
|
||||
import { useApi } from '../../../stores'
|
||||
|
||||
export const useDownloadAudioItem: () => [
|
||||
JellifyDownloadProgress,
|
||||
UseMutateFunction<boolean, Error, { item: BaseItemDto; autoCached: boolean }, void>,
|
||||
] = () => {
|
||||
const { api } = useJellifyContext()
|
||||
const api = useApi()
|
||||
|
||||
const { data: downloadedTracks, refetch } = useAllDownloadedTracks()
|
||||
|
||||
@@ -23,7 +23,7 @@ export const useDownloadAudioItem: () => [
|
||||
return [
|
||||
downloadProgress,
|
||||
useMutation({
|
||||
onMutate: () => console.debug('Downloading audio track from Jellyfin'),
|
||||
onMutate: () => {},
|
||||
mutationFn: async ({
|
||||
item,
|
||||
autoCached,
|
||||
@@ -40,7 +40,7 @@ export const useDownloadAudioItem: () => [
|
||||
)
|
||||
return Promise.resolve(false)
|
||||
|
||||
const track = mapDtoToTrack(api, item, downloadedTracks ?? [], deviceProfile)
|
||||
const track = mapDtoToTrack(api, item, deviceProfile)
|
||||
|
||||
return saveAudio(track, setDownloadProgress, autoCached)
|
||||
},
|
||||
@@ -79,8 +79,7 @@ export const useDeleteDownloads = () => {
|
||||
},
|
||||
onError: (error, itemIds) =>
|
||||
console.error(`Unable to delete ${itemIds.length} downloads`, error),
|
||||
onSuccess: (_, itemIds) =>
|
||||
console.debug(`Successfully deleted ${itemIds.length} downloads`),
|
||||
onSuccess: (_, itemIds) => {},
|
||||
onSettled: () => refetch(),
|
||||
}).mutate
|
||||
}
|
||||
|
||||
@@ -11,28 +11,69 @@ import {
|
||||
import { queryClient } from '../../../constants/query-client'
|
||||
import { AUDIO_CACHE_QUERY } from '../../queries/download/constants'
|
||||
|
||||
type DownloadedFileInfo = {
|
||||
uri: string
|
||||
path: string
|
||||
fileName: string
|
||||
size: number
|
||||
}
|
||||
|
||||
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(
|
||||
url: string,
|
||||
name: string,
|
||||
songName: string,
|
||||
setDownloadProgress: JellifyDownloadProgressState,
|
||||
) {
|
||||
preferredExtension?: string | null,
|
||||
): Promise<DownloadedFileInfo> {
|
||||
try {
|
||||
// Fetch the file
|
||||
const headRes = await axios.head(url)
|
||||
const contentType = headRes.headers['content-type']
|
||||
const urlExtension = normalizeExtension(getExtensionFromUrl(url))
|
||||
const hintedExtension = normalizeExtension(preferredExtension)
|
||||
|
||||
// Step 2: Get extension from content-type
|
||||
let extension = 'mp3' // default extension
|
||||
if (contentType && contentType.includes('/')) {
|
||||
const parts = contentType.split('/')
|
||||
const container = parts[1].split(';')[0] // handles "audio/m4a; charset=utf-8"
|
||||
if (container !== 'mpeg') {
|
||||
extension = container // don't use mpeg as an extension, use the default extension
|
||||
let extension = urlExtension ?? hintedExtension ?? null
|
||||
|
||||
if (!extension) {
|
||||
try {
|
||||
const headRes = await axios.head(url)
|
||||
const headExtension = extensionFromContentType(headRes.headers['content-type'])
|
||||
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 downloadDest = `${RNFS.DocumentDirectoryPath}/${fileName}`
|
||||
|
||||
@@ -47,9 +88,7 @@ export async function downloadJellyfinFile(
|
||||
toFile: downloadDest,
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
begin: (res: any) => {
|
||||
console.log('Download started')
|
||||
},
|
||||
begin: (res: any) => {},
|
||||
progress: (data: any) => {
|
||||
const percent = +(data.bytesWritten / data.contentLength).toFixed(2)
|
||||
|
||||
@@ -63,9 +102,15 @@ export async function downloadJellyfinFile(
|
||||
}
|
||||
|
||||
const result = await RNFS.downloadFile(options).promise
|
||||
console.log('Download complete:', result)
|
||||
|
||||
return `file://${downloadDest}`
|
||||
const metadata = await RNFS.stat(downloadDest)
|
||||
|
||||
return {
|
||||
uri: `file://${downloadDest}`,
|
||||
path: downloadDest,
|
||||
fileName,
|
||||
size: Number(metadata.size),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error)
|
||||
throw error
|
||||
@@ -116,44 +161,43 @@ export const saveAudio = async (
|
||||
}
|
||||
|
||||
try {
|
||||
console.debug('Downloading audio')
|
||||
|
||||
const downloadtrack = await downloadJellyfinFile(
|
||||
const downloadedTrackFile = await downloadJellyfinFile(
|
||||
track.url,
|
||||
track.item.Id as string,
|
||||
track.title as string,
|
||||
setDownloadProgress,
|
||||
track.mediaSourceInfo?.Container,
|
||||
)
|
||||
const dowloadalbum = await downloadJellyfinFile(
|
||||
track.artwork as string,
|
||||
track.item.Id as string,
|
||||
track.title as string,
|
||||
setDownloadProgress,
|
||||
)
|
||||
console.log('downloadtrack', downloadtrack)
|
||||
if (downloadtrack) {
|
||||
track.url = downloadtrack
|
||||
track.artwork = dowloadalbum
|
||||
let downloadedArtworkFile: DownloadedFileInfo | undefined
|
||||
if (track.artwork) {
|
||||
downloadedArtworkFile = await downloadJellyfinFile(
|
||||
track.artwork as string,
|
||||
track.item.Id as string,
|
||||
track.title as string,
|
||||
setDownloadProgress,
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
track.url = downloadedTrackFile.uri
|
||||
if (downloadedArtworkFile) track.artwork = downloadedArtworkFile.uri
|
||||
|
||||
const index = existingArray.findIndex((t) => t.item.Id === track.item.Id)
|
||||
|
||||
const downloadEntry: JellifyDownload = {
|
||||
...track,
|
||||
savedAt: new Date().toISOString(),
|
||||
isAutoDownloaded,
|
||||
path: downloadedTrackFile.uri,
|
||||
fileSizeBytes: downloadedTrackFile.size,
|
||||
artworkSizeBytes: downloadedArtworkFile?.size,
|
||||
}
|
||||
|
||||
if (index >= 0) {
|
||||
// Replace existing
|
||||
existingArray[index] = {
|
||||
...track,
|
||||
savedAt: new Date().toISOString(),
|
||||
isAutoDownloaded,
|
||||
path: downloadtrack,
|
||||
}
|
||||
existingArray[index] = downloadEntry
|
||||
} else {
|
||||
// Add new
|
||||
existingArray.push({
|
||||
...track,
|
||||
savedAt: new Date().toISOString(),
|
||||
isAutoDownloaded,
|
||||
path: downloadtrack,
|
||||
})
|
||||
existingArray.push(downloadEntry)
|
||||
}
|
||||
} catch (error) {
|
||||
return false
|
||||
@@ -164,17 +208,8 @@ export const saveAudio = async (
|
||||
}
|
||||
|
||||
export const deleteAudio = async (itemId: string | undefined | null) => {
|
||||
const downloads = getAudioCache()
|
||||
|
||||
const download = downloads.filter((download) => download.item.Id === itemId)
|
||||
|
||||
if (download.length === 1) {
|
||||
RNFS.unlink(`${RNFS.DocumentDirectoryPath}/${download[0].item.Id}`)
|
||||
setAudioCache([
|
||||
...downloads.slice(0, downloads.indexOf(download[0])),
|
||||
...downloads.slice(downloads.indexOf(download[0]) + 1, downloads.length - 1),
|
||||
])
|
||||
}
|
||||
if (!itemId) return
|
||||
await deleteDownloadsByIds([itemId])
|
||||
}
|
||||
|
||||
const setAudioCache = (downloads: JellifyDownload[]) => {
|
||||
@@ -194,8 +229,88 @@ export const getAudioCache = (): JellifyDownload[] => {
|
||||
return existingArray
|
||||
}
|
||||
|
||||
export const deleteAudioCache = async () => {
|
||||
const stripFileScheme = (path: string) => path.replace('file://', '')
|
||||
|
||||
const isLocalFile = (path: string) =>
|
||||
path.startsWith('file://') || path.startsWith(RNFS.DocumentDirectoryPath)
|
||||
|
||||
const deleteLocalFileIfExists = async (
|
||||
path: string | undefined,
|
||||
fallbackSize?: number,
|
||||
): Promise<number> => {
|
||||
if (!path || !isLocalFile(path)) return 0
|
||||
|
||||
const normalizedPath = stripFileScheme(path)
|
||||
try {
|
||||
const exists = await RNFS.exists(normalizedPath)
|
||||
let size = fallbackSize ?? 0
|
||||
if (exists && !fallbackSize) {
|
||||
const stat = await RNFS.stat(normalizedPath)
|
||||
size = Number(stat.size)
|
||||
}
|
||||
if (exists) await RNFS.unlink(normalizedPath)
|
||||
return size
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete file', normalizedPath, error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
const deleteDownloadAssets = async (download: JellifyDownload): Promise<number> => {
|
||||
let freedBytes = 0
|
||||
freedBytes += await deleteLocalFileIfExists(download.path, download.fileSizeBytes)
|
||||
freedBytes += await deleteLocalFileIfExists(download.artwork, download.artworkSizeBytes)
|
||||
return freedBytes
|
||||
}
|
||||
|
||||
export const deleteDownloadsByIds = async (
|
||||
itemIds: (string | null | undefined)[],
|
||||
): Promise<DeleteDownloadsResult> => {
|
||||
const targets = new Set(itemIds.filter(Boolean) as string[])
|
||||
if (targets.size === 0)
|
||||
return {
|
||||
deletedCount: 0,
|
||||
failedCount: 0,
|
||||
freedBytes: 0,
|
||||
}
|
||||
|
||||
const downloads = getAudioCache()
|
||||
const remaining: JellifyDownload[] = []
|
||||
let freedBytes = 0
|
||||
let deletedCount = 0
|
||||
let failedCount = 0
|
||||
|
||||
for (const download of downloads) {
|
||||
if (!targets.has(download.item.Id as string)) {
|
||||
remaining.push(download)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
freedBytes += await deleteDownloadAssets(download)
|
||||
deletedCount += 1
|
||||
} catch (error) {
|
||||
failedCount += 1
|
||||
remaining.push(download)
|
||||
console.error('Failed to delete download', download.item.Id, error)
|
||||
}
|
||||
}
|
||||
|
||||
setAudioCache(remaining)
|
||||
queryClient.invalidateQueries(AUDIO_CACHE_QUERY)
|
||||
|
||||
return {
|
||||
deletedCount,
|
||||
failedCount,
|
||||
freedBytes,
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteAudioCache = async (): Promise<DeleteDownloadsResult> => {
|
||||
const downloads = getAudioCache()
|
||||
const result = await deleteDownloadsByIds(downloads.map((download) => download.item.Id))
|
||||
mmkv.delete(MMKV_OFFLINE_MODE_KEYS.AUDIO_CACHE)
|
||||
return result
|
||||
}
|
||||
|
||||
export const purneAudioCache = async () => {
|
||||
@@ -220,17 +335,7 @@ export const purneAudioCache = async () => {
|
||||
// Remove the oldest `excess` files
|
||||
const itemsToDelete = autoDownloads.slice(0, excess)
|
||||
for (const item of itemsToDelete) {
|
||||
// Delete audio file
|
||||
if (item.url && (await RNFS.exists(item.url))) {
|
||||
await RNFS.unlink(item.url).catch(() => {})
|
||||
}
|
||||
|
||||
// Delete artwork
|
||||
if (item.artwork && (await RNFS.exists(item.artwork))) {
|
||||
await RNFS.unlink(item.artwork).catch(() => {})
|
||||
}
|
||||
|
||||
// Remove from the existingArray
|
||||
await deleteDownloadAssets(item)
|
||||
existingArray = existingArray.filter((i) => i.item.Id !== item.item.Id)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export default async function saveAudioItem(
|
||||
if (downloadedTracks?.filter((download) => download.item.Id === item.Id).length ?? 0 > 0)
|
||||
return Promise.resolve(false)
|
||||
|
||||
const track = mapDtoToTrack(api, item, downloadedTracks ?? [], deviceProfile)
|
||||
const track = mapDtoToTrack(api, item, deviceProfile)
|
||||
|
||||
// TODO: fix download progresses
|
||||
return saveAudio(track, () => {}, autoCached)
|
||||
|
||||
96
src/api/mutations/favorite/index.ts
Normal 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',
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
30
src/api/mutations/home/index.ts
Normal 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
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import JellifyTrack from '../../../types/JellifyTrack'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import reportPlaybackCompleted from './functions/playback-completed'
|
||||
@@ -6,19 +5,20 @@ import reportPlaybackStopped from './functions/playback-stopped'
|
||||
import isPlaybackFinished from './utils'
|
||||
import reportPlaybackProgress from './functions/playback-progress'
|
||||
import reportPlaybackStarted from './functions/playback-started'
|
||||
import { useApi } from '../../../stores'
|
||||
|
||||
interface PlaybackStartedMutation {
|
||||
track: JellifyTrack
|
||||
}
|
||||
|
||||
export const useReportPlaybackStarted = () => {
|
||||
const { api } = useJellifyContext()
|
||||
const api = useApi()
|
||||
|
||||
return useMutation({
|
||||
onMutate: () => {},
|
||||
mutationFn: async ({ track }: PlaybackStartedMutation) => reportPlaybackStarted(api, track),
|
||||
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 = () => {
|
||||
const { api } = useJellifyContext()
|
||||
const api = useApi()
|
||||
|
||||
return useMutation({
|
||||
onMutate: ({ lastPosition, duration }) =>
|
||||
console.debug(
|
||||
`Reporting playback ${isPlaybackFinished(lastPosition, duration) ? 'completed' : 'stopped'} for track`,
|
||||
),
|
||||
onMutate: ({ lastPosition, duration }) => {},
|
||||
mutationFn: async ({ track, lastPosition, duration }: PlaybackStoppedMutation) => {
|
||||
return isPlaybackFinished(lastPosition, duration)
|
||||
? await reportPlaybackCompleted(api, track)
|
||||
@@ -46,10 +43,7 @@ export const useReportPlaybackStopped = () => {
|
||||
`Reporting playback ${isPlaybackFinished(lastPosition, duration) ? 'completed' : 'stopped'} failed`,
|
||||
error,
|
||||
),
|
||||
onSuccess: (_, { lastPosition, duration }) =>
|
||||
console.debug(
|
||||
`Reported playback ${isPlaybackFinished(lastPosition, duration) ? 'completed' : 'stopped'} successfully`,
|
||||
),
|
||||
onSuccess: (_, { lastPosition, duration }) => {},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,10 +53,10 @@ interface PlaybackProgressMutation {
|
||||
}
|
||||
|
||||
export const useReportPlaybackProgress = () => {
|
||||
const { api } = useJellifyContext()
|
||||
const api = useApi()
|
||||
|
||||
return useMutation({
|
||||
onMutate: ({ position }) => console.debug(`Reporting progress at ${position}`),
|
||||
onMutate: ({ position }) => {},
|
||||
mutationFn: async ({ track, position }: PlaybackProgressMutation) =>
|
||||
reportPlaybackProgress(api, track, position),
|
||||
})
|
||||
|
||||
@@ -19,17 +19,11 @@ export async function addToPlaylist(
|
||||
track: BaseItemDto,
|
||||
playlist: BaseItemDto,
|
||||
): Promise<void> {
|
||||
console.debug('Adding track to playlist')
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (isUndefined(api)) return reject(new Error('No API client available'))
|
||||
|
||||
if (isUndefined(user)) return reject(new Error('No user available'))
|
||||
|
||||
console.debug(api)
|
||||
|
||||
console.debug(api.axiosInstance)
|
||||
|
||||
getPlaylistsApi(api)
|
||||
.addItemToPlaylist(
|
||||
{
|
||||
@@ -65,8 +59,6 @@ export async function addManyToPlaylist(
|
||||
tracks: BaseItemDto[],
|
||||
playlist: BaseItemDto,
|
||||
): Promise<void> {
|
||||
console.debug(`Adding ${tracks.length} tracks to playlist`)
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (isUndefined(api)) return reject(new Error('No API client available'))
|
||||
|
||||
@@ -110,8 +102,6 @@ export async function removeFromPlaylist(
|
||||
track: BaseItemDto,
|
||||
playlist: BaseItemDto,
|
||||
) {
|
||||
console.debug('Removing track from playlist')
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (isUndefined(api)) return reject(new Error('No API client available'))
|
||||
|
||||
@@ -145,8 +135,6 @@ export async function reorderPlaylist(
|
||||
itemId: string,
|
||||
to: number,
|
||||
) {
|
||||
console.debug(`Moving track to index ${to}`)
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (isUndefined(api)) return reject(new Error('No API client available'))
|
||||
|
||||
@@ -179,8 +167,6 @@ export async function createPlaylist(
|
||||
user: JellifyUser | undefined,
|
||||
name: string,
|
||||
) {
|
||||
console.debug('Creating new playlist...')
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (isUndefined(api)) return reject(new Error('No API client available'))
|
||||
|
||||
@@ -214,8 +200,6 @@ export async function createPlaylist(
|
||||
* @returns
|
||||
*/
|
||||
export async function deletePlaylist(api: Api | undefined, playlistId: string) {
|
||||
console.debug('Deleting playlist...')
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (isUndefined(api)) return reject(new Error('No API client available'))
|
||||
|
||||
@@ -248,8 +232,7 @@ export async function updatePlaylist(
|
||||
name: string,
|
||||
trackIds: string[],
|
||||
) {
|
||||
console.debug('Updating playlist')
|
||||
|
||||
console.info('Updating playlist with name:', name, 'and track IDs:', trackIds)
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (isUndefined(api)) return reject(new Error('No API client available'))
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { connectToServer } from './utils'
|
||||
import { JellifyServer } from '@/src/types/JellifyServer'
|
||||
import serverAddressContainsProtocol from './utils/parsing'
|
||||
import HTTPS, { HTTP } from '../../../constants/protocols'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import useJellifyStore from '../../../stores'
|
||||
|
||||
interface PublicSystemInfoMutation {
|
||||
serverAddress: string
|
||||
@@ -16,14 +16,12 @@ interface PublicSystemInfoHook {
|
||||
}
|
||||
|
||||
const usePublicSystemInfo = ({ onSuccess, onError }: PublicSystemInfoHook) => {
|
||||
const { setServer } = useJellifyContext()
|
||||
const setServer = useJellifyStore((state) => state.setServer)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ serverAddress, useHttps }: PublicSystemInfoMutation) =>
|
||||
connectToServer(serverAddress!, useHttps),
|
||||
onSuccess: ({ publicSystemInfoResponse, connectionType }, { serverAddress, useHttps }) => {
|
||||
console.debug(`Got public system info response`)
|
||||
|
||||
if (!publicSystemInfoResponse.Version)
|
||||
throw new Error(`Jellyfin instance did not respond`)
|
||||
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import useStreamingDeviceProfile from '../../../stores/device-profile'
|
||||
import { Api } from '@jellyfin/sdk'
|
||||
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api'
|
||||
import { MONOCHROME_ICON_URL } from '../../../configs/config'
|
||||
import { useEffect } from 'react'
|
||||
import { useApi } from '../../../stores'
|
||||
|
||||
const usePostFullCapabilities = () => {
|
||||
const api = useApi()
|
||||
const streamingDeviceProfile = useStreamingDeviceProfile()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (api: Api | undefined) => {
|
||||
const { mutate } = useMutation({
|
||||
onMutate: () => {},
|
||||
mutationFn: async () => {
|
||||
if (!api) return
|
||||
|
||||
return getSessionApi(api).postFullCapabilities({
|
||||
return await getSessionApi(api).postFullCapabilities({
|
||||
clientCapabilitiesDto: {
|
||||
IconUrl: MONOCHROME_ICON_URL,
|
||||
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
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useLibrarySortAndFilterContext } from '../../../providers/Library'
|
||||
import { QueryKeys } from '../../../enums/query-keys'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import { InfiniteData, useInfiniteQuery, UseInfiniteQueryResult } from '@tanstack/react-query'
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by'
|
||||
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 { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
|
||||
import flattenInfiniteQueryPages from '../../../utils/query-selectors'
|
||||
import { ApiLimits } from '../query.config'
|
||||
import { ApiLimits, MaxPages } from '../../../configs/query.config'
|
||||
import { fetchRecentlyAdded } from '../recents/utils'
|
||||
import { queryClient } from '../../../constants/query-client'
|
||||
import { useApi, useJellifyLibrary, useJellifyUser } from '../../../stores'
|
||||
import useLibraryStore from '../../../stores/library'
|
||||
|
||||
const useAlbums: () => [
|
||||
RefObject<Set<string>>,
|
||||
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>())
|
||||
|
||||
@@ -43,10 +45,11 @@ const useAlbums: () => [
|
||||
),
|
||||
initialPageParam: 0,
|
||||
select: selectAlbums,
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
|
||||
maxPages: MaxPages.Library,
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam) => {
|
||||
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
|
||||
},
|
||||
getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => {
|
||||
getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
|
||||
return firstPageParam === 0 ? null : firstPageParam - 1
|
||||
},
|
||||
})
|
||||
@@ -57,20 +60,21 @@ const useAlbums: () => [
|
||||
export default useAlbums
|
||||
|
||||
export const useRecentlyAddedAlbums = () => {
|
||||
const { api, user, library } = useJellifyContext()
|
||||
const api = useApi()
|
||||
const [library] = useJellifyLibrary()
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: [QueryKeys.RecentlyAddedAlbums, library?.musicLibraryId],
|
||||
queryFn: ({ pageParam }) => fetchRecentlyAdded(api, library, pageParam),
|
||||
select: (data) => data.pages.flatMap((page) => page),
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam) =>
|
||||
lastPage.length > 0 ? lastPageParam + 1 : undefined,
|
||||
initialPageParam: 0,
|
||||
})
|
||||
}
|
||||
|
||||
export const useRefetchRecentlyAdded: () => () => void = () => {
|
||||
const { library } = useJellifyContext()
|
||||
const [library] = useJellifyLibrary()
|
||||
|
||||
return () =>
|
||||
queryClient.invalidateQueries({
|
||||
|
||||
@@ -10,7 +10,8 @@ import { Api } from '@jellyfin/sdk'
|
||||
import { fetchItem, fetchItems } from '../../item'
|
||||
import { JellifyUser } from '../../../../types/JellifyUser'
|
||||
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
|
||||
import { ApiLimits } from '../../query.config'
|
||||
import { ApiLimits } from '../../../../configs/query.config'
|
||||
import { nitroFetch } from '../../../utils/nitro'
|
||||
export function fetchAlbums(
|
||||
api: Api | undefined,
|
||||
user: JellifyUser | undefined,
|
||||
@@ -20,31 +21,26 @@ export function fetchAlbums(
|
||||
sortBy: ItemSortBy[] = [ItemSortBy.SortName],
|
||||
sortOrder: SortOrder[] = [SortOrder.Ascending],
|
||||
): Promise<BaseItemDto[]> {
|
||||
console.debug('Fetching albums', page)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!api) return reject('No API instance provided')
|
||||
if (!user) return reject('No user provided')
|
||||
if (!library) return reject('Library has not been set')
|
||||
|
||||
getItemsApi(api)
|
||||
.getItems({
|
||||
parentId: library.musicLibraryId,
|
||||
includeItemTypes: [BaseItemKind.MusicAlbum],
|
||||
userId: user.id,
|
||||
enableUserData: true, // This will populate the user data query later down the line
|
||||
sortBy,
|
||||
sortOrder,
|
||||
startIndex: page * ApiLimits.Library,
|
||||
limit: ApiLimits.Library,
|
||||
isFavorite,
|
||||
fields: [ItemFields.SortName],
|
||||
recursive: true,
|
||||
})
|
||||
.then(({ data }) => {
|
||||
console.debug('Albums Response receieved')
|
||||
return data.Items ? resolve(data.Items) : resolve([])
|
||||
})
|
||||
nitroFetch<{ Items: BaseItemDto[] }>(api, '/Items', {
|
||||
ParentId: library.musicLibraryId,
|
||||
IncludeItemTypes: [BaseItemKind.MusicAlbum],
|
||||
UserId: user.id,
|
||||
EnableUserData: true, // This will populate the user data query later down the line
|
||||
SortBy: sortBy,
|
||||
SortOrder: sortOrder,
|
||||
StartIndex: page * ApiLimits.Library,
|
||||
Limit: ApiLimits.Library,
|
||||
IsFavorite: isFavorite,
|
||||
Fields: [ItemFields.SortName],
|
||||
Recursive: true,
|
||||
}).then((data) => {
|
||||
return data.Items ? resolve(data.Items) : resolve([])
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,16 +6,17 @@ import {
|
||||
UseInfiniteQueryResult,
|
||||
useQuery,
|
||||
} from '@tanstack/react-query'
|
||||
import { isString, isUndefined } from 'lodash'
|
||||
import { isUndefined } from 'lodash'
|
||||
import { fetchArtistAlbums, fetchArtistFeaturedOn, fetchArtists } from './utils/artist'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import { ApiLimits } from '../query.config'
|
||||
import { ApiLimits, MaxPages } from '../../../configs/query.config'
|
||||
import { RefObject, useCallback, useRef } from 'react'
|
||||
import { useLibrarySortAndFilterContext } from '../../../providers/Library'
|
||||
import flattenInfiniteQueryPages from '../../../utils/query-selectors'
|
||||
import { useApi, useJellifyLibrary, useJellifyUser } from '../../../stores'
|
||||
import useLibraryStore from '../../../stores/library'
|
||||
|
||||
export const useArtistAlbums = (artist: BaseItemDto) => {
|
||||
const { api, library } = useJellifyContext()
|
||||
const api = useApi()
|
||||
const [library] = useJellifyLibrary()
|
||||
|
||||
return useQuery({
|
||||
queryKey: [QueryKeys.ArtistAlbums, library?.musicLibraryId, artist.Id],
|
||||
@@ -25,7 +26,8 @@ export const useArtistAlbums = (artist: BaseItemDto) => {
|
||||
}
|
||||
|
||||
export const useArtistFeaturedOn = (artist: BaseItemDto) => {
|
||||
const { api, library } = useJellifyContext()
|
||||
const api = useApi()
|
||||
const [library] = useJellifyLibrary()
|
||||
|
||||
return useQuery({
|
||||
queryKey: [QueryKeys.ArtistFeaturedOn, library?.musicLibraryId, artist.Id],
|
||||
@@ -38,9 +40,11 @@ export const useAlbumArtists: () => [
|
||||
RefObject<Set<string>>,
|
||||
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>())
|
||||
|
||||
@@ -64,6 +68,7 @@ export const useAlbumArtists: () => [
|
||||
[sortDescending ? SortOrder.Descending : SortOrder.Ascending],
|
||||
),
|
||||
select: selectArtists,
|
||||
maxPages: MaxPages.Library,
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
|
||||
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
} from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { getArtistsApi, getItemsApi } from '@jellyfin/sdk/lib/utils/api'
|
||||
import { JellifyUser } from '../../../../types/JellifyUser'
|
||||
import { ApiLimits } from '../../query.config'
|
||||
import { ApiLimits } from '../../../../configs/query.config'
|
||||
import { nitroFetch } from '../../../utils/nitro'
|
||||
|
||||
export function fetchArtists(
|
||||
api: Api | undefined,
|
||||
@@ -20,28 +21,24 @@ export function fetchArtists(
|
||||
sortBy: ItemSortBy[] = [ItemSortBy.SortName],
|
||||
sortOrder: SortOrder[] = [SortOrder.Ascending],
|
||||
): Promise<BaseItemDto[]> {
|
||||
console.debug('Fetching artists', page)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!api) return reject('No API instance provided')
|
||||
if (!user) return reject('No user provided')
|
||||
if (!library) return reject('Library has not been set')
|
||||
|
||||
getArtistsApi(api)
|
||||
.getAlbumArtists({
|
||||
parentId: library.musicLibraryId,
|
||||
userId: user.id,
|
||||
enableUserData: true, // This will populate the User Data query later down the line
|
||||
sortBy: sortBy,
|
||||
sortOrder: sortOrder,
|
||||
startIndex: page * ApiLimits.Library,
|
||||
limit: ApiLimits.Library,
|
||||
isFavorite: isFavorite,
|
||||
fields: [ItemFields.SortName, ItemFields.Genres],
|
||||
})
|
||||
.then((response) => {
|
||||
console.debug('Artists Response received')
|
||||
return response.data.Items ? resolve(response.data.Items) : resolve([])
|
||||
nitroFetch<{ Items: BaseItemDto[] }>(api, '/Artists/AlbumArtists', {
|
||||
ParentId: library.musicLibraryId,
|
||||
UserId: user.id,
|
||||
EnableUserData: true,
|
||||
SortBy: sortBy,
|
||||
SortOrder: sortOrder,
|
||||
StartIndex: page * ApiLimits.Library,
|
||||
Limit: ApiLimits.Library,
|
||||
IsFavorite: isFavorite,
|
||||
Fields: [ItemFields.SortName, ItemFields.Genres],
|
||||
})
|
||||
.then((data) => {
|
||||
return data.Items ? resolve(data.Items) : resolve([])
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error)
|
||||
@@ -60,25 +57,22 @@ export function fetchArtistAlbums(
|
||||
libraryId: string | undefined,
|
||||
artist: BaseItemDto,
|
||||
): Promise<BaseItemDto[]> {
|
||||
console.debug('Fetching artist albums')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!api) return reject('No API instance provided')
|
||||
if (!libraryId) return reject('Library has not been set')
|
||||
|
||||
getItemsApi(api!)
|
||||
.getItems({
|
||||
parentId: libraryId,
|
||||
includeItemTypes: [BaseItemKind.MusicAlbum],
|
||||
recursive: true,
|
||||
excludeItemIds: [artist.Id!],
|
||||
sortBy: [ItemSortBy.PremiereDate, ItemSortBy.ProductionYear, ItemSortBy.SortName],
|
||||
sortOrder: [SortOrder.Descending],
|
||||
albumArtistIds: [artist.Id!],
|
||||
fields: [ItemFields.ChildCount],
|
||||
})
|
||||
.then((response) => {
|
||||
return response.data.Items ? resolve(response.data.Items) : resolve([])
|
||||
nitroFetch<{ Items: BaseItemDto[] }>(api!, '/Items', {
|
||||
ParentId: libraryId,
|
||||
IncludeItemTypes: [BaseItemKind.MusicAlbum],
|
||||
Recursive: true,
|
||||
ExcludeItemIds: [artist.Id!],
|
||||
SortBy: [ItemSortBy.PremiereDate, ItemSortBy.ProductionYear, ItemSortBy.SortName],
|
||||
SortOrder: [SortOrder.Descending],
|
||||
AlbumArtistIds: [artist.Id!],
|
||||
Fields: [ItemFields.ChildCount],
|
||||
})
|
||||
.then((data) => {
|
||||
return data.Items ? resolve(data.Items) : resolve([])
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error)
|
||||
@@ -97,24 +91,21 @@ export function fetchArtistFeaturedOn(
|
||||
libraryId: string | undefined,
|
||||
artist: BaseItemDto,
|
||||
): Promise<BaseItemDto[]> {
|
||||
console.debug('Fetching artist featured on')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!api) return reject('No API instance provided')
|
||||
if (!libraryId) return reject('Library has not been set')
|
||||
|
||||
getItemsApi(api)
|
||||
.getItems({
|
||||
parentId: libraryId,
|
||||
includeItemTypes: [BaseItemKind.MusicAlbum],
|
||||
recursive: true,
|
||||
excludeItemIds: [artist.Id!],
|
||||
sortBy: [ItemSortBy.PremiereDate, ItemSortBy.ProductionYear, ItemSortBy.SortName],
|
||||
sortOrder: [SortOrder.Descending],
|
||||
contributingArtistIds: [artist.Id!],
|
||||
})
|
||||
.then((response) => {
|
||||
return response.data.Items ? resolve(response.data.Items) : resolve([])
|
||||
nitroFetch<{ Items: BaseItemDto[] }>(api, '/Items', {
|
||||
ParentId: libraryId,
|
||||
IncludeItemTypes: [BaseItemKind.MusicAlbum],
|
||||
Recursive: true,
|
||||
ExcludeItemIds: [artist.Id!],
|
||||
SortBy: [ItemSortBy.PremiereDate, ItemSortBy.ProductionYear, ItemSortBy.SortName],
|
||||
SortOrder: [SortOrder.Descending],
|
||||
ContributingArtistIds: [artist.Id!],
|
||||
})
|
||||
.then((data) => {
|
||||
return data.Items ? resolve(data.Items) : resolve([])
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import RNFS from 'react-native-fs'
|
||||
import DeviceInfo from 'react-native-device-info'
|
||||
|
||||
type JellifyStorage = {
|
||||
totalStorage: number
|
||||
@@ -9,10 +10,11 @@ type JellifyStorage = {
|
||||
const fetchStorageInUse: () => Promise<JellifyStorage> = async () => {
|
||||
const totalStorage = await RNFS.getFSInfo()
|
||||
const storageInUse = await RNFS.stat(RNFS.DocumentDirectoryPath)
|
||||
const freeDiskStorage = await DeviceInfo.getFreeDiskStorage()
|
||||
|
||||
return {
|
||||
totalStorage: totalStorage.totalSpace,
|
||||
freeSpace: totalStorage.freeSpace,
|
||||
freeSpace: freeDiskStorage,
|
||||
storageInUseByJellify: storageInUse.size,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,6 @@ export async function fetchFavoriteArtists(
|
||||
user: JellifyUser | undefined,
|
||||
library: JellifyLibrary | undefined,
|
||||
): Promise<BaseItemDto[]> {
|
||||
console.debug(`Fetching user's favorite artists`)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isUndefined(api)) return reject('Client instance not set')
|
||||
if (isUndefined(user)) return reject('User instance not set')
|
||||
@@ -39,8 +37,6 @@ export async function fetchFavoriteArtists(
|
||||
sortOrder: [SortOrder.Ascending],
|
||||
})
|
||||
.then((response) => {
|
||||
console.debug(`Received favorite artist response`, response)
|
||||
|
||||
if (response.data.Items) return resolve(response.data.Items)
|
||||
else return resolve([])
|
||||
})
|
||||
@@ -63,8 +59,6 @@ export async function fetchFavoriteAlbums(
|
||||
user: JellifyUser | undefined,
|
||||
library: JellifyLibrary | undefined,
|
||||
): Promise<BaseItemDto[]> {
|
||||
console.debug(`Fetching user's favorite albums`)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isUndefined(api)) return reject('Client 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],
|
||||
})
|
||||
.then((response) => {
|
||||
console.debug(`Received favorite album response`, response)
|
||||
|
||||
if (response.data.Items) return resolve(response.data.Items)
|
||||
else return resolve([])
|
||||
})
|
||||
@@ -104,8 +96,6 @@ export async function fetchFavoritePlaylists(
|
||||
user: JellifyUser | undefined,
|
||||
library: JellifyLibrary | undefined,
|
||||
): Promise<BaseItemDto[]> {
|
||||
console.debug(`Fetching user's favorite playlists`)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isUndefined(api)) return reject('Client instance not set')
|
||||
if (isUndefined(user)) return reject('User instance not set')
|
||||
@@ -120,7 +110,6 @@ export async function fetchFavoritePlaylists(
|
||||
sortOrder: [SortOrder.Ascending],
|
||||
})
|
||||
.then((response) => {
|
||||
console.log(response)
|
||||
if (response.data.Items)
|
||||
return resolve(
|
||||
response.data.Items.filter(
|
||||
@@ -149,8 +138,6 @@ export async function fetchFavoriteTracks(
|
||||
user: JellifyUser | undefined,
|
||||
library: JellifyLibrary | undefined,
|
||||
): Promise<BaseItemDto[]> {
|
||||
console.debug(`Fetching user's favorite tracks`)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isUndefined(api)) return reject('Client instance not set')
|
||||
if (isUndefined(user)) return reject('User instance not set')
|
||||
@@ -166,8 +153,6 @@ export async function fetchFavoriteTracks(
|
||||
sortOrder: [SortOrder.Ascending],
|
||||
})
|
||||
.then((response) => {
|
||||
console.debug(`Received favorite artist response`, response)
|
||||
|
||||
if (response.data.Items) return resolve(response.data.Items)
|
||||
else return resolve([])
|
||||
})
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { FrequentlyPlayedArtistsQueryKey, FrequentlyPlayedTracksQueryKey } from './keys'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import { fetchFrequentlyPlayed, fetchFrequentlyPlayedArtists } from './utils/frequents'
|
||||
import { ApiLimits } from '../query.config'
|
||||
import { ApiLimits, MaxPages } from '../../../configs/query.config'
|
||||
import { isUndefined } from 'lodash'
|
||||
import { useApi, useJellifyLibrary, useJellifyUser } from '../../../stores'
|
||||
|
||||
const FREQUENTS_QUERY_CONFIG = {
|
||||
maxPages: MaxPages.Home,
|
||||
refetchOnMount: false,
|
||||
staleTime: Infinity,
|
||||
} as const
|
||||
|
||||
export const useFrequentlyPlayedTracks = () => {
|
||||
const { api, user, library } = useJellifyContext()
|
||||
const api = useApi()
|
||||
const [user] = useJellifyUser()
|
||||
const [library] = useJellifyLibrary()
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: FrequentlyPlayedTracksQueryKey(user, library),
|
||||
@@ -19,7 +21,6 @@ export const useFrequentlyPlayedTracks = () => {
|
||||
select: (data) => data.pages.flatMap((page) => page),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
|
||||
console.debug('Getting next page for frequently played')
|
||||
return lastPage.length === ApiLimits.Home ? lastPageParam + 1 : undefined
|
||||
},
|
||||
...FREQUENTS_QUERY_CONFIG,
|
||||
@@ -27,7 +28,9 @@ export const useFrequentlyPlayedTracks = () => {
|
||||
}
|
||||
|
||||
export const useFrequentlyPlayedArtists = () => {
|
||||
const { api, user, library } = useJellifyContext()
|
||||
const api = useApi()
|
||||
const [user] = useJellifyUser()
|
||||
const [library] = useJellifyLibrary()
|
||||
|
||||
const { data: frequentlyPlayedTracks } = useFrequentlyPlayedTracks()
|
||||
|
||||
@@ -37,7 +40,6 @@ export const useFrequentlyPlayedArtists = () => {
|
||||
select: (data) => data.pages.flatMap((page) => page),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
|
||||
console.debug('Getting next page for frequent artists')
|
||||
return lastPage.length > 0 ? lastPageParam + 1 : undefined
|
||||
},
|
||||
enabled: !isUndefined(frequentlyPlayedTracks),
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Api } from '@jellyfin/sdk'
|
||||
import { isEmpty, isNull, isUndefined } from 'lodash'
|
||||
import { JellifyLibrary } from '../../../../types/JellifyLibrary'
|
||||
import { fetchItem } from '../../item'
|
||||
import { ApiLimits } from '../../query.config'
|
||||
import { ApiLimits } from '../../../../configs/query.config'
|
||||
import { JellifyUser } from '@/src/types/JellifyUser'
|
||||
import { queryClient } from '../../../../constants/query-client'
|
||||
import { InfiniteData } from '@tanstack/react-query'
|
||||
@@ -66,11 +66,7 @@ export function fetchFrequentlyPlayedArtists(
|
||||
library: JellifyLibrary | undefined,
|
||||
page: number,
|
||||
): Promise<BaseItemDto[]> {
|
||||
console.debug('Fetching frequently played artists', page)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
console.debug('Fetching frequently played artists')
|
||||
|
||||
if (isUndefined(api)) return reject('Client instance not set')
|
||||
if (isUndefined(library)) return reject('Library instance not set')
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -2,22 +2,63 @@ import { Api } from '@jellyfin/sdk'
|
||||
import { BaseItemDto, ImageType } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
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(
|
||||
api: Api | undefined,
|
||||
item: BaseItemDto,
|
||||
type: ImageType,
|
||||
options?: ImageUrlOptions,
|
||||
): string | undefined {
|
||||
const { AlbumId, AlbumPrimaryImageTag, ImageTags, Id } = item
|
||||
const { AlbumId, AlbumPrimaryImageTag, ImageTags, Id, AlbumArtists } = item
|
||||
|
||||
if (!api) return undefined
|
||||
|
||||
return AlbumId
|
||||
? getImageApi(api).getItemImageUrlById(AlbumId, type, {
|
||||
tag: AlbumPrimaryImageTag ?? undefined,
|
||||
})
|
||||
: Id
|
||||
? getImageApi(api).getItemImageUrlById(Id, type, {
|
||||
tag: ImageTags ? ImageTags[type] : undefined,
|
||||
})
|
||||
: undefined
|
||||
// Use provided dimensions or default thumbnail size for list performance
|
||||
const imageParams = {
|
||||
tag: undefined as string | undefined,
|
||||
maxWidth: options?.maxWidth ?? DEFAULT_THUMBNAIL_SIZE,
|
||||
maxHeight: options?.maxHeight ?? DEFAULT_THUMBNAIL_SIZE,
|
||||
quality: options?.quality ?? 90,
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { getInstantMixApi } from '@jellyfin/sdk/lib/utils/api'
|
||||
import { isUndefined } from 'lodash'
|
||||
import QueryConfig from './query.config'
|
||||
import QueryConfig from '../../configs/query.config'
|
||||
import { Api } from '@jellyfin/sdk'
|
||||
import { JellifyUser } from '../../types/JellifyUser'
|
||||
/**
|
||||
@@ -16,8 +16,6 @@ export function fetchInstantMixFromItem(
|
||||
user: JellifyUser | undefined,
|
||||
item: BaseItemDto,
|
||||
): Promise<BaseItemDto[]> {
|
||||
console.debug('Fetching instant mix from item')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isUndefined(api)) return reject(new Error('Client not initialized'))
|
||||
if (isUndefined(user)) return reject(new Error('User not initialized'))
|
||||
|
||||
@@ -10,8 +10,9 @@ import { groupBy, isEmpty, isEqual, isUndefined } from 'lodash'
|
||||
import { SectionList } from 'react-native'
|
||||
import { Api } from '@jellyfin/sdk/lib/api'
|
||||
import { JellifyLibrary } from '../../types/JellifyLibrary'
|
||||
import QueryConfig from './query.config'
|
||||
import QueryConfig from '../../configs/query.config'
|
||||
import { JellifyUser } from '../../types/JellifyUser'
|
||||
import { nitroFetch } from '../utils/nitro'
|
||||
|
||||
/**
|
||||
* Fetches a single Jellyfin item by it's ID
|
||||
@@ -19,7 +20,6 @@ import { JellifyUser } from '../../types/JellifyUser'
|
||||
* @returns The item - a {@link BaseItemDto}
|
||||
*/
|
||||
export async function fetchItem(api: Api | undefined, itemId: string): Promise<BaseItemDto> {
|
||||
console.debug('Fetching item by id')
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isEmpty(itemId)) return reject('No item ID proviced')
|
||||
if (isUndefined(api)) return reject('Client not initialized')
|
||||
@@ -63,27 +63,25 @@ export async function fetchItems(
|
||||
parentId?: string | undefined,
|
||||
ids?: string[] | undefined,
|
||||
): Promise<{ title: string | number; data: BaseItemDto[] }> {
|
||||
console.debug('Fetching items', page)
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isUndefined(api)) return reject('Client not initialized')
|
||||
if (isUndefined(user)) return reject('User not initialized')
|
||||
if (isUndefined(library)) return reject('Library not initialized')
|
||||
|
||||
getItemsApi(api)
|
||||
.getItems({
|
||||
parentId: parentId ?? library.musicLibraryId,
|
||||
userId: user.id,
|
||||
includeItemTypes: types,
|
||||
sortBy,
|
||||
recursive: true,
|
||||
sortOrder,
|
||||
fields: [ItemFields.ChildCount, ItemFields.SortName, ItemFields.Genres],
|
||||
startIndex: typeof page === 'number' ? page * QueryConfig.limits.library : 0,
|
||||
limit: QueryConfig.limits.library,
|
||||
isFavorite,
|
||||
ids,
|
||||
})
|
||||
.then(({ data }) => {
|
||||
nitroFetch<{ Items: BaseItemDto[] }>(api, '/Items', {
|
||||
ParentId: parentId ?? library.musicLibraryId,
|
||||
UserId: user.id,
|
||||
IncludeItemTypes: types,
|
||||
SortBy: sortBy,
|
||||
Recursive: true,
|
||||
SortOrder: sortOrder,
|
||||
Fields: [ItemFields.ChildCount, ItemFields.SortName, ItemFields.Genres],
|
||||
StartIndex: typeof page === 'number' ? page * QueryConfig.limits.library : 0,
|
||||
Limit: QueryConfig.limits.library,
|
||||
IsFavorite: isFavorite,
|
||||
Ids: ids,
|
||||
})
|
||||
.then((data) => {
|
||||
resolve({ title: page, data: data.Items ?? [] })
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -102,7 +100,6 @@ export async function fetchAlbumDiscs(
|
||||
api: Api | undefined,
|
||||
album: BaseItemDto,
|
||||
): Promise<{ title: string; data: BaseItemDto[] }[]> {
|
||||
console.debug('Fetching album discs')
|
||||
return new Promise<{ title: string; data: BaseItemDto[] }[]>((resolve, reject) => {
|
||||
if (isEmpty(album.Id)) return reject('No album ID provided')
|
||||
if (isUndefined(api)) return reject('Client not initialized')
|
||||
@@ -111,12 +108,11 @@ export async function fetchAlbumDiscs(
|
||||
|
||||
sortBy = [ItemSortBy.ParentIndexNumber, ItemSortBy.IndexNumber, ItemSortBy.SortName]
|
||||
|
||||
getItemsApi(api)
|
||||
.getItems({
|
||||
parentId: album.Id!,
|
||||
sortBy,
|
||||
})
|
||||
.then(({ data }) => {
|
||||
nitroFetch<{ Items: BaseItemDto[] }>(api, '/Items', {
|
||||
ParentId: album.Id!,
|
||||
SortBy: sortBy,
|
||||
})
|
||||
.then((data) => {
|
||||
const discs = data.Items
|
||||
? Object.keys(groupBy(data.Items, (track) => track.ParentIndexNumber)).map(
|
||||
(discNumber) => {
|
||||
|
||||
@@ -6,8 +6,6 @@ import { Api } from '@jellyfin/sdk'
|
||||
import { JellifyUser } from '../../types/JellifyUser'
|
||||
|
||||
export async function fetchMusicLibraries(api: Api | undefined): Promise<BaseItemDto[] | void> {
|
||||
console.debug('Fetching music libraries from Jellyfin')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
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> {
|
||||
console.debug('Fetching playlist library from Jellyfin')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isUndefined(api)) return reject('Client instance not set')
|
||||
|
||||
@@ -57,8 +53,6 @@ export async function fetchUserViews(
|
||||
api: Api | undefined,
|
||||
user: JellifyUser | undefined,
|
||||
): Promise<BaseItemDto[] | void> {
|
||||
console.debug('Fetching user views')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isUndefined(api)) return reject('Client instance not set')
|
||||
if (isUndefined(user)) return reject('User instance not set')
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useNowPlaying } from '../../../providers/Player/hooks/queries'
|
||||
import { useQuery, UseQueryResult } from '@tanstack/react-query'
|
||||
import LyricsQueryKey from './keys'
|
||||
import { isUndefined } from 'lodash'
|
||||
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}
|
||||
@@ -11,8 +11,8 @@ import { useJellifyContext } from '../../../providers'
|
||||
* @returns a {@link UseQueryResult} for the
|
||||
*/
|
||||
const useRawLyrics = () => {
|
||||
const { api } = useJellifyContext()
|
||||
const { data: nowPlaying } = useNowPlaying()
|
||||
const api = useApi()
|
||||
const nowPlaying = useCurrentTrack()
|
||||
|
||||
return useQuery({
|
||||
queryKey: LyricsQueryKey(nowPlaying),
|
||||
|
||||
@@ -19,15 +19,11 @@ export async function fetchRawLyrics(
|
||||
if (isUndefined(api)) throw new Error('Client not initialized')
|
||||
if (isEmpty(itemId)) throw new Error('No item ID provided')
|
||||
|
||||
console.log('itemId', itemId)
|
||||
|
||||
try {
|
||||
// Jellyfin LyricsApi returns plain text (often LRC) for the given item
|
||||
// SDK: LyricsApi.getLyrics({ itemId })
|
||||
const lyricsApi: LyricsApi = getLyricsApi(api)
|
||||
console.log('lyricsApi', lyricsApi)
|
||||
const { data } = await lyricsApi.getLyrics({ itemId })
|
||||
console.log('data', data)
|
||||
|
||||
// Some SDK versions may wrap text; defensively unwrap
|
||||
return data.Lyrics
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Api } from '@jellyfin/sdk'
|
||||
import { useJellifyContext } from '../../../../src/providers'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { JellifyUser } from '@/src/types/JellifyUser'
|
||||
import useStreamingDeviceProfile, {
|
||||
useDownloadingDeviceProfile,
|
||||
} from '../../../stores/device-profile'
|
||||
import { fetchMediaInfo } from './utils'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
|
||||
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
|
||||
@@ -16,22 +16,24 @@ import MediaInfoQueryKey from './keys'
|
||||
* Depends on the {@link useStreamingDeviceProfile} hook for retrieving
|
||||
* the currently configured device profile
|
||||
*
|
||||
* Depends on the {@link useJellifyContext} hook for retrieving
|
||||
* the currently configured {@link Api} and {@link JellifyUser}
|
||||
* Depends on the {@link useApi} hook for retrieving
|
||||
* the currently configured {@link Api}
|
||||
* instance
|
||||
*
|
||||
* @param itemId The Id of the {@link BaseItemDto}
|
||||
* @returns
|
||||
*/
|
||||
const useStreamedMediaInfo = (itemId: string | null | undefined) => {
|
||||
const { api } = useJellifyContext()
|
||||
const api = useApi()
|
||||
|
||||
const deviceProfile = useStreamingDeviceProfile()
|
||||
|
||||
return useQuery({
|
||||
queryKey: MediaInfoQueryKey({ 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
|
||||
* the currently configured device profile
|
||||
*
|
||||
* Depends on the {@link useJellifyContext} hook for retrieving
|
||||
* the currently configured {@link Api} and {@link JellifyUser}
|
||||
* Depends on the {@link useApi} hook for retrieving
|
||||
* the currently configured {@link Api}
|
||||
* instance
|
||||
*
|
||||
* @param itemId The Id of the {@link BaseItemDto}
|
||||
* @returns
|
||||
*/
|
||||
export const useDownloadedMediaInfo = (itemId: string | null | undefined) => {
|
||||
const { api } = useJellifyContext()
|
||||
const api = useApi()
|
||||
|
||||
const deviceProfile = useDownloadingDeviceProfile()
|
||||
|
||||
return useQuery({
|
||||
queryKey: MediaInfoQueryKey({ 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ export async function fetchMediaInfo(
|
||||
deviceProfile: DeviceProfile | undefined,
|
||||
itemId: string | null | undefined,
|
||||
): Promise<PlaybackInfoResponse> {
|
||||
console.debug(`Fetching media info of with ${deviceProfile?.Name} profile`)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isUndefined(api)) return reject('Client instance not set')
|
||||
|
||||
@@ -21,7 +19,6 @@ export async function fetchMediaInfo(
|
||||
},
|
||||
})
|
||||
.then(({ data }) => {
|
||||
console.debug('Received media info response')
|
||||
resolve(data)
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { QueryKeys } from '../../../enums/query-keys'
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import fetchPatrons from './utils'
|
||||
import { ONE_DAY } from '../../../constants/query-client'
|
||||
import { useApi } from '../../../stores'
|
||||
|
||||
const usePatronsQuery = () => {
|
||||
const { api } = useJellifyContext()
|
||||
const api = useApi()
|
||||
|
||||
return useQuery({
|
||||
queryKey: [QueryKeys.Patrons],
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import { UserPlaylistsQueryKey } from './keys'
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { fetchUserPlaylists, fetchPublicPlaylists } from './utils'
|
||||
import { ApiLimits } from '../query.config'
|
||||
import { fetchUserPlaylists, fetchPublicPlaylists, fetchPlaylistTracks } from './utils'
|
||||
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 = () => {
|
||||
const { api, user, library } = useJellifyContext()
|
||||
const api = useApi()
|
||||
const [user] = useJellifyUser()
|
||||
const [library] = useJellifyLibrary()
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: UserPlaylistsQueryKey(library),
|
||||
@@ -13,7 +17,39 @@ export const useUserPlaylists = () => {
|
||||
select: (data) => data.pages.flatMap((page) => page),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
|
||||
if (!lastPage) return 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
ItemFields,
|
||||
ItemSortBy,
|
||||
SortOrder,
|
||||
@@ -9,20 +10,28 @@ import { JellifyUser } from '../../../../types/JellifyUser'
|
||||
import { Api } from '@jellyfin/sdk'
|
||||
import { isUndefined } from 'lodash'
|
||||
import { JellifyLibrary } from '../../../../types/JellifyLibrary'
|
||||
import QueryConfig from '../../query.config'
|
||||
import QueryConfig, { 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(
|
||||
api: Api | undefined,
|
||||
user: JellifyUser | undefined,
|
||||
library: JellifyLibrary | undefined,
|
||||
sortBy: ItemSortBy[] = [],
|
||||
): 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) => {
|
||||
if (isUndefined(api)) return reject('Client instance not set')
|
||||
if (isUndefined(user)) return reject('User instance not set')
|
||||
@@ -37,6 +46,7 @@ export async function fetchUserPlaylists(
|
||||
ItemFields.CanDelete,
|
||||
ItemFields.Genres,
|
||||
ItemFields.ChildCount,
|
||||
ItemFields.ItemCounts,
|
||||
],
|
||||
sortBy: [ItemSortBy.SortName],
|
||||
sortOrder: [SortOrder.Ascending],
|
||||
@@ -44,10 +54,9 @@ export async function fetchUserPlaylists(
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data.Items)
|
||||
// Playlists must be stored in Jellyfin's internal config directory
|
||||
return resolve(
|
||||
response.data.Items.filter((playlist) =>
|
||||
playlist.Path!.includes('/data/playlists'),
|
||||
),
|
||||
response.data.Items.filter((playlist) => playlist.Path?.includes('data')),
|
||||
)
|
||||
else return resolve([])
|
||||
})
|
||||
@@ -62,8 +71,6 @@ export async function fetchPublicPlaylists(
|
||||
library: JellifyLibrary | undefined,
|
||||
page: number,
|
||||
): Promise<BaseItemDto[]> {
|
||||
console.debug('Fetching public playlists')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isUndefined(api)) return reject('Client instance not set')
|
||||
if (isUndefined(library)) return reject('Library instance not set')
|
||||
@@ -75,16 +82,19 @@ export async function fetchPublicPlaylists(
|
||||
sortOrder: [SortOrder.Ascending],
|
||||
startIndex: page * QueryConfig.limits.library,
|
||||
limit: QueryConfig.limits.library,
|
||||
fields: ['Path', 'CanDelete', 'Genres'],
|
||||
fields: [
|
||||
ItemFields.Path,
|
||||
ItemFields.CanDelete,
|
||||
ItemFields.Genres,
|
||||
ItemFields.ChildCount,
|
||||
ItemFields.ItemCounts,
|
||||
],
|
||||
})
|
||||
.then((response) => {
|
||||
console.log(response)
|
||||
|
||||
if (response.data.Items)
|
||||
// Playlists must not be stored in Jellyfin's internal config directory
|
||||
return resolve(
|
||||
response.data.Items.filter(
|
||||
(playlist) => !playlist.Path?.includes('/data/playlists'),
|
||||
),
|
||||
response.data.Items.filter((playlist) => !playlist.Path?.includes('data')),
|
||||
)
|
||||
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 ?? []
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { useJellifyContext } from '../../../providers'
|
||||
import { RecentlyPlayedArtistsQueryKey, RecentlyPlayedTracksQueryKey } from './keys'
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { fetchRecentlyPlayed, fetchRecentlyPlayedArtists } from './utils'
|
||||
import { ApiLimits } from '../query.config'
|
||||
import { ApiLimits, MaxPages } from '../../../configs/query.config'
|
||||
import { isUndefined } from 'lodash'
|
||||
import { useApi, useJellifyUser, useJellifyLibrary } from '../../../stores'
|
||||
|
||||
const RECENTS_QUERY_CONFIG = {
|
||||
maxPages: 2,
|
||||
maxPages: MaxPages.Home,
|
||||
refetchOnMount: false,
|
||||
staleTime: Infinity,
|
||||
} as const
|
||||
|
||||
export const useRecentlyPlayedTracks = () => {
|
||||
const { api, user, library } = useJellifyContext()
|
||||
const api = useApi()
|
||||
const [user] = useJellifyUser()
|
||||
const [library] = useJellifyLibrary()
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: RecentlyPlayedTracksQueryKey(user, library),
|
||||
@@ -20,7 +21,6 @@ export const useRecentlyPlayedTracks = () => {
|
||||
initialPageParam: 0,
|
||||
select: (data) => data.pages.flatMap((page) => page),
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
|
||||
console.debug('Getting next page for recent tracks')
|
||||
return lastPage.length === ApiLimits.Home ? lastPageParam + 1 : undefined
|
||||
},
|
||||
...RECENTS_QUERY_CONFIG,
|
||||
@@ -28,7 +28,9 @@ export const useRecentlyPlayedTracks = () => {
|
||||
}
|
||||
|
||||
export const useRecentArtists = () => {
|
||||
const { api, user, library } = useJellifyContext()
|
||||
const api = useApi()
|
||||
const [user] = useJellifyUser()
|
||||
const [library] = useJellifyLibrary()
|
||||
|
||||
const { data: recentlyPlayedTracks } = useRecentlyPlayedTracks()
|
||||
|
||||
@@ -38,7 +40,6 @@ export const useRecentArtists = () => {
|
||||
select: (data) => data.pages.flatMap((page) => page),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
|
||||
console.debug('Getting next page for recent artists')
|
||||
return lastPage.length > 0 ? lastPageParam + 1 : undefined
|
||||
},
|
||||
enabled: !isUndefined(recentlyPlayedTracks),
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
SortOrder,
|
||||
} from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api'
|
||||
import QueryConfig, { ApiLimits } from '../../query.config'
|
||||
import QueryConfig, { ApiLimits } from '../../../../configs/query.config'
|
||||
import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api'
|
||||
import { Api } from '@jellyfin/sdk'
|
||||
import { isUndefined } from 'lodash'
|
||||
@@ -56,8 +56,6 @@ export async function fetchRecentlyPlayed(
|
||||
page: number,
|
||||
limit: number = ApiLimits.Home,
|
||||
): Promise<BaseItemDto[]> {
|
||||
console.debug('Fetching recently played items')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isUndefined(api)) return reject('Client instance not set')
|
||||
if (isUndefined(user)) return reject('User instance not set')
|
||||
@@ -77,8 +75,6 @@ export async function fetchRecentlyPlayed(
|
||||
enableUserData: true,
|
||||
})
|
||||
.then((response) => {
|
||||
console.debug('Received recently played items response')
|
||||
|
||||
if (response.data.Items) return resolve(response.data.Items)
|
||||
return resolve([])
|
||||
})
|
||||
@@ -101,7 +97,6 @@ export function fetchRecentlyPlayedArtists(
|
||||
library: JellifyLibrary | undefined,
|
||||
page: number,
|
||||
): Promise<BaseItemDto[]> {
|
||||
console.debug('Fetching recently played artists')
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isUndefined(library)) return reject('Library instance not set')
|
||||
|
||||
|
||||