Compare commits
210 Commits
fix/remove
...
fix-artifa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6c7246cd1 | ||
|
|
973d226c49 | ||
|
|
dd849b532b | ||
|
|
1a58df27d2 | ||
|
|
68b5fe3599 | ||
|
|
67f73bfa39 | ||
|
|
5de7cab285 | ||
|
|
67d39c39ea | ||
|
|
9d8e227609 | ||
|
|
962323a75c | ||
|
|
fc23201b4f | ||
|
|
f0519ea88d | ||
|
|
f9f21606ff | ||
|
|
c4d026f4d8 | ||
|
|
577827303e | ||
|
|
3e0a1af9fa | ||
|
|
63bc806a06 | ||
|
|
f05496a458 | ||
|
|
d3660b45b1 | ||
|
|
1b812ebed5 | ||
|
|
6703299da9 | ||
|
|
80d63c0219 | ||
|
|
c2f8145e74 | ||
|
|
ce00aeb5f1 | ||
|
|
5899cc8625 | ||
|
|
90217bb495 | ||
|
|
16e88cca8c | ||
|
|
e8e62061ae | ||
|
|
3adc4d2a21 | ||
|
|
185524c06c | ||
|
|
6a208ee201 | ||
|
|
99938ddf5a | ||
|
|
963a54a36c | ||
|
|
e939c9b933 | ||
|
|
2ffd569bba | ||
|
|
c8ea494d6f | ||
|
|
577a61a452 | ||
|
|
a731c4eebd | ||
|
|
8a664757b8 | ||
|
|
655a78900d | ||
|
|
87a33af8d1 | ||
|
|
36b1c48fdd | ||
|
|
0454ba9f29 | ||
|
|
b55ed6349c | ||
|
|
0c34add45a | ||
|
|
1c1345a3b7 | ||
|
|
9f706a348e | ||
|
|
f4750e781d | ||
|
|
0b574cc047 | ||
|
|
4a816470d1 | ||
|
|
0d43b57f55 | ||
|
|
31f662a582 | ||
|
|
23e0ec9774 | ||
|
|
d6ac8569a8 | ||
|
|
2ce04b3fd3 | ||
|
|
bf5203348b | ||
|
|
16fb1a52ca | ||
|
|
d8be7b2463 | ||
|
|
ec37b5ab2c | ||
|
|
29eb072e5d | ||
|
|
2a4a7f5f2d | ||
|
|
8b3f950bc5 | ||
|
|
db527311d6 | ||
|
|
b76e834be1 | ||
|
|
c9905d9d88 | ||
|
|
b9bb109f4a | ||
|
|
16b834cf71 | ||
|
|
f6baf490fb | ||
|
|
3201499397 | ||
|
|
6555251c2e | ||
|
|
71c15f3651 | ||
|
|
25da30d6e2 | ||
|
|
1394eae01e | ||
|
|
205715ae29 | ||
|
|
587d419502 | ||
|
|
bc081b535e | ||
|
|
62d2d1f7ca | ||
|
|
66aab5b771 | ||
|
|
5c89100afd | ||
|
|
ffbaaa81a8 | ||
|
|
f1a3b48017 | ||
|
|
8ab72b1262 | ||
|
|
b9c02618d5 | ||
|
|
0b22f28bb6 | ||
|
|
2932a7b324 | ||
|
|
8e8ae32287 | ||
|
|
2189b3d3dd | ||
|
|
f770cf174b | ||
|
|
f7e771123f | ||
|
|
5757b1c010 | ||
|
|
92513e234f | ||
|
|
2688e1b981 | ||
|
|
54423a1267 | ||
|
|
defe87debb | ||
|
|
a1b2248f16 | ||
|
|
cd42a86d40 | ||
|
|
76661c7599 | ||
|
|
6de829c16d | ||
|
|
9b0ba285b3 | ||
|
|
cbcb160bdd | ||
|
|
10bfa95060 | ||
|
|
7201be6f02 | ||
|
|
9f17f13175 | ||
|
|
c0e9f29c04 | ||
|
|
ab5df3c9ef | ||
|
|
7768939767 | ||
|
|
8b72bde4a9 | ||
|
|
c29b2cb8da | ||
|
|
96e3362f43 | ||
|
|
7cdf0e5355 | ||
|
|
ef355b1f04 | ||
|
|
81535894e1 | ||
|
|
887f30e739 | ||
|
|
3d7889e19a | ||
|
|
4b8e8cddb5 | ||
|
|
d33baf07d3 | ||
|
|
66f61c3c38 | ||
|
|
88efb09317 | ||
|
|
5df021a836 | ||
|
|
89eb0d7796 | ||
|
|
ba9178a0f6 | ||
|
|
27cd73efab | ||
|
|
e15b19deb3 | ||
|
|
baccc931a2 | ||
|
|
79a2873975 | ||
|
|
e397be4b2e | ||
|
|
4dddc0f926 | ||
|
|
ebcb414b89 | ||
|
|
77dba04289 | ||
|
|
12ceef02cd | ||
|
|
fe3b652b4f | ||
|
|
9c9785ba9e | ||
|
|
bce9ed2690 | ||
|
|
ec914133d6 | ||
|
|
2d1b03e403 | ||
|
|
7f07260177 | ||
|
|
e1314077e2 | ||
|
|
09e9462ac0 | ||
|
|
dd65505f7f | ||
|
|
951158bcd3 | ||
|
|
9b1dd0923a | ||
|
|
bd908516b5 | ||
|
|
8cb10d1062 | ||
|
|
446439c2e0 | ||
|
|
a5463d783d | ||
|
|
640db35456 | ||
|
|
caa4b765c1 | ||
|
|
9c6aebe66a | ||
|
|
ef42510383 | ||
|
|
5273dfd22b | ||
|
|
00bc4232fb | ||
|
|
35c9258062 | ||
|
|
89bf51c3cc | ||
|
|
f64c5a02db | ||
|
|
cf284eb3d8 | ||
|
|
b581a077e1 | ||
|
|
e651b975b7 | ||
|
|
1c550b1b77 | ||
|
|
5bcae81538 | ||
|
|
c951725222 | ||
|
|
0b966d7c04 | ||
|
|
8e0e35afe3 | ||
|
|
daf7f35196 | ||
|
|
d5ac30b6d8 | ||
|
|
81b91bbb97 | ||
|
|
af2bd030e9 | ||
|
|
5590c2f784 | ||
|
|
6cc70dd123 | ||
|
|
fae588b0f0 | ||
|
|
bd2aeb2234 | ||
|
|
cca0bbf42c | ||
|
|
06e0eb5c4e | ||
|
|
b478fbb6bf | ||
|
|
b98a7b0634 | ||
|
|
ce38024a3f | ||
|
|
04dce9265b | ||
|
|
5b8418cd82 | ||
|
|
b0c5255bd7 | ||
|
|
73dd171987 | ||
|
|
ff35559687 | ||
|
|
5aadd50946 | ||
|
|
63b5ba2112 | ||
|
|
8b955578a2 | ||
|
|
1e5c021c93 | ||
|
|
0b86f56486 | ||
|
|
728b93f4e5 | ||
|
|
2fc483b24e | ||
|
|
fc901bc01e | ||
|
|
2b0884b154 | ||
|
|
307d20e538 | ||
|
|
a2f03908f6 | ||
|
|
77aef8877e | ||
|
|
0cf930d6e1 | ||
|
|
4b0b949541 | ||
|
|
14b717f985 | ||
|
|
cfbac538f8 | ||
|
|
1ac6b7e3df | ||
|
|
c9f6e8676b | ||
|
|
5aab1450cd | ||
|
|
1e7080a136 | ||
|
|
993cec4138 | ||
|
|
6c524499f9 | ||
|
|
b3463ffdfc | ||
|
|
50942b44f1 | ||
|
|
f602f8919f | ||
|
|
0e86d8a00f | ||
|
|
56b1e1977c | ||
|
|
30e23b9079 | ||
|
|
d83ecb881b | ||
|
|
4c14c08b35 |
1
.env.development
Normal file
@@ -0,0 +1 @@
|
|||||||
|
EXPO_PUBLIC_WRITE_DEBUG=1
|
||||||
1
.env.production
Normal file
@@ -0,0 +1 @@
|
|||||||
|
EXPO_PUBLIC_WRITE_DEBUG=0
|
||||||
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text
|
||||||
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -43,6 +43,8 @@ body:
|
|||||||
label: Version
|
label: Version
|
||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
|
- 0.28.0
|
||||||
|
- 0.27.0
|
||||||
- 0.26.1
|
- 0.26.1
|
||||||
- 0.26.0
|
- 0.26.0
|
||||||
- 0.25.0
|
- 0.25.0
|
||||||
|
|||||||
79
.github/workflows/build-android.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
name: 🤖 Android APK Build
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
branches: [develop, master]
|
||||||
|
push:
|
||||||
|
branches: [develop, master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
name: 🏗️ Build Android APK
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout code
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
show-progress: false
|
||||||
|
submodules: recursive
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: 🍞 Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
|
with:
|
||||||
|
bun-version: '1.2.15'
|
||||||
|
|
||||||
|
- name: ☕ Setup JDK
|
||||||
|
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||||
|
with:
|
||||||
|
distribution: 'zulu'
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
|
- name: 💾 Cache Bun dependencies
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-cache-
|
||||||
|
|
||||||
|
- name: 📦 Install dependencies
|
||||||
|
run: |
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
bun run submodule-reload
|
||||||
|
|
||||||
|
- name: 💾 Cache Android dependencies
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
android/.gradle
|
||||||
|
key: ${{ runner.os }}-android-deps-${{ hashFiles('android/**/build.gradle') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-android-deps-
|
||||||
|
|
||||||
|
- name: 🛠️ Generate project files
|
||||||
|
run: bun run prebuild
|
||||||
|
|
||||||
|
- name: 🚀 Build APK via Bun
|
||||||
|
run: bun run build:android:local
|
||||||
|
|
||||||
|
- name: 📅 Set date tag
|
||||||
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: 📤 Upload APK artifact
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: streamyfin-apk-${{ github.sha }}-${{ env.DATE_TAG }}
|
||||||
|
path: |
|
||||||
|
android/app/build/outputs/apk/release/*.apk
|
||||||
|
android/app/build/outputs/bundle/release/*.aab
|
||||||
|
retention-days: 7
|
||||||
49
.github/workflows/build-ios.yaml
vendored
@@ -1,49 +0,0 @@
|
|||||||
name: Automatic Build and Deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: macos-15
|
|
||||||
name: Build IOS
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
name: Check out repository
|
|
||||||
- uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
- run: |
|
|
||||||
bun i && bun run submodule-reload
|
|
||||||
npx expo prebuild
|
|
||||||
- uses: sparkfabrik/ios-build-action@v2.3.0
|
|
||||||
with:
|
|
||||||
upload-to-testflight: false
|
|
||||||
increment-build-number: false
|
|
||||||
build-pods: true
|
|
||||||
pods-path: "ios/Podfile"
|
|
||||||
configuration: Release
|
|
||||||
# Change later to app-store if wanted
|
|
||||||
export-method: appstore
|
|
||||||
#export-method: ad-hoc
|
|
||||||
workspace-path: "ios/Streamyfin.xcodeproj/project.xcworkspace/"
|
|
||||||
project-path: "ios/Streamyfin.xcodeproj"
|
|
||||||
scheme: Streamyfin
|
|
||||||
apple-key-id: ${{ secrets.APPLE_KEY_ID }}
|
|
||||||
apple-key-issuer-id: ${{ secrets.APPLE_KEY_ISSUER_ID }}
|
|
||||||
apple-key-content: ${{ secrets.APPLE_KEY_CONTENT }}
|
|
||||||
team-id: ${{ secrets.TEAM_ID }}
|
|
||||||
team-name: ${{ secrets.TEAM_NAME }}
|
|
||||||
#match-password: ${{ secrets.MATCH_PASSWORD }}
|
|
||||||
#match-git-url: ${{ secrets.MATCH_GIT_URL }}
|
|
||||||
#match-git-basic-authorization: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
|
|
||||||
#match-build-type: "appstore"
|
|
||||||
#browserstack-upload: true
|
|
||||||
#browserstack-username: ${{ secrets.BROWSERSTACK_USERNAME }}
|
|
||||||
#browserstack-access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
|
|
||||||
#fastlane-env: stage
|
|
||||||
ios-app-id: com.stetsed.teststreamyfin
|
|
||||||
output-path: build-${{ github.sha }}.ipa
|
|
||||||
70
.github/workflows/build-ios.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
name: 🤖 iOS IPA Build
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
branches: [develop, master]
|
||||||
|
push:
|
||||||
|
branches: [develop, master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: macos-15
|
||||||
|
name: 🏗️ Build iOS IPA
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Check out repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
show-progress: false
|
||||||
|
submodules: recursive
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: 🍞 Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
|
with:
|
||||||
|
bun-version: '1.2.15'
|
||||||
|
|
||||||
|
- name: 💾 Cache Bun dependencies
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-cache-
|
||||||
|
|
||||||
|
- name: 📦 Install & Prepare
|
||||||
|
run: |
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
bun run submodule-reload
|
||||||
|
|
||||||
|
- name: 🛠️ Generate project files
|
||||||
|
run: bun run prebuild
|
||||||
|
|
||||||
|
- name: 🏗 Setup EAS
|
||||||
|
uses: expo/expo-github-action@main
|
||||||
|
with:
|
||||||
|
eas-version: 16.7.1
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
|
- name: 🏗️ Build iOS app
|
||||||
|
run: |
|
||||||
|
eas build -p ios --local --non-interactive
|
||||||
|
|
||||||
|
- name: 📅 Set date tag
|
||||||
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: 📤 Upload IPA artifact
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: streamyfin-ipa-${{ github.sha }}-${{ env.DATE_TAG }}
|
||||||
|
path: |
|
||||||
|
build-*.ipa
|
||||||
|
retention-days: 7
|
||||||
46
.github/workflows/check-lockfile.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
name: 🔒 Lockfile Consistency Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [develop, master]
|
||||||
|
push:
|
||||||
|
branches: [develop, master]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-lockfile:
|
||||||
|
name: 🔍 Check bun.lock and package.json consistency
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
show-progress: false
|
||||||
|
submodules: recursive
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: 🍞 Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
|
with:
|
||||||
|
bun-version: '1.2.15'
|
||||||
|
|
||||||
|
- name: 💾 Cache Bun dependencies
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
|
|
||||||
|
- name: 🛡️ Verify lockfile consistency
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
echo "➡️ Checking for discrepancies between bun.lock and package.json..."
|
||||||
|
bun install --frozen-lockfile --dry-run --ignore-scripts
|
||||||
|
echo "✅ Lockfile is consistent with package.json!"
|
||||||
43
.github/workflows/ci-codeql.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: 🛡️ CodeQL Analysis
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [master, develop]
|
||||||
|
schedule:
|
||||||
|
- cron: '24 2 * * *'
|
||||||
|
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: 🔎 Analyze with CodeQL
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [ 'javascript-typescript' ]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
show-progress: false
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: 🏁 Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
|
- name: 🛠️ Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
||||||
|
|
||||||
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
||||||
24
.github/workflows/conflict.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: 🏷️🔀Merge Conflict Labeler
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [develop]
|
||||||
|
pull_request_target:
|
||||||
|
branches: [develop]
|
||||||
|
types: [synchronize]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label:
|
||||||
|
name: 🏷️ Labeling Merge Conflicts
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
if: ${{ github.repository == 'streamyfin/streamyfin' }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: 🚩 Apply merge conflict label
|
||||||
|
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||||
|
with:
|
||||||
|
dirtyLabel: 'merge-conflict'
|
||||||
|
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||||
|
repoToken: '${{ secrets.GITHUB_TOKEN }}'
|
||||||
41
.github/workflows/lint-pr.yaml
vendored
@@ -1,41 +0,0 @@
|
|||||||
name: "Lint PR"
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- edited
|
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
main:
|
|
||||||
name: Validate PR title
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: amannn/action-semantic-pull-request@v5
|
|
||||||
id: lint_pr_title
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- uses: marocchino/sticky-pull-request-comment@v2
|
|
||||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
|
||||||
with:
|
|
||||||
header: pr-title-lint-error
|
|
||||||
message: |
|
|
||||||
Hey there and thank you for opening this pull request! 👋🏼
|
|
||||||
|
|
||||||
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
|
|
||||||
|
|
||||||
Details:
|
|
||||||
|
|
||||||
```
|
|
||||||
${{ steps.lint_pr_title.outputs.error_message }}
|
|
||||||
```
|
|
||||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
|
||||||
uses: marocchino/sticky-pull-request-comment@v2
|
|
||||||
with:
|
|
||||||
header: pr-title-lint-error
|
|
||||||
delete: true
|
|
||||||
95
.github/workflows/linting.yml
vendored
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
name: 🚦 Security & Quality Gate
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, edited, synchronize, reopened]
|
||||||
|
branches: [develop, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate_pr_title:
|
||||||
|
name: "📝 Validate PR Title"
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3
|
||||||
|
id: lint_pr_title
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
||||||
|
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
message: |
|
||||||
|
Hey there and thank you for opening this pull request! 👋🏼
|
||||||
|
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
|
||||||
|
|
||||||
|
**Error details:**
|
||||||
|
```
|
||||||
|
${{ steps.lint_pr_title.outputs.error_message }}
|
||||||
|
```
|
||||||
|
|
||||||
|
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||||
|
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
delete: true
|
||||||
|
|
||||||
|
dependency-review:
|
||||||
|
name: 🔍 Vulnerable Dependencies
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Dependency Review
|
||||||
|
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
|
||||||
|
with:
|
||||||
|
fail-on-severity: high
|
||||||
|
deny-licenses: GPL-3.0, AGPL-3.0
|
||||||
|
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
||||||
|
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||||
|
|
||||||
|
code_quality:
|
||||||
|
name: "🔍 Lint & Test (${{ matrix.command }})"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
command:
|
||||||
|
- "lint"
|
||||||
|
- "check"
|
||||||
|
steps:
|
||||||
|
- name: "📥 Checkout PR code"
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
submodules: recursive
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: "🟢 Setup Node.js"
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: '20.x'
|
||||||
|
|
||||||
|
- name: "🍞 Setup Bun"
|
||||||
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
|
with:
|
||||||
|
bun-version: '1.2.15'
|
||||||
|
|
||||||
|
- name: "📦 Install dependencies"
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: "🚨 Run ${{ matrix.command }}"
|
||||||
|
run: bun run ${{ matrix.command }}
|
||||||
18
.github/workflows/notification.yaml
vendored
@@ -1,18 +0,0 @@
|
|||||||
name: Discord Pull Request Notification
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, reopened]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
notify:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: joelwmale/webhook-action@master
|
|
||||||
with:
|
|
||||||
url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
|
||||||
body: |
|
|
||||||
{
|
|
||||||
"content": "New Pull Request: ${{ github.event.pull_request.title }}\nBy: ${{ github.event.pull_request.user.login }}\n\n${{ github.event.pull_request.html_url }}",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/193271640"
|
|
||||||
}
|
|
||||||
23
.github/workflows/notification.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: 🛎️ Discord Pull Request Notification
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened]
|
||||||
|
branches: [develop]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notify:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: 🛎️ Notify Discord
|
||||||
|
uses: Ilshidur/action-discord@0c4b27844ba47cb1c7bee539c8eead5284ce9fa9 # 0.3.2
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
|
||||||
|
with:
|
||||||
|
args: |
|
||||||
|
📢 New Pull Request in **${{ github.repository }}**
|
||||||
|
**Title:** ${{ github.event.pull_request.title }}
|
||||||
|
**By:** ${{ github.event.pull_request.user.login }}
|
||||||
|
**Branch:** ${{ github.event.pull_request.head.ref }}
|
||||||
|
🔗 ${{ github.event.pull_request.html_url }}
|
||||||
49
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
name: 🕒 Handle Stale Issues
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Runs daily at 1:30 AM UTC (3:30 AM CEST - France time)
|
||||||
|
- cron: "30 1 * * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
stale-issues:
|
||||||
|
name: 🗑️ Cleanup Stale Issues
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 🔄 Mark/Close Stale Issues
|
||||||
|
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||||
|
with:
|
||||||
|
# Global settings
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
operations-per-run: 500 # Increase if you have >1000 issues
|
||||||
|
log-level: debug
|
||||||
|
|
||||||
|
# Issue configuration
|
||||||
|
days-before-issue-stale: 90
|
||||||
|
days-before-issue-close: 7
|
||||||
|
stale-issue-label: "stale"
|
||||||
|
exempt-issue-labels: "Roadmap v1,help needed,enhancement"
|
||||||
|
|
||||||
|
# Notifications messages
|
||||||
|
stale-issue-message: |
|
||||||
|
⏳ This issue has been automatically marked as **stale** because it has had no activity for 90 days.
|
||||||
|
|
||||||
|
**Next steps:**
|
||||||
|
- If this is still relevant, add a comment to keep it open
|
||||||
|
- Otherwise, it will be closed in 7 days
|
||||||
|
|
||||||
|
Thank you for your contributions! 🙌
|
||||||
|
|
||||||
|
close-issue-message: |
|
||||||
|
🚮 This issue has been automatically closed due to inactivity (7 days since being marked stale).
|
||||||
|
|
||||||
|
**Need to reopen?**
|
||||||
|
Click "Reopen" and add a comment explaining why this should stay open.
|
||||||
|
|
||||||
|
# Disable PR handling
|
||||||
|
days-before-pr-stale: -1
|
||||||
|
days-before-pr-close: -1
|
||||||
10
.gitignore
vendored
@@ -10,6 +10,7 @@ npm-debug.*
|
|||||||
*.orig.*
|
*.orig.*
|
||||||
web-build/
|
web-build/
|
||||||
modules/vlc-player/android/build
|
modules/vlc-player/android/build
|
||||||
|
modules/vlc-player/android/.gradle
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -18,9 +19,7 @@ expo-env.d.ts
|
|||||||
|
|
||||||
Streamyfin.app
|
Streamyfin.app
|
||||||
|
|
||||||
build-*
|
|
||||||
*.mp4
|
*.mp4
|
||||||
build-*
|
|
||||||
Streamyfin.app
|
Streamyfin.app
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
@@ -41,4 +40,9 @@ credentials.json
|
|||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
.ruby-lsp
|
.ruby-lsp
|
||||||
|
modules/hls-downloader/android/build
|
||||||
|
streamyfin-4fec1-firebase-adminsdk.json
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.aab
|
||||||
|
|||||||
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
lint-staged
|
||||||
17
.vscode/settings.json
vendored
@@ -1,15 +1,24 @@
|
|||||||
{
|
{
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
||||||
"editor.formatOnSave": true,
|
|
||||||
"[javascript]": {
|
"[javascript]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true
|
||||||
},
|
},
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true
|
||||||
},
|
},
|
||||||
|
"prettier.printWidth": 120,
|
||||||
"[swift]": {
|
"[swift]": {
|
||||||
"editor.defaultFormatter": "sswg.swift-lang"
|
"editor.defaultFormatter": "sswg.swift-lang"
|
||||||
|
},
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
},
|
||||||
|
"[javascriptreact]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
|
"editor.formatOnSave": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
Makefile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
e2e:
|
||||||
|
maestro start-device --platform android
|
||||||
|
maestro test login.yaml
|
||||||
|
|
||||||
|
e2e-setup:
|
||||||
|
curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||||
30
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||||
|
|
||||||
Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
|
Welcome to Streamyfin, a simple and user-friendly Jellyfin video streaming client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
|
||||||
|
|
||||||
<div style="display: flex; flex-direction: row; gap: 8px">
|
<div style="display: flex; flex-direction: row; gap: 8px">
|
||||||
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
||||||
@@ -15,11 +15,11 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
|||||||
|
|
||||||
- 🚀 **Skip Intro / Credits Support**
|
- 🚀 **Skip Intro / Credits Support**
|
||||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
||||||
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
|
||||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
||||||
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
|
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
|
||||||
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
||||||
|
- 👁️ **Sessions View:** View all active sessions currently streaming on your server.
|
||||||
|
|
||||||
## 🧪 Experimental Features
|
## 🧪 Experimental Features
|
||||||
|
|
||||||
@@ -31,16 +31,16 @@ Downloading works by using ffmpeg to convert an HLS stream into a video file on
|
|||||||
|
|
||||||
### Chromecast
|
### Chromecast
|
||||||
|
|
||||||
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features.
|
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos, but we're working on adding support for subtitles and other features.
|
||||||
|
|
||||||
### Streamyfin Plugin
|
### Streamyfin Plugin
|
||||||
|
|
||||||
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that hold all settings for the client Streamyfin. This allows you to syncronize settings accross all your users, like:
|
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
|
||||||
|
|
||||||
- Auto log in to Jellyseerr without the user having to do anythin
|
- Auto log in to Jellyseerr without the user having to do anything
|
||||||
- Choose the default languages
|
- Choose the default languages
|
||||||
- Set download method and search provider
|
- Set download method and search provider
|
||||||
- Customize homescreen
|
- Customize home screen
|
||||||
- And more...
|
- And more...
|
||||||
|
|
||||||
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
||||||
@@ -66,7 +66,7 @@ Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/r
|
|||||||
|
|
||||||
### Beta testing
|
### Beta testing
|
||||||
|
|
||||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
|
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and I'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that I can manually add you.
|
||||||
|
|
||||||
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
||||||
|
|
||||||
@@ -85,9 +85,10 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
|
|||||||
|
|
||||||
1. Use node `>20`
|
1. Use node `>20`
|
||||||
2. Install dependencies `bun i && bun run submodule-reload`
|
2. Install dependencies `bun i && bun run submodule-reload`
|
||||||
3. Make sure you have xcode and/or android studio installed.
|
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||||
|
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
||||||
4. run `npm run prebuild`
|
4. run `npm run prebuild`
|
||||||
5. Create an expo dev build by running `npm run ios` or `nom run android`. This will open a simulator on your computer and run the app.
|
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app.
|
||||||
|
|
||||||
For the TV version suffix the npm commands with `:tv`.
|
For the TV version suffix the npm commands with `:tv`.
|
||||||
|
|
||||||
@@ -117,12 +118,23 @@ If you have questions or need support, feel free to reach out:
|
|||||||
- GitHub Issues: Report bugs or request features here.
|
- GitHub Issues: Report bugs or request features here.
|
||||||
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
1. Q: Why can't I see my libraries in Streamyfin?
|
||||||
|
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only.
|
||||||
|
2. Q: Why can't I see my music library?
|
||||||
|
A: We don't currently support music and are unlikely to support music in the near future.
|
||||||
|
|
||||||
## 📝 Credits
|
## 📝 Credits
|
||||||
|
|
||||||
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
||||||
|
|
||||||
## ✨ Acknowledgements
|
## ✨ Acknowledgements
|
||||||
|
|
||||||
|
We would like to thank the Jellyfin team for their great software and awesome support on discord.
|
||||||
|
|
||||||
|
Special shoutout to the JF official clients for being an inspiration to ours.
|
||||||
|
|
||||||
### Core Developers
|
### Core Developers
|
||||||
|
|
||||||
Thanks to the following contributors for their significant contributions:
|
Thanks to the following contributors for their significant contributions:
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
module.exports = ({ config }) => {
|
module.exports = ({ config }) => {
|
||||||
if (process.env.EXPO_TV != "1") {
|
if (process.env.EXPO_TV !== "1") {
|
||||||
config.plugins.push([
|
config.plugins.push([
|
||||||
"react-native-google-cast",
|
"react-native-google-cast",
|
||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
android: {
|
||||||
|
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
|
||||||
|
},
|
||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
35
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.26.1",
|
"version": "0.28.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -27,26 +27,33 @@
|
|||||||
"usesNonExemptEncryption": false
|
"usesNonExemptEncryption": false
|
||||||
},
|
},
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.fredrikburmester.streamyfin"
|
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||||
|
"icon": {
|
||||||
|
"dark": "./assets/images/icon-plain.png",
|
||||||
|
"light": "./assets/images/icon-ios-light.png",
|
||||||
|
"tinted": "./assets/images/icon-ios-tinted.png"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 53,
|
"versionCode": 56,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
"foregroundImage": "./assets/images/icon-plain.png",
|
||||||
|
"monochromeImage": "./assets/images/icon-mono.png",
|
||||||
|
"backgroundColor": "#464646"
|
||||||
},
|
},
|
||||||
"package": "com.fredrikburmester.streamyfin",
|
"package": "com.fredrikburmester.streamyfin",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"android.permission.FOREGROUND_SERVICE",
|
"android.permission.FOREGROUND_SERVICE",
|
||||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||||
"android.permission.WRITE_SETTINGS"
|
"android.permission.WRITE_SETTINGS"
|
||||||
]
|
],
|
||||||
|
"googleServicesFile": "./google-services.json"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"@react-native-tvos/config-tv",
|
"@react-native-tvos/config-tv",
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"@config-plugins/ffmpeg-kit-react-native",
|
|
||||||
[
|
[
|
||||||
"react-native-video",
|
"react-native-video",
|
||||||
{
|
{
|
||||||
@@ -111,6 +118,7 @@
|
|||||||
["./plugins/withAndroidManifest.js"],
|
["./plugins/withAndroidManifest.js"],
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
["./plugins/withGradleProperties.js"],
|
["./plugins/withGradleProperties.js"],
|
||||||
|
["./plugins/withRNBackgroundDownloader.js"],
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
@@ -118,6 +126,19 @@
|
|||||||
"image": "./assets/images/StreamyFinFinal.png",
|
"image": "./assets/images/StreamyFinFinal.png",
|
||||||
"imageWidth": 100
|
"imageWidth": 100
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-notifications",
|
||||||
|
{
|
||||||
|
"icon": "./assets/images/notification.png",
|
||||||
|
"color": "#9333EA"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"react-native-google-cast",
|
||||||
|
{
|
||||||
|
"useDefaultExpandedMediaControls": true
|
||||||
|
}
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
@@ -131,7 +152,7 @@
|
|||||||
"projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
"projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"owner": "fredrikburmester",
|
"owner": "streamyfin",
|
||||||
"runtimeVersion": {
|
"runtimeVersion": {
|
||||||
"policy": "appVersion"
|
"policy": "appVersion"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import {Stack} from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
export default function CustomMenuLayout() {
|
export default function CustomMenuLayout() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="index"
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
|
import { useAtom } from "jotai/index";
|
||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { FlatList, TouchableOpacity, View } from "react-native";
|
import { FlatList, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
|
||||||
import { useAtom } from "jotai/index";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
|
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
|
||||||
|
|
||||||
@@ -26,11 +26,11 @@ export default function menuLinks() {
|
|||||||
const getMenuLinks = useCallback(async () => {
|
const getMenuLinks = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api?.axiosInstance.get(
|
const response = await api?.axiosInstance.get(
|
||||||
api?.basePath + "/web/config.json"
|
`${api?.basePath}/web/config.json`,
|
||||||
);
|
);
|
||||||
const config = response?.data;
|
const config = response?.data;
|
||||||
|
|
||||||
if (!config && !config.hasOwnProperty("menuLinks")) {
|
if (!config && !Object.hasOwn(config, "menuLinks")) {
|
||||||
console.error("Menu links not found");
|
console.error("Menu links not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ export default function menuLinks() {
|
|||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<FlatList
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: 10,
|
paddingTop: 10,
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
@@ -63,7 +63,7 @@ export default function menuLinks() {
|
|||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={item.name}
|
title={item.name}
|
||||||
iconAfter={<Ionicons name="link" size={24} color="white" />}
|
iconAfter={<Ionicons name='link' size={24} color='white' />}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
@@ -76,8 +76,10 @@ export default function menuLinks() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className="flex flex-col items-center justify-center h-full">
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
<Text className="font-bold text-xl text-neutral-500">{t("custom_links.no_links")}</Text>
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
{t("custom_links.no_links")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
export default function SearchLayout() {
|
export default function SearchLayout() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="index"
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
@@ -17,7 +17,7 @@ export default function SearchLayout() {
|
|||||||
backgroundColor: "black",
|
backgroundColor: "black",
|
||||||
},
|
},
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default function favorites() {
|
|||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@ export default function favorites() {
|
|||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="my-4">
|
<View className='my-4'>
|
||||||
<Favorites />
|
<Favorites />
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null;
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||||
|
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="index"
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
@@ -20,20 +25,15 @@ export default function IndexLayout() {
|
|||||||
headerLargeStyle: {
|
headerLargeStyle: {
|
||||||
backgroundColor: "black",
|
backgroundColor: "black",
|
||||||
},
|
},
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<>
|
<>
|
||||||
<Chromecast.Chromecast />
|
<Chromecast.Chromecast />
|
||||||
<TouchableOpacity
|
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||||
onPress={() => {
|
<SettingsButton />
|
||||||
router.push("/(auth)/settings");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Feather name="settings" color={"white"} size={22} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -41,55 +41,61 @@ export default function IndexLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="downloads/index"
|
name='downloads/index'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.downloads.downloads_title"),
|
title: t("home.downloads.downloads_title"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="downloads/[seriesId]"
|
name='downloads/[seriesId]'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.downloads.tvseries"),
|
title: t("home.downloads.tvseries"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings"
|
name='sessions/index'
|
||||||
|
options={{
|
||||||
|
title: t("home.sessions.title"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.settings_title"),
|
title: t("home.settings.settings_title"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings/optimized-server/page"
|
name='settings/optimized-server/page'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings/marlin-search/page"
|
name='settings/marlin-search/page'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings/jellyseerr/page"
|
name='settings/jellyseerr/page'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings/hide-libraries/page"
|
name='settings/hide-libraries/page'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings/logs/page"
|
name='settings/logs/page'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="intro/page"
|
name='intro/page'
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
title: "",
|
title: "",
|
||||||
@@ -100,15 +106,50 @@ export default function IndexLayout() {
|
|||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="collections/[collectionId]"
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SettingsButton = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/settings");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Feather name='settings' color={"white"} size={22} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SessionsButton = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/sessions");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='mr-4'>
|
||||||
|
<Ionicons
|
||||||
|
name='play-circle'
|
||||||
|
color={sessions.length === 0 ? "white" : "#9333ea"}
|
||||||
|
size={25}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
|
|
||||||
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
import {
|
||||||
SeasonDropdown,
|
SeasonDropdown,
|
||||||
SeasonIndexState,
|
type SeasonIndexState,
|
||||||
} from "@/components/series/SeasonDropdown";
|
} from "@/components/series/SeasonDropdown";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -21,7 +21,7 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
||||||
{}
|
{},
|
||||||
);
|
);
|
||||||
const { downloadedFiles, deleteItems } = useDownload();
|
const { downloadedFiles, deleteItems } = useDownload();
|
||||||
|
|
||||||
@@ -29,9 +29,9 @@ export default function page() {
|
|||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
downloadedFiles
|
downloadedFiles
|
||||||
?.filter((f) => f.item.SeriesId == seriesId)
|
?.filter((f) => f.item.SeriesId === seriesId)
|
||||||
?.sort(
|
?.sort(
|
||||||
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!
|
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
|
||||||
) || []
|
) || []
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -64,7 +64,7 @@ export default function page() {
|
|||||||
() =>
|
() =>
|
||||||
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
||||||
series?.[0]?.item?.ParentIndexNumber,
|
series?.[0]?.item?.ParentIndexNumber,
|
||||||
[groupBySeason]
|
[groupBySeason],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -92,14 +92,14 @@ export default function page() {
|
|||||||
onPress: () => deleteItems(groupBySeason),
|
onPress: () => deleteItems(groupBySeason),
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
}, [groupBySeason]);
|
}, [groupBySeason]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1">
|
<View className='flex-1'>
|
||||||
{series.length > 0 && (
|
{series.length > 0 && (
|
||||||
<View className="flex flex-row items-center justify-start my-2 px-4">
|
<View className='flex flex-row items-center justify-start my-2 px-4'>
|
||||||
<SeasonDropdown
|
<SeasonDropdown
|
||||||
item={series[0].item}
|
item={series[0].item}
|
||||||
seasons={series.map((s) => s.item)}
|
seasons={series.map((s) => s.item)}
|
||||||
@@ -112,17 +112,17 @@ export default function page() {
|
|||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2">
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
|
||||||
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
|
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto">
|
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
|
||||||
<TouchableOpacity onPress={deleteSeries}>
|
<TouchableOpacity onPress={deleteSeries}>
|
||||||
<Ionicons name="trash" size={20} color="white" />
|
<Ionicons name='trash' size={20} color='white' />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<ScrollView key={seasonIndex} className="px-4">
|
<ScrollView key={seasonIndex} className='px-4'>
|
||||||
{groupBySeason.map((episode, index) => (
|
{groupBySeason.map((episode, index) => (
|
||||||
<EpisodeCard key={index} item={episode} />
|
<EpisodeCard key={index} item={episode} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||||
|
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||||
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
import { type DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
|
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, { useEffect, useMemo, useRef } from "react";
|
|
||||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { t } from 'i18next';
|
|
||||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
BottomSheetBackdropProps,
|
type BottomSheetBackdropProps,
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -45,7 +45,7 @@ export default function page() {
|
|||||||
const groupedBySeries = useMemo(() => {
|
const groupedBySeries = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
const episodes = downloadedFiles?.filter(
|
const episodes = downloadedFiles?.filter(
|
||||||
(f) => f.item.Type === "Episode"
|
(f) => f.item.Type === "Episode",
|
||||||
);
|
);
|
||||||
const series: { [key: string]: DownloadedItem[] } = {};
|
const series: { [key: string]: DownloadedItem[] } = {};
|
||||||
episodes?.forEach((e) => {
|
episodes?.forEach((e) => {
|
||||||
@@ -73,14 +73,22 @@ export default function page() {
|
|||||||
|
|
||||||
const deleteMovies = () =>
|
const deleteMovies = () =>
|
||||||
deleteFileByType("Movie")
|
deleteFileByType("Movie")
|
||||||
.then(() => toast.success(t("home.downloads.toasts.deleted_all_movies_successfully")))
|
.then(() =>
|
||||||
|
toast.success(
|
||||||
|
t("home.downloads.toasts.deleted_all_movies_successfully"),
|
||||||
|
),
|
||||||
|
)
|
||||||
.catch((reason) => {
|
.catch((reason) => {
|
||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
||||||
});
|
});
|
||||||
const deleteShows = () =>
|
const deleteShows = () =>
|
||||||
deleteFileByType("Episode")
|
deleteFileByType("Episode")
|
||||||
.then(() => toast.success(t("home.downloads.toasts.deleted_all_tvseries_successfully")))
|
.then(() =>
|
||||||
|
toast.success(
|
||||||
|
t("home.downloads.toasts.deleted_all_tvseries_successfully"),
|
||||||
|
),
|
||||||
|
)
|
||||||
.catch((reason) => {
|
.catch((reason) => {
|
||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||||
@@ -97,26 +105,28 @@ export default function page() {
|
|||||||
paddingBottom: 100,
|
paddingBottom: 100,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="py-4">
|
<View className='py-4'>
|
||||||
<View className="mb-4 flex flex-col space-y-4 px-4">
|
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||||
{settings?.downloadMethod === DownloadMethod.Remux && (
|
{settings?.downloadMethod === DownloadMethod.Remux && (
|
||||||
<View className="bg-neutral-900 p-4 rounded-2xl">
|
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||||
<Text className="text-lg font-bold">{t("home.downloads.queue")}</Text>
|
<Text className='text-lg font-bold'>
|
||||||
<Text className="text-xs opacity-70 text-red-600">
|
{t("home.downloads.queue")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-xs opacity-70 text-red-600'>
|
||||||
{t("home.downloads.queue_hint")}
|
{t("home.downloads.queue_hint")}
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex flex-col space-y-2 mt-2">
|
<View className='flex flex-col space-y-2 mt-2'>
|
||||||
{queue.map((q, index) => (
|
{queue.map((q, index) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
||||||
}
|
}
|
||||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
|
||||||
key={index}
|
key={index}
|
||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-semibold">{q.item.Name}</Text>
|
<Text className='font-semibold'>{q.item.Name}</Text>
|
||||||
<Text className="text-xs opacity-50">
|
<Text className='text-xs opacity-50'>
|
||||||
{q.item.Type}
|
{q.item.Type}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -129,14 +139,16 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="close" size={24} color="red" />
|
<Ionicons name='close' size={24} color='red' />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{queue.length === 0 && (
|
{queue.length === 0 && (
|
||||||
<Text className="opacity-50">{t("home.downloads.no_items_in_queue")}</Text>
|
<Text className='opacity-50'>
|
||||||
|
{t("home.downloads.no_items_in_queue")}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -145,17 +157,19 @@ export default function page() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{movies.length > 0 && (
|
{movies.length > 0 && (
|
||||||
<View className="mb-4">
|
<View className='mb-4'>
|
||||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
<Text className="text-lg font-bold">{t("home.downloads.movies")}</Text>
|
<Text className='text-lg font-bold'>
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
{t("home.downloads.movies")}
|
||||||
<Text className="text-xs font-bold">{movies?.length}</Text>
|
</Text>
|
||||||
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
|
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
<View className="px-4 flex flex-row">
|
<View className='px-4 flex flex-row'>
|
||||||
{movies?.map((item) => (
|
{movies?.map((item) => (
|
||||||
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
<View className='mb-2 last:mb-0' key={item.item.Id}>
|
||||||
<MovieCard item={item.item} />
|
<MovieCard item={item.item} />
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -164,20 +178,22 @@ export default function page() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{groupedBySeries.length > 0 && (
|
{groupedBySeries.length > 0 && (
|
||||||
<View className="mb-4">
|
<View className='mb-4'>
|
||||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
<Text className="text-lg font-bold">{t("home.downloads.tvseries")}</Text>
|
<Text className='text-lg font-bold'>
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
{t("home.downloads.tvseries")}
|
||||||
<Text className="text-xs font-bold">
|
</Text>
|
||||||
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
|
<Text className='text-xs font-bold'>
|
||||||
{groupedBySeries?.length}
|
{groupedBySeries?.length}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
<View className="px-4 flex flex-row">
|
<View className='px-4 flex flex-row'>
|
||||||
{groupedBySeries?.map((items) => (
|
{groupedBySeries?.map((items) => (
|
||||||
<View
|
<View
|
||||||
className="mb-2 last:mb-0"
|
className='mb-2 last:mb-0'
|
||||||
key={items[0].item.SeriesId}
|
key={items[0].item.SeriesId}
|
||||||
>
|
>
|
||||||
<SeriesCard
|
<SeriesCard
|
||||||
@@ -191,8 +207,10 @@ export default function page() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{downloadedFiles?.length === 0 && (
|
{downloadedFiles?.length === 0 && (
|
||||||
<View className="flex px-4">
|
<View className='flex px-4'>
|
||||||
<Text className="opacity-50">{t("home.downloads.no_downloaded_items")}</Text>
|
<Text className='opacity-50'>
|
||||||
|
{t("home.downloads.no_downloaded_items")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -215,14 +233,14 @@ export default function page() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className="p-4 space-y-4 mb-4">
|
<View className='p-4 space-y-4 mb-4'>
|
||||||
<Button color="purple" onPress={deleteMovies}>
|
<Button color='purple' onPress={deleteMovies}>
|
||||||
{t("home.downloads.delete_all_movies_button")}
|
{t("home.downloads.delete_all_movies_button")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="purple" onPress={deleteShows}>
|
<Button color='purple' onPress={deleteShows}>
|
||||||
{t("home.downloads.delete_all_tvseries_button")}
|
{t("home.downloads.delete_all_tvseries_button")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="red" onPress={deleteAllMedia}>
|
<Button color='red' onPress={deleteAllMedia}>
|
||||||
{t("home.downloads.delete_all_button")}
|
{t("home.downloads.delete_all_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
@@ -248,6 +266,6 @@ function migration_20241124() {
|
|||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: async () => await deleteAllFiles(),
|
onPress: async () => await deleteAllFiles(),
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,498 +1,5 @@
|
|||||||
import { Button } from "@/components/Button";
|
import { HomeIndex } from "@/components/settings/HomeIndex";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
|
||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
BaseItemKind,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
|
||||||
getItemsApi,
|
|
||||||
getSuggestionsApi,
|
|
||||||
getTvShowsApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
getUserViewsApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import NetInfo from "@react-native-community/netinfo";
|
|
||||||
import { QueryFunction, useQuery } from "@tanstack/react-query";
|
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
RefreshControl,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import {
|
|
||||||
useSplashScreenLoading,
|
|
||||||
useSplashScreenVisible,
|
|
||||||
} from "@/providers/SplashScreenProvider";
|
|
||||||
|
|
||||||
type ScrollingCollectionListSection = {
|
export default function page() {
|
||||||
type: "ScrollingCollectionList";
|
return <HomeIndex />;
|
||||||
title?: string;
|
|
||||||
queryKey: (string | undefined | null)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
|
||||||
orientation?: "horizontal" | "vertical";
|
|
||||||
};
|
|
||||||
|
|
||||||
type MediaListSection = {
|
|
||||||
type: "MediaListSection";
|
|
||||||
queryKey: (string | undefined)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Section = ScrollingCollectionListSection | MediaListSection;
|
|
||||||
|
|
||||||
export default function index() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
pluginSettings,
|
|
||||||
setPluginSettings,
|
|
||||||
refreshStreamyfinPluginSettings,
|
|
||||||
] = useSettings();
|
|
||||||
|
|
||||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
|
||||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
|
||||||
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
|
||||||
const { downloadedFiles, cleanCacheDirectory } = useDownload();
|
|
||||||
useEffect(() => {
|
|
||||||
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/(auth)/downloads");
|
|
||||||
}}
|
|
||||||
className="p-2"
|
|
||||||
>
|
|
||||||
<Feather
|
|
||||||
name="download"
|
|
||||||
color={hasDownloads ? Colors.primary : "white"}
|
|
||||||
size={22}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [downloadedFiles, navigation, router]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
cleanCacheDirectory().catch((e) =>
|
|
||||||
console.error("Something went wrong cleaning cache directory")
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkConnection = useCallback(async () => {
|
|
||||||
setLoadingRetry(true);
|
|
||||||
const state = await NetInfo.fetch();
|
|
||||||
setIsConnected(state.isConnected);
|
|
||||||
setLoadingRetry(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
|
||||||
if (state.isConnected == false || state.isInternetReachable === false)
|
|
||||||
setIsConnected(false);
|
|
||||||
else setIsConnected(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
NetInfo.fetch().then((state) => {
|
|
||||||
setIsConnected(state.isConnected);
|
|
||||||
});
|
|
||||||
|
|
||||||
// cleanCacheDirectory().catch((e) =>
|
|
||||||
// console.error("Something went wrong cleaning cache directory")
|
|
||||||
// );
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
isError: e1,
|
|
||||||
isLoading: l1,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["home", "userViews", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getUserViewsApi(api).getUserViews({
|
|
||||||
userId: user.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items || null;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// show splash screen until query loaded
|
|
||||||
useSplashScreenLoading(l1);
|
|
||||||
const splashScreenVisible = useSplashScreenVisible();
|
|
||||||
|
|
||||||
const userViews = useMemo(
|
|
||||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
|
||||||
[data, settings?.hiddenLibraries]
|
|
||||||
);
|
|
||||||
|
|
||||||
const collections = useMemo(() => {
|
|
||||||
const allow = ["movies", "tvshows"];
|
|
||||||
return (
|
|
||||||
userViews?.filter(
|
|
||||||
(c) => c.CollectionType && allow.includes(c.CollectionType)
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
}, [userViews]);
|
|
||||||
|
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
|
||||||
|
|
||||||
const refetch = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
await refreshStreamyfinPluginSettings();
|
|
||||||
await invalidateCache();
|
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const createCollectionConfig = useCallback(
|
|
||||||
(
|
|
||||||
title: string,
|
|
||||||
queryKey: string[],
|
|
||||||
includeItemTypes: BaseItemKind[],
|
|
||||||
parentId: string | undefined
|
|
||||||
): ScrollingCollectionListSection => ({
|
|
||||||
title,
|
|
||||||
queryKey,
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return [];
|
|
||||||
return (
|
|
||||||
(
|
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 20,
|
|
||||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
includeItemTypes,
|
|
||||||
parentId,
|
|
||||||
})
|
|
||||||
).data || []
|
|
||||||
);
|
|
||||||
},
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
}),
|
|
||||||
[api, user?.Id]
|
|
||||||
);
|
|
||||||
|
|
||||||
let sections: Section[] = [];
|
|
||||||
if (!settings?.home || !settings?.home?.sections) {
|
|
||||||
sections = useMemo(() => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
|
|
||||||
const latestMediaViews = collections.map((c) => {
|
|
||||||
const includeItemTypes: BaseItemKind[] =
|
|
||||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
|
||||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
|
||||||
const queryKey = [
|
|
||||||
"home",
|
|
||||||
"recentlyAddedIn" + c.CollectionType,
|
|
||||||
user?.Id!,
|
|
||||||
c.Id!,
|
|
||||||
];
|
|
||||||
return createCollectionConfig(
|
|
||||||
title || "",
|
|
||||||
queryKey,
|
|
||||||
includeItemTypes,
|
|
||||||
c.Id
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ss: Section[] = [
|
|
||||||
{
|
|
||||||
title: t("home.continue_watching"),
|
|
||||||
queryKey: ["home", "resumeItems"],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getItemsApi(api).getResumeItems({
|
|
||||||
userId: user.Id,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("home.next_up"),
|
|
||||||
queryKey: ["home", "nextUp-all"],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user?.Id,
|
|
||||||
fields: ["MediaSourceCount"],
|
|
||||||
limit: 20,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
enableResumable: false,
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
...latestMediaViews,
|
|
||||||
// ...(mediaListCollections?.map(
|
|
||||||
// (ml) =>
|
|
||||||
// ({
|
|
||||||
// title: ml.Name,
|
|
||||||
// queryKey: ["home", "mediaList", ml.Id!],
|
|
||||||
// queryFn: async () => ml,
|
|
||||||
// type: "MediaListSection",
|
|
||||||
// orientation: "vertical",
|
|
||||||
// } as Section)
|
|
||||||
// ) || []),
|
|
||||||
{
|
|
||||||
title: t("home.suggested_movies"),
|
|
||||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 10,
|
|
||||||
mediaType: ["Video"],
|
|
||||||
type: ["Movie"],
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "vertical",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("home.suggested_episodes"),
|
|
||||||
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
try {
|
|
||||||
const suggestions = await getSuggestions(api, user.Id);
|
|
||||||
const nextUpPromises = suggestions.map((series) =>
|
|
||||||
getNextUp(api, user.Id, series.Id)
|
|
||||||
);
|
|
||||||
const nextUpResults = await Promise.all(nextUpPromises);
|
|
||||||
|
|
||||||
return nextUpResults.filter((item) => item !== null) || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching data:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return ss;
|
|
||||||
}, [api, user?.Id, collections]);
|
|
||||||
} else {
|
|
||||||
sections = useMemo(() => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
const ss: Section[] = [];
|
|
||||||
|
|
||||||
for (const key in settings.home?.sections) {
|
|
||||||
// @ts-expect-error
|
|
||||||
const section = settings.home?.sections[key];
|
|
||||||
const id = section.title || key;
|
|
||||||
ss.push({
|
|
||||||
title: id,
|
|
||||||
queryKey: ["home", id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (section.items) {
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: section.items?.limit || 25,
|
|
||||||
recursive: true,
|
|
||||||
includeItemTypes: section.items?.includeItemTypes,
|
|
||||||
sortBy: section.items?.sortBy,
|
|
||||||
sortOrder: section.items?.sortOrder,
|
|
||||||
filters: section.items?.filters,
|
|
||||||
parentId: section.items?.parentId,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
} else if (section.nextUp) {
|
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user?.Id,
|
|
||||||
fields: ["MediaSourceCount"],
|
|
||||||
limit: section.items?.limit || 25,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
enableResumable: section.items?.enableResumable || false,
|
|
||||||
enableRewatching: section.items?.enableRewatching || false,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: section?.orientation || "vertical",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return ss;
|
|
||||||
}, [api, user?.Id, settings.home?.sections]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isConnected === false) {
|
|
||||||
return (
|
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
|
||||||
<Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
|
|
||||||
<Text className="text-center opacity-70">
|
|
||||||
{t("home.no_internet_message")}
|
|
||||||
</Text>
|
|
||||||
<View className="mt-4">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onPress={() => router.push("/(auth)/downloads")}
|
|
||||||
justify="center"
|
|
||||||
iconRight={
|
|
||||||
<Ionicons name="arrow-forward" size={20} color="white" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("home.go_to_downloads")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="black"
|
|
||||||
onPress={() => {
|
|
||||||
checkConnection();
|
|
||||||
}}
|
|
||||||
justify="center"
|
|
||||||
className="mt-2"
|
|
||||||
iconRight={
|
|
||||||
loadingRetry ? null : (
|
|
||||||
<Ionicons name="refresh" size={20} color="white" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{loadingRetry ? (
|
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
|
||||||
) : (
|
|
||||||
"Retry"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e1)
|
|
||||||
return (
|
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
|
||||||
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
|
|
||||||
<Text className="text-center opacity-70">
|
|
||||||
{t("home.error_message")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
// this spinner should only show up, when user navigates here
|
|
||||||
// on launch the splash screen is used for loading
|
|
||||||
if (l1 && !splashScreenVisible)
|
|
||||||
return (
|
|
||||||
<View className="justify-center items-center h-full">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
nestedScrollEnabled
|
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
|
||||||
}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
paddingBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col space-y-4">
|
|
||||||
<LargeMovieCarousel />
|
|
||||||
|
|
||||||
{sections.map((section, index) => {
|
|
||||||
if (section.type === "ScrollingCollectionList") {
|
|
||||||
return (
|
|
||||||
<ScrollingCollectionList
|
|
||||||
key={index}
|
|
||||||
title={section.title}
|
|
||||||
queryKey={section.queryKey}
|
|
||||||
queryFn={section.queryFn}
|
|
||||||
orientation={section.orientation}
|
|
||||||
hideIfEmpty
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (section.type === "MediaListSection") {
|
|
||||||
return (
|
|
||||||
<MediaListSection
|
|
||||||
key={index}
|
|
||||||
queryKey={section.queryKey}
|
|
||||||
queryFn={section.queryFn}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to get suggestions
|
|
||||||
async function getSuggestions(api: Api, userId: string | undefined) {
|
|
||||||
if (!userId) return [];
|
|
||||||
const response = await getSuggestionsApi(api).getSuggestions({
|
|
||||||
userId,
|
|
||||||
limit: 10,
|
|
||||||
mediaType: ["Unknown"],
|
|
||||||
type: ["Series"],
|
|
||||||
});
|
|
||||||
return response.data.Items ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to get the next up TV show for a series
|
|
||||||
async function getNextUp(
|
|
||||||
api: Api,
|
|
||||||
userId: string | undefined,
|
|
||||||
seriesId: string | undefined
|
|
||||||
) {
|
|
||||||
if (!userId || !seriesId) return null;
|
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
|
||||||
userId,
|
|
||||||
seriesId,
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
return response.data.Items?.[0] ?? null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,26 +15,26 @@ export default function page() {
|
|||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
storage.set("hasShownIntro", true);
|
storage.set("hasShownIntro", true);
|
||||||
}, [])
|
}, []),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="bg-neutral-900 h-full py-16 px-4 space-y-8">
|
<View className='bg-neutral-900 h-full py-16 px-4 space-y-8'>
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-3xl font-bold text-center mb-2">
|
<Text className='text-3xl font-bold text-center mb-2'>
|
||||||
{t("home.intro.welcome_to_streamyfin")}
|
{t("home.intro.welcome_to_streamyfin")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-center">
|
<Text className='text-center'>
|
||||||
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
|
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-lg font-bold">
|
<Text className='text-lg font-bold'>
|
||||||
{t("home.intro.features_title")}
|
{t("home.intro.features_title")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-xs">{t("home.intro.features_description")}</Text>
|
<Text className='text-xs'>{t("home.intro.features_description")}</Text>
|
||||||
<View className="flex flex-row items-center mt-4">
|
<View className='flex flex-row items-center mt-4'>
|
||||||
<Image
|
<Image
|
||||||
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
||||||
style={{
|
style={{
|
||||||
@@ -42,70 +42,70 @@ export default function page() {
|
|||||||
height: 50,
|
height: 50,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View className="shrink ml-2">
|
<View className='shrink ml-2'>
|
||||||
<Text className="font-bold mb-1">Jellyseerr</Text>
|
<Text className='font-bold mb-1'>Jellyseerr</Text>
|
||||||
<Text className="shrink text-xs">
|
<Text className='shrink text-xs'>
|
||||||
{t("home.intro.jellyseerr_feature_description")}
|
{t("home.intro.jellyseerr_feature_description")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-row items-center mt-4">
|
<View className='flex flex-row items-center mt-4'>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-center"
|
className='flex items-center justify-center'
|
||||||
>
|
>
|
||||||
<Ionicons name="cloud-download-outline" size={32} color="white" />
|
<Ionicons name='cloud-download-outline' size={32} color='white' />
|
||||||
</View>
|
</View>
|
||||||
<View className="shrink ml-2">
|
<View className='shrink ml-2'>
|
||||||
<Text className="font-bold mb-1">
|
<Text className='font-bold mb-1'>
|
||||||
{t("home.intro.downloads_feature_title")}
|
{t("home.intro.downloads_feature_title")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="shrink text-xs">
|
<Text className='shrink text-xs'>
|
||||||
{t("home.intro.downloads_feature_description")}
|
{t("home.intro.downloads_feature_description")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-row items-center mt-4">
|
<View className='flex flex-row items-center mt-4'>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-center"
|
className='flex items-center justify-center'
|
||||||
>
|
>
|
||||||
<Feather name="cast" size={28} color={"white"} />
|
<Feather name='cast' size={28} color={"white"} />
|
||||||
</View>
|
</View>
|
||||||
<View className="shrink ml-2">
|
<View className='shrink ml-2'>
|
||||||
<Text className="font-bold mb-1">Chromecast</Text>
|
<Text className='font-bold mb-1'>Chromecast</Text>
|
||||||
<Text className="shrink text-xs">
|
<Text className='shrink text-xs'>
|
||||||
{t("home.intro.chromecast_feature_description")}
|
{t("home.intro.chromecast_feature_description")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-row items-center mt-4">
|
<View className='flex flex-row items-center mt-4'>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-center"
|
className='flex items-center justify-center'
|
||||||
>
|
>
|
||||||
<Feather name="settings" size={28} color={"white"} />
|
<Feather name='settings' size={28} color={"white"} />
|
||||||
</View>
|
</View>
|
||||||
<View className="shrink ml-2">
|
<View className='shrink ml-2'>
|
||||||
<Text className="font-bold mb-1">
|
<Text className='font-bold mb-1'>
|
||||||
{t("home.intro.centralised_settings_plugin_title")}
|
{t("home.intro.centralised_settings_plugin_title")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="shrink text-xs">
|
<Text className='shrink text-xs'>
|
||||||
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
||||||
<Text
|
<Text
|
||||||
className="text-purple-600"
|
className='text-purple-600'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
Linking.openURL(
|
Linking.openURL(
|
||||||
"https://github.com/streamyfin/jellyfin-plugin-streamyfin"
|
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -120,7 +120,7 @@ export default function page() {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.back();
|
router.back();
|
||||||
}}
|
}}
|
||||||
className="mt-4"
|
className='mt-4'
|
||||||
>
|
>
|
||||||
{t("home.intro.done_button")}
|
{t("home.intro.done_button")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -129,9 +129,9 @@ export default function page() {
|
|||||||
router.back();
|
router.back();
|
||||||
router.push("/settings");
|
router.push("/settings");
|
||||||
}}
|
}}
|
||||||
className="mt-4"
|
className='mt-4'
|
||||||
>
|
>
|
||||||
<Text className="text-purple-600 text-center">
|
<Text className='text-purple-600 text-center'>
|
||||||
{t("home.intro.go_to_settings_button")}
|
{t("home.intro.go_to_settings_button")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
556
app/(auth)/(tabs)/(home)/sessions/index.tsx
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
import { Badge } from "@/components/Badge";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import Poster from "@/components/posters/Poster";
|
||||||
|
import { useInterval } from "@/hooks/useInterval";
|
||||||
|
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { formatTimeString } from "@/utils/time";
|
||||||
|
import {
|
||||||
|
AntDesign,
|
||||||
|
Entypo,
|
||||||
|
Ionicons,
|
||||||
|
MaterialCommunityIcons,
|
||||||
|
} from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
HardwareAccelerationType,
|
||||||
|
type SessionInfoDto,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import {
|
||||||
|
GeneralCommandType,
|
||||||
|
PlaystateCommand,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { get } from "lodash";
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<View className='justify-center items-center h-full'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sessions || sessions.length === 0)
|
||||||
|
return (
|
||||||
|
<View className='h-full w-full flex justify-center items-center'>
|
||||||
|
<Text className='text-lg text-neutral-500'>
|
||||||
|
{t("home.sessions.no_active_sessions")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: 17,
|
||||||
|
paddingHorizontal: 17,
|
||||||
|
paddingBottom: 150,
|
||||||
|
}}
|
||||||
|
data={sessions}
|
||||||
|
renderItem={({ item }) => <SessionCard session={item} />}
|
||||||
|
keyExtractor={(item) => item.Id || ""}
|
||||||
|
estimatedItemSize={200}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionCardProps {
|
||||||
|
session: SessionInfoDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionCard = ({ session }: SessionCardProps) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const [remainingTicks, setRemainingTicks] = useState<number>(0);
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (session.PlayState?.IsPaused) return;
|
||||||
|
setRemainingTicks(remainingTicks - 10000000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgressPercentage = () => {
|
||||||
|
if (!session.NowPlayingItem || !session.NowPlayingItem.RunTimeTicks) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round(
|
||||||
|
(100 / session.NowPlayingItem?.RunTimeTicks) *
|
||||||
|
(session.NowPlayingItem?.RunTimeTicks - remainingTicks),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTime = session.PlayState?.PositionTicks;
|
||||||
|
const duration = session.NowPlayingItem?.RunTimeTicks;
|
||||||
|
if (
|
||||||
|
duration !== null &&
|
||||||
|
duration !== undefined &&
|
||||||
|
currentTime !== null &&
|
||||||
|
currentTime !== undefined
|
||||||
|
) {
|
||||||
|
const remainingTimeTicks = duration - currentTime;
|
||||||
|
setRemainingTicks(remainingTimeTicks);
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const { data: ipInfo } = useQuery({
|
||||||
|
queryKey: ["ipinfo", session.RemoteEndPoint],
|
||||||
|
cacheTime: Number.POSITIVE_INFINITY,
|
||||||
|
queryFn: async () => {
|
||||||
|
const resp = await api.axiosInstance.get(
|
||||||
|
`https://freeipapi.com/api/json/${session.RemoteEndPoint}`,
|
||||||
|
);
|
||||||
|
return resp.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle session controls
|
||||||
|
const [isControlLoading, setIsControlLoading] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const handleSystemCommand = async (command: GeneralCommandType) => {
|
||||||
|
if (!api || !session.Id) return false;
|
||||||
|
|
||||||
|
setIsControlLoading({ ...isControlLoading, [command]: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
getSessionApi(api).sendSystemCommand({
|
||||||
|
sessionId: session.Id,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error sending ${command} command:`, error);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsControlLoading({ ...isControlLoading, [command]: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlaystateCommand = async (command: PlaystateCommand) => {
|
||||||
|
if (!api || !session.Id) return false;
|
||||||
|
|
||||||
|
setIsControlLoading({ ...isControlLoading, [command]: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
getSessionApi(api).sendPlaystateCommand({
|
||||||
|
sessionId: session.Id,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error sending playstate ${command} command:`, error);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsControlLoading({ ...isControlLoading, [command]: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlayPause = async () => {
|
||||||
|
console.log("handlePlayPause");
|
||||||
|
await handlePlaystateCommand(PlaystateCommand.PlayPause);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = async () => {
|
||||||
|
await handlePlaystateCommand(PlaystateCommand.Stop);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevious = async () => {
|
||||||
|
await handlePlaystateCommand(PlaystateCommand.PreviousTrack);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = async () => {
|
||||||
|
await handlePlaystateCommand(PlaystateCommand.NextTrack);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleMute = async () => {
|
||||||
|
await handleSystemCommand(GeneralCommandType.ToggleMute);
|
||||||
|
};
|
||||||
|
const handleVolumeUp = async () => {
|
||||||
|
await handleSystemCommand(GeneralCommandType.VolumeUp);
|
||||||
|
};
|
||||||
|
const handleVolumeDown = async () => {
|
||||||
|
await handleSystemCommand(GeneralCommandType.VolumeDown);
|
||||||
|
};
|
||||||
|
|
||||||
|
useInterval(tick, 1000);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex flex-col shadow-md bg-neutral-900 rounded-2xl mb-4'>
|
||||||
|
<View className='flex flex-row p-4'>
|
||||||
|
<View className='w-20 pr-4'>
|
||||||
|
<Poster
|
||||||
|
id={session.NowPlayingItem?.Id}
|
||||||
|
url={getPrimaryImageUrl({ api, item: session.NowPlayingItem })}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className='w-full flex-1'>
|
||||||
|
<View className='flex flex-row justify-between'>
|
||||||
|
<View className='flex-1 pr-4'>
|
||||||
|
{session.NowPlayingItem?.Type === "Episode" ? (
|
||||||
|
<>
|
||||||
|
<Text className='font-bold'>
|
||||||
|
{session.NowPlayingItem?.Name}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} className='text-xs opacity-50'>
|
||||||
|
{`S${session.NowPlayingItem.ParentIndexNumber?.toString()}:E${session.NowPlayingItem.IndexNumber?.toString()}`}
|
||||||
|
{" - "}
|
||||||
|
{session.NowPlayingItem.SeriesName}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text className='font-bold'>
|
||||||
|
{session.NowPlayingItem?.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-xs opacity-50'>
|
||||||
|
{session.NowPlayingItem?.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-xs opacity-50'>
|
||||||
|
{session.NowPlayingItem?.SeriesName}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text className='text-xs opacity-50 align-right text-right'>
|
||||||
|
{session.UserName}
|
||||||
|
{"\n"}
|
||||||
|
{session.Client}
|
||||||
|
{"\n"}
|
||||||
|
{session.DeviceName}
|
||||||
|
{"\n"}
|
||||||
|
{ipInfo?.cityName} {ipInfo?.countryCode}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='flex-1' />
|
||||||
|
<View className='flex flex-col align-bottom'>
|
||||||
|
<View className='flex flex-row justify-between align-bottom mb-1'>
|
||||||
|
<Text className='-ml-0.5 text-xs opacity-50 align-left text-left'>
|
||||||
|
{!session.PlayState?.IsPaused ? (
|
||||||
|
<Ionicons name='play' size={14} color='white' />
|
||||||
|
) : (
|
||||||
|
<Ionicons name='pause' size={14} color='white' />
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-xs opacity-50 align-right text-right'>
|
||||||
|
{formatTimeString(remainingTicks, "tick")} left
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='align-bottom bg-gray-800 h-1'>
|
||||||
|
<View
|
||||||
|
className={"bg-purple-600 h-full"}
|
||||||
|
style={{
|
||||||
|
width: `${getProgressPercentage()}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Session controls */}
|
||||||
|
<View className='flex flex-row mt-2 space-x-4 justify-center'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePrevious}
|
||||||
|
disabled={isControlLoading[PlaystateCommand.PreviousTrack]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[PlaystateCommand.PreviousTrack]
|
||||||
|
? 0.5
|
||||||
|
: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name='skip-previous'
|
||||||
|
size={24}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePlayPause}
|
||||||
|
disabled={isControlLoading[PlaystateCommand.PlayPause]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[PlaystateCommand.PlayPause]
|
||||||
|
? 0.5
|
||||||
|
: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session.PlayState?.IsPaused ? (
|
||||||
|
<Ionicons name='play' size={24} color='white' />
|
||||||
|
) : (
|
||||||
|
<Ionicons name='pause' size={24} color='white' />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleStop}
|
||||||
|
disabled={isControlLoading[PlaystateCommand.Stop]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[PlaystateCommand.Stop] ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='stop' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleNext}
|
||||||
|
disabled={isControlLoading[PlaystateCommand.NextTrack]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[PlaystateCommand.NextTrack]
|
||||||
|
? 0.5
|
||||||
|
: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name='skip-next'
|
||||||
|
size={24}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleVolumeDown}
|
||||||
|
disabled={isControlLoading[GeneralCommandType.VolumeDown]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[GeneralCommandType.VolumeDown]
|
||||||
|
? 0.5
|
||||||
|
: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='volume-low' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleToggleMute}
|
||||||
|
disabled={isControlLoading[GeneralCommandType.ToggleMute]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[GeneralCommandType.ToggleMute]
|
||||||
|
? 0.5
|
||||||
|
: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='volume-mute'
|
||||||
|
size={24}
|
||||||
|
color={session.PlayState?.IsMuted ? "red" : "white"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleVolumeUp}
|
||||||
|
disabled={isControlLoading[GeneralCommandType.VolumeUp]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[GeneralCommandType.VolumeUp]
|
||||||
|
? 0.5
|
||||||
|
: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='volume-high' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TranscodingView session={session} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TranscodingBadgesProps {
|
||||||
|
properties: StreamProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
|
||||||
|
const iconMap = {
|
||||||
|
bitrate: <Ionicons name='speedometer-outline' size={12} color='white' />,
|
||||||
|
codec: <Ionicons name='layers-outline' size={12} color='white' />,
|
||||||
|
videoRange: (
|
||||||
|
<Ionicons name='color-palette-outline' size={12} color='white' />
|
||||||
|
),
|
||||||
|
resolution: <Ionicons name='film-outline' size={12} color='white' />,
|
||||||
|
language: <Ionicons name='language-outline' size={12} color='white' />,
|
||||||
|
audioChannels: <Ionicons name='mic-outline' size={12} color='white' />,
|
||||||
|
hwType: <Ionicons name='hardware-chip-outline' size={12} color='white' />,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const icon = (val: string) => {
|
||||||
|
return (
|
||||||
|
iconMap[val as keyof typeof iconMap] ?? (
|
||||||
|
<Ionicons name='layers-outline' size={12} color='white' />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatVal = (key: string, val: any) => {
|
||||||
|
switch (key) {
|
||||||
|
case "bitrate":
|
||||||
|
return formatBitrate(val);
|
||||||
|
case "hwType":
|
||||||
|
return val === HardwareAccelerationType.None ? "sw" : "hw";
|
||||||
|
default:
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.entries(properties)
|
||||||
|
.filter(([_, value]) => value !== undefined && value !== null)
|
||||||
|
.map(([key]) => (
|
||||||
|
<Badge
|
||||||
|
key={key}
|
||||||
|
variant='gray'
|
||||||
|
className='m-0 p-0 pt-0.5 mr-1'
|
||||||
|
text={formatVal(key, properties[key as keyof StreamProps])}
|
||||||
|
iconLeft={icon(key)}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StreamProps {
|
||||||
|
hwType?: HardwareAccelerationType | null | undefined;
|
||||||
|
resolution?: string | null | undefined;
|
||||||
|
language?: string | null | undefined;
|
||||||
|
codec?: string | null | undefined;
|
||||||
|
bitrate?: number | null | undefined;
|
||||||
|
videoRange?: string | null | undefined;
|
||||||
|
audioChannels?: string | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TranscodingStreamViewProps {
|
||||||
|
title: string | undefined;
|
||||||
|
value?: string;
|
||||||
|
isTranscoding: boolean;
|
||||||
|
transcodeValue?: string | undefined | null;
|
||||||
|
properties: StreamProps;
|
||||||
|
transcodeProperties?: StreamProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TranscodingStreamView = ({
|
||||||
|
title,
|
||||||
|
isTranscoding,
|
||||||
|
properties,
|
||||||
|
transcodeProperties,
|
||||||
|
value,
|
||||||
|
transcodeValue,
|
||||||
|
}: TranscodingStreamViewProps) => {
|
||||||
|
return (
|
||||||
|
<View className='flex flex-col pt-2 first:pt-0'>
|
||||||
|
<View className='flex flex-row'>
|
||||||
|
<Text className='text-xs opacity-50 w-20 font-bold text-right pr-4'>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text className='flex-1'>
|
||||||
|
<TranscodingBadges properties={properties} />
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isTranscoding && transcodeProperties ? (
|
||||||
|
<>
|
||||||
|
<View className='flex flex-row'>
|
||||||
|
<Text className='-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4'>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name='arrow-right-bottom'
|
||||||
|
size={14}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
<Text className='flex-1 text-sm mt-1'>
|
||||||
|
<TranscodingBadges properties={transcodeProperties} />
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TranscodingView = ({ session }: SessionCardProps) => {
|
||||||
|
const videoStream = useMemo(() => {
|
||||||
|
return session.NowPlayingItem?.MediaStreams?.filter(
|
||||||
|
(s) => s.Type === "Video",
|
||||||
|
)[0];
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const audioStream = useMemo(() => {
|
||||||
|
const index = session.PlayState?.AudioStreamIndex;
|
||||||
|
return index !== null && index !== undefined
|
||||||
|
? session.NowPlayingItem?.MediaStreams?.[index]
|
||||||
|
: undefined;
|
||||||
|
}, [session.PlayState?.AudioStreamIndex]);
|
||||||
|
|
||||||
|
const subtitleStream = useMemo(() => {
|
||||||
|
const index = session.PlayState?.SubtitleStreamIndex;
|
||||||
|
return index !== null && index !== undefined
|
||||||
|
? session.NowPlayingItem?.MediaStreams?.[index]
|
||||||
|
: undefined;
|
||||||
|
}, [session.PlayState?.SubtitleStreamIndex]);
|
||||||
|
|
||||||
|
const isTranscoding = useMemo(() => {
|
||||||
|
return (
|
||||||
|
session.PlayState?.PlayMethod === "Transcode" && session.TranscodingInfo
|
||||||
|
);
|
||||||
|
}, [session.PlayState?.PlayMethod, session.TranscodingInfo]);
|
||||||
|
|
||||||
|
const videoStreamTitle = () => {
|
||||||
|
return videoStream?.DisplayTitle?.split(" ")[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2'>
|
||||||
|
<TranscodingStreamView
|
||||||
|
title='Video'
|
||||||
|
properties={{
|
||||||
|
resolution: videoStreamTitle(),
|
||||||
|
bitrate: videoStream?.BitRate,
|
||||||
|
codec: videoStream?.Codec,
|
||||||
|
}}
|
||||||
|
transcodeProperties={{
|
||||||
|
hwType: session.TranscodingInfo?.HardwareAccelerationType,
|
||||||
|
bitrate: session.TranscodingInfo?.Bitrate,
|
||||||
|
codec: session.TranscodingInfo?.VideoCodec,
|
||||||
|
}}
|
||||||
|
isTranscoding={
|
||||||
|
!!(isTranscoding && !session.TranscodingInfo?.IsVideoDirect)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TranscodingStreamView
|
||||||
|
title='Audio'
|
||||||
|
properties={{
|
||||||
|
language: audioStream?.Language,
|
||||||
|
bitrate: audioStream?.BitRate,
|
||||||
|
codec: audioStream?.Codec,
|
||||||
|
audioChannels: audioStream?.ChannelLayout,
|
||||||
|
}}
|
||||||
|
transcodeProperties={{
|
||||||
|
codec: session.TranscodingInfo?.AudioCodec,
|
||||||
|
audioChannels: session.TranscodingInfo?.AudioChannels?.toString(),
|
||||||
|
}}
|
||||||
|
isTranscoding={
|
||||||
|
!!(isTranscoding && !session.TranscodingInfo?.IsVideoDirect)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{subtitleStream && (
|
||||||
|
<TranscodingStreamView
|
||||||
|
title='Subtitle'
|
||||||
|
isTranscoding={false}
|
||||||
|
properties={{
|
||||||
|
language: subtitleStream?.Language,
|
||||||
|
codec: subtitleStream?.Codec,
|
||||||
|
}}
|
||||||
|
transcodeValue={null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Platform } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||||
|
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
|
||||||
|
import DownloadSettings from "@/components/settings/DownloadSettings";
|
||||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||||
import { OtherSettings } from "@/components/settings/OtherSettings";
|
import { OtherSettings } from "@/components/settings/OtherSettings";
|
||||||
@@ -10,24 +12,23 @@ import { PluginSettings } from "@/components/settings/PluginSettings";
|
|||||||
import { QuickConnect } from "@/components/settings/QuickConnect";
|
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||||
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
|
||||||
import { UserInfo } from "@/components/settings/UserInfo";
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
import { useJellyfin } from "@/providers/JellyfinProvider";
|
|
||||||
import { clearLogs } from "@/utils/log";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { clearLogs } from "@/utils/log";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import React, { lazy, useEffect } from "react";
|
import { useAtom } from "jotai";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import React, { useEffect } from "react";
|
||||||
|
import { ScrollView, Switch, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
const DownloadSettings = lazy(
|
|
||||||
() => import("@/components/settings/DownloadSettings")
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ export default function settings() {
|
|||||||
logout();
|
logout();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className="text-red-600">
|
<Text className='text-red-600'>
|
||||||
{t("home.settings.log_out_button")}
|
{t("home.settings.log_out_button")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -60,24 +61,27 @@ export default function settings() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="p-4 flex flex-col gap-y-4">
|
<View className='p-4 flex flex-col gap-y-4'>
|
||||||
<UserInfo />
|
<UserInfo />
|
||||||
<QuickConnect className="mb-4" />
|
|
||||||
|
<QuickConnect className='mb-4' />
|
||||||
|
|
||||||
<MediaProvider>
|
<MediaProvider>
|
||||||
<MediaToggles className="mb-4" />
|
<MediaToggles className='mb-4' />
|
||||||
<AudioToggles className="mb-4" />
|
<AudioToggles className='mb-4' />
|
||||||
<SubtitleToggles className="mb-4" />
|
<SubtitleToggles className='mb-4' />
|
||||||
</MediaProvider>
|
</MediaProvider>
|
||||||
|
|
||||||
<OtherSettings />
|
<OtherSettings />
|
||||||
|
|
||||||
{!Platform.isTV && <DownloadSettings />}
|
<DownloadSettings />
|
||||||
|
|
||||||
<PluginSettings />
|
<PluginSettings />
|
||||||
|
|
||||||
<AppLanguageSelector />
|
<AppLanguageSelector />
|
||||||
|
|
||||||
|
<ChromecastSettings />
|
||||||
|
|
||||||
<ListGroup title={"Intro"}>
|
<ListGroup title={"Intro"}>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -86,7 +90,7 @@ export default function settings() {
|
|||||||
title={t("home.settings.intro.show_intro")}
|
title={t("home.settings.intro.show_intro")}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
textColor="red"
|
textColor='red'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
storage.set("hasShownIntro", false);
|
storage.set("hasShownIntro", false);
|
||||||
}}
|
}}
|
||||||
@@ -94,7 +98,7 @@ export default function settings() {
|
|||||||
/>
|
/>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
<View className="mb-4">
|
<View className='mb-4'>
|
||||||
<ListGroup title={t("home.settings.logs.logs_title")}>
|
<ListGroup title={t("home.settings.logs.logs_title")}>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/logs/page")}
|
onPress={() => router.push("/settings/logs/page")}
|
||||||
@@ -102,7 +106,7 @@ export default function settings() {
|
|||||||
title={t("home.settings.logs.logs_title")}
|
title={t("home.settings.logs.logs_title")}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
textColor="red"
|
textColor='red'
|
||||||
onPress={onClearLogsClicked}
|
onPress={onClearLogsClicked}
|
||||||
title={t("home.settings.logs.delete_all_logs")}
|
title={t("home.settings.logs.delete_all_logs")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
|
import { Loader } from "@/components/Loader";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import { Loader } from "@/components/Loader";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch, View } from "react-native";
|
import { Switch, View } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
@@ -18,7 +18,7 @@ export default function page() {
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data, isLoading: isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["user-views", user?.Id],
|
queryKey: ["user-views", user?.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await getUserViewsApi(api!).getUserViews({
|
const response = await getUserViewsApi(api!).getUserViews({
|
||||||
@@ -33,7 +33,7 @@ export default function page() {
|
|||||||
|
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return (
|
||||||
<View className="mt-4">
|
<View className='mt-4'>
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -41,7 +41,7 @@ export default function page() {
|
|||||||
return (
|
return (
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
||||||
className="px-4"
|
className='px-4'
|
||||||
>
|
>
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
{data?.map((view) => (
|
{data?.map((view) => (
|
||||||
@@ -59,8 +59,8 @@ export default function page() {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
@@ -8,7 +8,7 @@ export default function page() {
|
|||||||
return (
|
return (
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||||
className="p-4"
|
className='p-4'
|
||||||
>
|
>
|
||||||
<JellyseerrSettings />
|
<JellyseerrSettings />
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
|
|||||||
@@ -1,35 +1,157 @@
|
|||||||
|
import { Loader } from "@/components/Loader";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useLog } from "@/utils/log";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { ScrollView, View } from "react-native";
|
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import * as Sharing from "expo-sharing";
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import Collapsible from "react-native-collapsible";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
const { logs } = useLog();
|
const { logs } = useLog();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const defaultLevels: LogLevel[] = ["INFO", "ERROR", "DEBUG", "WARN"];
|
||||||
|
const codeBlockStyle = {
|
||||||
|
backgroundColor: "#000",
|
||||||
|
padding: 10,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
maxHeight: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [state, setState] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const [order, setOrder] = useState<"asc" | "desc">("desc");
|
||||||
|
const [levels, setLevels] = useState<LogLevel[]>(defaultLevels);
|
||||||
|
|
||||||
|
const filteredLogs = useMemo(
|
||||||
|
() =>
|
||||||
|
logs
|
||||||
|
?.filter((log) => levels.includes(log.level))
|
||||||
|
?.[
|
||||||
|
// Already in asc order as they are recorded. just reverse for desc
|
||||||
|
order === "desc" ? "reverse" : "concat"
|
||||||
|
]?.(),
|
||||||
|
[logs, order, levels],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sharing it as txt while its formatted allows us to share it with many more applications
|
||||||
|
const share = useCallback(async () => {
|
||||||
|
const uri = `${FileSystem.documentDirectory}logs.txt`;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
FileSystem.writeAsStringAsync(uri, JSON.stringify(filteredLogs))
|
||||||
|
.then(() => {
|
||||||
|
setLoading(false);
|
||||||
|
Sharing.shareAsync(uri, { mimeType: "txt", UTI: "txt" });
|
||||||
|
})
|
||||||
|
.catch((e) =>
|
||||||
|
writeErrorLog("Something went wrong attempting to export", e),
|
||||||
|
)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [filteredLogs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () =>
|
||||||
|
loading ? (
|
||||||
|
<Loader />
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity onPress={share}>
|
||||||
|
<Text>{t("home.settings.logs.export_logs")}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [share, loading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView className="p-4">
|
<>
|
||||||
<View className="flex flex-col space-y-2">
|
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
||||||
{logs?.map((log, index) => (
|
<FilterButton
|
||||||
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
id='order'
|
||||||
<Text
|
queryKey='log'
|
||||||
className={`
|
queryFn={async () => ["asc", "desc"]}
|
||||||
mb-1
|
set={(values) => setOrder(values[0])}
|
||||||
${log.level === "INFO" && "text-blue-500"}
|
values={[order]}
|
||||||
${log.level === "ERROR" && "text-red-500"}
|
title={t("library.filters.sort_order")}
|
||||||
`}
|
renderItemLabel={(order) => t(`library.filters.${order}`)}
|
||||||
>
|
showSearch={false}
|
||||||
{log.level}
|
/>
|
||||||
</Text>
|
<FilterButton
|
||||||
<Text uiTextView selectable className="text-xs">
|
id='levels'
|
||||||
{log.message}
|
queryKey='log'
|
||||||
</Text>
|
queryFn={async () => defaultLevels}
|
||||||
</View>
|
set={setLevels}
|
||||||
))}
|
values={levels}
|
||||||
{logs?.length === 0 && (
|
title={t("home.settings.logs.level")}
|
||||||
<Text className="opacity-50">{t("home.settings.logs.no_logs_available")}</Text>
|
renderItemLabel={(level) => level}
|
||||||
)}
|
showSearch={false}
|
||||||
|
multiple={true}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
<ScrollView className='pb-4 px-4'>
|
||||||
|
<View className='flex flex-col space-y-2'>
|
||||||
|
{filteredLogs?.map((log, index) => (
|
||||||
|
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
||||||
|
<TouchableOpacity
|
||||||
|
disabled={!log.data}
|
||||||
|
onPress={() =>
|
||||||
|
setState((v) => ({
|
||||||
|
...v,
|
||||||
|
[log.timestamp]: !v[log.timestamp],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className='flex flex-row justify-between'>
|
||||||
|
<Text
|
||||||
|
className={`mb-1
|
||||||
|
${log.level === "INFO" && "text-blue-500"}
|
||||||
|
${log.level === "ERROR" && "text-red-500"}
|
||||||
|
${log.level === "DEBUG" && "text-purple-500"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{log.level}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className='text-xs'>
|
||||||
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text uiTextView selectable className='text-xs'>
|
||||||
|
{log.message}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{log.data && (
|
||||||
|
<>
|
||||||
|
{!state[log.timestamp] && (
|
||||||
|
<Text className='text-xs mt-0.5'>
|
||||||
|
{t("home.settings.logs.click_for_more_info")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Collapsible collapsed={!state[log.timestamp]}>
|
||||||
|
<View className='mt-2 flex flex-col space-y-2'>
|
||||||
|
<ScrollView className='rounded-xl' style={codeBlockStyle}>
|
||||||
|
<Text>{JSON.stringify(log.data, null, 2)}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Collapsible>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{filteredLogs?.length === 0 && (
|
||||||
|
<Text className='opacity-50'>
|
||||||
|
{t("home.settings.logs.no_logs_available")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import React, {useEffect, useMemo, useState} from "react";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -15,7 +16,6 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -39,7 +39,10 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const disabled = useMemo(() => {
|
const disabled = useMemo(() => {
|
||||||
return pluginSettings?.searchEngine?.locked === true && pluginSettings?.marlinServerUrl?.locked === true
|
return (
|
||||||
|
pluginSettings?.searchEngine?.locked === true &&
|
||||||
|
pluginSettings?.marlinServerUrl?.locked === true
|
||||||
|
);
|
||||||
}, [pluginSettings]);
|
}, [pluginSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -47,7 +50,9 @@ export default function page() {
|
|||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity onPress={() => onSave(value)}>
|
<TouchableOpacity onPress={() => onSave(value)}>
|
||||||
<Text className="text-blue-500">{t("home.settings.plugins.marlin_search.save_button")}</Text>
|
<Text className='text-blue-500'>
|
||||||
|
{t("home.settings.plugins.marlin_search.save_button")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -57,17 +62,16 @@ export default function page() {
|
|||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DisabledSetting
|
<DisabledSetting disabled={disabled} className='px-4'>
|
||||||
disabled={disabled}
|
|
||||||
className="px-4"
|
|
||||||
>
|
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.searchEngine?.locked === true}
|
disabled={pluginSettings?.searchEngine?.locked === true}
|
||||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.marlin_search.enable_marlin_search")}
|
title={t(
|
||||||
|
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||||
|
)}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
updateSettings({ searchEngine: "Jellyfin" });
|
updateSettings({ searchEngine: "Jellyfin" });
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
@@ -87,28 +91,30 @@ export default function page() {
|
|||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||||
showText={!pluginSettings?.searchEngine?.locked}
|
showText={!pluginSettings?.searchEngine?.locked}
|
||||||
className="mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4"
|
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
||||||
>
|
>
|
||||||
<View
|
<View className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}>
|
||||||
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
|
<Text className='mr-4'>
|
||||||
>
|
{t("home.settings.plugins.marlin_search.url")}
|
||||||
<Text className="mr-4">{t("home.settings.plugins.marlin_search.url")}</Text>
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
editable={settings.searchEngine === "Marlin"}
|
editable={settings.searchEngine === "Marlin"}
|
||||||
className="text-white"
|
className='text-white'
|
||||||
placeholder={t("home.settings.plugins.marlin_search.server_url_placeholder")}
|
placeholder={t(
|
||||||
|
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||||
|
)}
|
||||||
value={value}
|
value={value}
|
||||||
keyboardType="url"
|
keyboardType='url'
|
||||||
returnKeyType="done"
|
returnKeyType='done'
|
||||||
autoCapitalize="none"
|
autoCapitalize='none'
|
||||||
textContentType="URL"
|
textContentType='URL'
|
||||||
onChangeText={(text) => setValue(text)}
|
onChangeText={(text) => setValue(text)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||||
<Text className="text-blue-500" onPress={handleOpenLink}>
|
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -8,10 +9,9 @@ import { useMutation } from "@tanstack/react-query";
|
|||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -31,14 +31,14 @@ export default function page() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/";
|
const updatedUrl = newVal.endsWith("/") ? newVal : `${newVal}/`;
|
||||||
|
|
||||||
updateSettings({
|
updateSettings({
|
||||||
optimizedVersionsServerUrl: updatedUrl,
|
optimizedVersionsServerUrl: updatedUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
return await getStatistics({
|
return await getStatistics({
|
||||||
url: settings?.optimizedVersionsServerUrl,
|
url: updatedUrl,
|
||||||
authHeader: api?.accessToken,
|
authHeader: api?.accessToken,
|
||||||
deviceId: getOrSetDeviceId(),
|
deviceId: getOrSetDeviceId(),
|
||||||
});
|
});
|
||||||
@@ -67,8 +67,12 @@ export default function page() {
|
|||||||
saveMutation.isPending ? (
|
saveMutation.isPending ? (
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
<ActivityIndicator size={"small"} color={"white"} />
|
||||||
) : (
|
) : (
|
||||||
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
|
<TouchableOpacity
|
||||||
<Text className="text-blue-500">{t("home.settings.downloads.save_button")}</Text>
|
onPress={() => onSave(optimizedVersionsServerUrl)}
|
||||||
|
>
|
||||||
|
<Text className='text-blue-500'>
|
||||||
|
{t("home.settings.downloads.save_button")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -78,7 +82,7 @@ export default function page() {
|
|||||||
return (
|
return (
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
|
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
|
||||||
className="p-4"
|
className='p-4'
|
||||||
>
|
>
|
||||||
<OptimizedServerForm
|
<OptimizedServerForm
|
||||||
value={optimizedVersionsServerUrl}
|
value={optimizedVersionsServerUrl}
|
||||||
|
|||||||
@@ -10,15 +10,15 @@ import MoviePoster from "@/components/posters/MoviePoster";
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { View } from "react-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
@@ -68,7 +68,7 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
[api, user?.Id, actorId]
|
[api, user?.Id, actorId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const backdropUrl = useMemo(
|
const backdropUrl = useMemo(
|
||||||
@@ -79,12 +79,12 @@ const page: React.FC = () => {
|
|||||||
quality: 90,
|
quality: 90,
|
||||||
width: 1000,
|
width: 1000,
|
||||||
}),
|
}),
|
||||||
[item]
|
[item],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (l1)
|
if (l1)
|
||||||
return (
|
return (
|
||||||
<View className="justify-center items-center h-full">
|
<View className='justify-center items-center h-full'>
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -105,13 +105,13 @@ const page: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col space-y-4 my-4">
|
<View className='flex flex-col space-y-4 my-4'>
|
||||||
<View className="px-4 mb-4">
|
<View className='px-4 mb-4'>
|
||||||
<MoviesTitleHeader item={item} className="mb-4" />
|
<MoviesTitleHeader item={item} className='mb-4' />
|
||||||
<OverviewText text={item.Overview} />
|
<OverviewText text={item.Overview} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
<Text className='px-4 text-2xl font-bold mb-2 text-neutral-100'>
|
||||||
{t("item_card.appeared_in")}
|
{t("item_card.appeared_in")}
|
||||||
</Text>
|
</Text>
|
||||||
<InfiniteHorizontalScroll
|
<InfiniteHorizontalScroll
|
||||||
@@ -133,7 +133,7 @@ const page: React.FC = () => {
|
|||||||
queryFn={fetchItems}
|
queryFn={fetchItems}
|
||||||
queryKey={["actor", "movies", actorId]}
|
queryKey={["actor", "movies", actorId]}
|
||||||
/>
|
/>
|
||||||
<View className="h-12"></View>
|
<View className='h-12' />
|
||||||
</View>
|
</View>
|
||||||
</ParallaxScrollView>
|
</ParallaxScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
|
SortByOption,
|
||||||
|
SortOrderOption,
|
||||||
genreFilterAtom,
|
genreFilterAtom,
|
||||||
sortByAtom,
|
sortByAtom,
|
||||||
SortByOption,
|
|
||||||
sortOptions,
|
sortOptions,
|
||||||
sortOrderAtom,
|
sortOrderAtom,
|
||||||
SortOrderOption,
|
|
||||||
sortOrderOptions,
|
sortOrderOptions,
|
||||||
tagsFilterAtom,
|
tagsFilterAtom,
|
||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
import {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
ItemSortBy,
|
ItemSortBy,
|
||||||
@@ -29,11 +30,11 @@ import {
|
|||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import type React from "react";
|
||||||
import { FlatList, View } from "react-native";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FlatList, View } from "react-native";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -43,7 +44,7 @@ const page: React.FC = () => {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const [orientation, setOrientation] = useState(
|
const [orientation, setOrientation] = useState(
|
||||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
ScreenOrientation.Orientation.PORTRAIT_UP,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -111,7 +112,7 @@ const page: React.FC = () => {
|
|||||||
recursive: true,
|
recursive: true,
|
||||||
genres: selectedGenres,
|
genres: selectedGenres,
|
||||||
tags: selectedTags,
|
tags: selectedTags,
|
||||||
years: selectedYears.map((year) => parseInt(year)),
|
years: selectedYears.map((year) => Number.parseInt(year)),
|
||||||
includeItemTypes: ["Movie", "Series"],
|
includeItemTypes: ["Movie", "Series"],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@ const page: React.FC = () => {
|
|||||||
selectedTags,
|
selectedTags,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
@@ -151,14 +152,13 @@ const page: React.FC = () => {
|
|||||||
const totalItems = lastPage.TotalRecordCount;
|
const totalItems = lastPage.TotalRecordCount;
|
||||||
const accumulatedItems = pages.reduce(
|
const accumulatedItems = pages.reduce(
|
||||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (accumulatedItems < totalItems) {
|
if (accumulatedItems < totalItems) {
|
||||||
return lastPage?.Items?.length * pages.length;
|
return lastPage?.Items?.length * pages.length;
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
},
|
},
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
enabled: !!api && !!user?.Id && !!collection,
|
enabled: !!api && !!user?.Id && !!collection,
|
||||||
@@ -188,8 +188,8 @@ const page: React.FC = () => {
|
|||||||
index % 3 === 0
|
index % 3 === 0
|
||||||
? "flex-end"
|
? "flex-end"
|
||||||
: (index + 1) % 3 === 0
|
: (index + 1) % 3 === 0
|
||||||
? "flex-start"
|
? "flex-start"
|
||||||
: "center",
|
: "center",
|
||||||
width: "89%",
|
width: "89%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -199,14 +199,14 @@ const page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
),
|
),
|
||||||
[orientation]
|
[orientation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|
||||||
const ListHeaderComponent = useCallback(
|
const ListHeaderComponent = useCallback(
|
||||||
() => (
|
() => (
|
||||||
<View className="">
|
<View className=''>
|
||||||
<FlatList
|
<FlatList
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
@@ -232,13 +232,13 @@ const page: React.FC = () => {
|
|||||||
key: "genre",
|
key: "genre",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
collectionId={collectionId}
|
id={collectionId}
|
||||||
queryKey="genreFilter"
|
queryKey='genreFilter'
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getFilterApi(
|
const response = await getFilterApi(
|
||||||
api
|
api,
|
||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: collectionId,
|
parentId: collectionId,
|
||||||
@@ -259,13 +259,13 @@ const page: React.FC = () => {
|
|||||||
key: "year",
|
key: "year",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
collectionId={collectionId}
|
id={collectionId}
|
||||||
queryKey="yearFilter"
|
queryKey='yearFilter'
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getFilterApi(
|
const response = await getFilterApi(
|
||||||
api
|
api,
|
||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: collectionId,
|
parentId: collectionId,
|
||||||
@@ -284,13 +284,13 @@ const page: React.FC = () => {
|
|||||||
key: "tags",
|
key: "tags",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
collectionId={collectionId}
|
id={collectionId}
|
||||||
queryKey="tagsFilter"
|
queryKey='tagsFilter'
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getFilterApi(
|
const response = await getFilterApi(
|
||||||
api
|
api,
|
||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: collectionId,
|
parentId: collectionId,
|
||||||
@@ -311,9 +311,9 @@ const page: React.FC = () => {
|
|||||||
key: "sortBy",
|
key: "sortBy",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
collectionId={collectionId}
|
id={collectionId}
|
||||||
queryKey="sortBy"
|
queryKey='sortBy'
|
||||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
set={setSortBy}
|
set={setSortBy}
|
||||||
values={sortBy}
|
values={sortBy}
|
||||||
@@ -331,9 +331,9 @@ const page: React.FC = () => {
|
|||||||
key: "sortOrder",
|
key: "sortOrder",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
collectionId={collectionId}
|
id={collectionId}
|
||||||
queryKey="sortOrder"
|
queryKey='sortOrder'
|
||||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
set={setSortOrder}
|
set={setSortOrder}
|
||||||
values={sortOrder}
|
values={sortOrder}
|
||||||
@@ -368,7 +368,7 @@ const page: React.FC = () => {
|
|||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
isFetching,
|
isFetching,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!collection) return null;
|
if (!collection) return null;
|
||||||
@@ -376,8 +376,10 @@ const page: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className="flex flex-col items-center justify-center h-full">
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
<Text className="font-bold text-xl text-neutral-500">{t("search.no_results")}</Text>
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
{t("search.no_results")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
extraData={[
|
extraData={[
|
||||||
@@ -387,7 +389,7 @@ const page: React.FC = () => {
|
|||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
]}
|
]}
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
data={flatData}
|
data={flatData}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
@@ -409,7 +411,7 @@ const page: React.FC = () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
}}
|
}}
|
||||||
></View>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ItemContent } from "@/components/ItemContent";
|
import { ItemContent } from "@/components/ItemContent";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect } from "react";
|
import type React from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
runOnJS,
|
runOnJS,
|
||||||
@@ -13,7 +15,6 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -75,36 +76,36 @@ const Page: React.FC = () => {
|
|||||||
|
|
||||||
if (isError)
|
if (isError)
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col items-center justify-center h-screen w-screen">
|
<View className='flex flex-col items-center justify-center h-screen w-screen'>
|
||||||
<Text>{t("item_card.could_not_load_item")}</Text>
|
<Text>{t("item_card.could_not_load_item")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-1 relative">
|
<View className='flex flex-1 relative'>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
pointerEvents={"none"}
|
pointerEvents={"none"}
|
||||||
style={[animatedStyle]}
|
style={[animatedStyle]}
|
||||||
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
|
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
height: item?.Type === "Episode" ? 300 : 450,
|
height: item?.Type === "Episode" ? 300 : 450,
|
||||||
}}
|
}}
|
||||||
className="bg-transparent rounded-lg mb-4 w-full"
|
className='bg-transparent rounded-lg mb-4 w-full'
|
||||||
></View>
|
/>
|
||||||
<View className="h-6 bg-neutral-900 rounded mb-4 w-14"></View>
|
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
|
||||||
<View className="h-10 bg-neutral-900 rounded-lg mb-2 w-1/2"></View>
|
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
|
||||||
<View className="h-3 bg-neutral-900 rounded mb-3 w-8"></View>
|
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
|
||||||
<View className="flex flex-row space-x-1 mb-8">
|
<View className='flex flex-row space-x-1 mb-8'>
|
||||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
</View>
|
</View>
|
||||||
<View className="h-3 bg-neutral-900 rounded w-2/3 mb-1"></View>
|
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
|
||||||
<View className="h-10 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||||
<View className="h-12 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||||
<View className="h-24 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
{item && <ItemContent item={item} />}
|
{item && <ItemContent item={item} />}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,45 +1,45 @@
|
|||||||
import {router, useLocalSearchParams, useSegments,} from "expo-router";
|
|
||||||
import React, {useMemo,} from "react";
|
|
||||||
import {TouchableOpacity} from "react-native";
|
|
||||||
import {useInfiniteQuery} from "@tanstack/react-query";
|
|
||||||
import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
|
|
||||||
import {Text} from "@/components/common/Text";
|
|
||||||
import {Image} from "expo-image";
|
|
||||||
import Poster from "@/components/posters/Poster";
|
|
||||||
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
|
||||||
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
|
|
||||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||||
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
|
||||||
import {COMPANY_LOGO_IMAGE_FILTER} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
|
||||||
import {uniqBy} from "lodash";
|
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
|
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
|
import {
|
||||||
|
type MovieResult,
|
||||||
|
Results,
|
||||||
|
type TvResult,
|
||||||
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { uniqBy } from "lodash";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const {jellyseerrApi} = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
const {companyId, name, image, type} = local as unknown as {
|
const { companyId, name, image, type } = local as unknown as {
|
||||||
companyId: string,
|
companyId: string;
|
||||||
name: string,
|
name: string;
|
||||||
image: string,
|
image: string;
|
||||||
type: DiscoverSliderType
|
type: DiscoverSliderType;
|
||||||
};
|
};
|
||||||
|
|
||||||
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
|
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
queryKey: ["jellyseerr", "company", type, companyId],
|
queryKey: ["jellyseerr", "company", type, companyId],
|
||||||
queryFn: async ({pageParam}) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
let params: any = {
|
const params: any = {
|
||||||
page: Number(pageParam),
|
page: Number(pageParam),
|
||||||
};
|
};
|
||||||
|
|
||||||
return jellyseerrApi?.discover(
|
return jellyseerrApi?.discover(
|
||||||
(
|
`${
|
||||||
type == DiscoverSliderType.NETWORKS
|
type === DiscoverSliderType.NETWORKS
|
||||||
? Endpoints.DISCOVER_TV_NETWORK
|
? Endpoints.DISCOVER_TV_NETWORK
|
||||||
: Endpoints.DISCOVER_MOVIES_STUDIO
|
: Endpoints.DISCOVER_MOVIES_STUDIO
|
||||||
) + `/${companyId}`,
|
}/${companyId}`,
|
||||||
params
|
params,
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
enabled: !!jellyseerrApi && !!companyId,
|
enabled: !!jellyseerrApi && !!companyId,
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
@@ -50,46 +50,58 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const flatData = useMemo(
|
const flatData = useMemo(
|
||||||
() => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
|
() =>
|
||||||
[data]
|
uniqBy(
|
||||||
|
data?.pages
|
||||||
|
?.filter((p) => p?.results.length)
|
||||||
|
.flatMap((p) => p?.results ?? []),
|
||||||
|
"id",
|
||||||
|
) ?? [],
|
||||||
|
[data],
|
||||||
);
|
);
|
||||||
|
|
||||||
const backdrops = useMemo(
|
const backdrops = useMemo(
|
||||||
() => jellyseerrApi
|
() =>
|
||||||
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
|
jellyseerrApi
|
||||||
: [],
|
? flatData.map((r) =>
|
||||||
[jellyseerrApi, flatData]
|
jellyseerrApi.imageProxy(
|
||||||
|
(r as TvResult | MovieResult).backdropPath,
|
||||||
|
"w1920_and_h800_multi_faces",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
[jellyseerrApi, flatData],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ParallaxSlideShow
|
<ParallaxSlideShow
|
||||||
data={flatData}
|
data={flatData}
|
||||||
images={backdrops}
|
images={backdrops}
|
||||||
listHeader=""
|
listHeader=''
|
||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage) {
|
if (hasNextPage) {
|
||||||
fetchNextPage()
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
logo={
|
logo={
|
||||||
<Image
|
<Image
|
||||||
id={companyId}
|
id={companyId}
|
||||||
key={companyId}
|
key={companyId}
|
||||||
className="bottom-1 w-1/2"
|
className='bottom-1 w-1/2'
|
||||||
source={{
|
source={{
|
||||||
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
|
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
|
||||||
}}
|
}}
|
||||||
cachePolicy={"memory-disk"}
|
cachePolicy={"memory-disk"}
|
||||||
contentFit="contain"
|
contentFit='contain'
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: "4/3",
|
aspectRatio: "4/3",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
renderItem={(item, index) =>
|
renderItem={(item, index) => (
|
||||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,46 @@
|
|||||||
import {router, useLocalSearchParams, useSegments,} from "expo-router";
|
import { Text } from "@/components/common/Text";
|
||||||
import React, {useMemo,} from "react";
|
|
||||||
import {TouchableOpacity} from "react-native";
|
|
||||||
import {useInfiniteQuery} from "@tanstack/react-query";
|
|
||||||
import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
|
|
||||||
import {Text} from "@/components/common/Text";
|
|
||||||
import Poster from "@/components/posters/Poster";
|
|
||||||
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
||||||
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
|
|
||||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||||
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||||
import {uniqBy} from "lodash";
|
|
||||||
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
|
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
|
import Poster from "@/components/posters/Poster";
|
||||||
|
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
|
import {
|
||||||
|
type MovieResult,
|
||||||
|
Results,
|
||||||
|
type TvResult,
|
||||||
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { router, useLocalSearchParams, useSegments } from "expo-router";
|
||||||
|
import { uniqBy } from "lodash";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { TouchableOpacity } from "react-native";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const {jellyseerrApi} = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
const {genreId, name, type} = local as unknown as {
|
const { genreId, name, type } = local as unknown as {
|
||||||
genreId: string,
|
genreId: string;
|
||||||
name: string,
|
name: string;
|
||||||
type: DiscoverSliderType
|
type: DiscoverSliderType;
|
||||||
};
|
};
|
||||||
|
|
||||||
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
|
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
queryKey: ["jellyseerr", "company", type, genreId],
|
queryKey: ["jellyseerr", "company", type, genreId],
|
||||||
queryFn: async ({pageParam}) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
let params: any = {
|
const params: any = {
|
||||||
page: Number(pageParam),
|
page: Number(pageParam),
|
||||||
genre: genreId
|
genre: genreId,
|
||||||
};
|
};
|
||||||
|
|
||||||
return jellyseerrApi?.discover(
|
return jellyseerrApi?.discover(
|
||||||
type == DiscoverSliderType.MOVIE_GENRES
|
type === DiscoverSliderType.MOVIE_GENRES
|
||||||
? Endpoints.DISCOVER_MOVIES
|
? Endpoints.DISCOVER_MOVIES
|
||||||
: Endpoints.DISCOVER_TV,
|
: Endpoints.DISCOVER_TV,
|
||||||
params
|
params,
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
enabled: !!jellyseerrApi && !!genreId,
|
enabled: !!jellyseerrApi && !!genreId,
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
@@ -47,41 +51,54 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const flatData = useMemo(
|
const flatData = useMemo(
|
||||||
() => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
|
() =>
|
||||||
[data]
|
uniqBy(
|
||||||
|
data?.pages
|
||||||
|
?.filter((p) => p?.results.length)
|
||||||
|
.flatMap((p) => p?.results ?? []),
|
||||||
|
"id",
|
||||||
|
) ?? [],
|
||||||
|
[data],
|
||||||
);
|
);
|
||||||
|
|
||||||
const backdrops = useMemo(
|
const backdrops = useMemo(
|
||||||
() => jellyseerrApi
|
() =>
|
||||||
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
|
jellyseerrApi
|
||||||
: [],
|
? flatData.map((r) =>
|
||||||
[jellyseerrApi, flatData]
|
jellyseerrApi.imageProxy(
|
||||||
|
(r as TvResult | MovieResult).backdropPath,
|
||||||
|
"w1920_and_h800_multi_faces",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
[jellyseerrApi, flatData],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ParallaxSlideShow
|
<ParallaxSlideShow
|
||||||
data={flatData}
|
data={flatData}
|
||||||
images={backdrops}
|
images={backdrops}
|
||||||
listHeader=""
|
listHeader=''
|
||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage) {
|
if (hasNextPage) {
|
||||||
fetchNextPage()
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
logo={
|
logo={
|
||||||
<Text
|
<Text
|
||||||
className="text-4xl font-bold text-center bottom-1"
|
className='text-4xl font-bold text-center bottom-1'
|
||||||
style={{
|
style={{
|
||||||
...textShadowStyle.shadow,
|
...textShadowStyle.shadow,
|
||||||
shadowRadius: 10
|
shadowRadius: 10,
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
renderItem={(item, index) =>
|
renderItem={(item, index) => (
|
||||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,68 @@
|
|||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { GenreTags } from "@/components/GenreTags";
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
import Cast from "@/components/jellyseerr/Cast";
|
|
||||||
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { JellyserrRatings } from "@/components/Ratings";
|
import { JellyserrRatings } from "@/components/Ratings";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import Cast from "@/components/jellyseerr/Cast";
|
||||||
|
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||||
import { ItemActions } from "@/components/series/SeriesActions";
|
import { ItemActions } from "@/components/series/SeriesActions";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||||
import {
|
import {
|
||||||
IssueType,
|
type IssueType,
|
||||||
IssueTypeName,
|
IssueTypeName,
|
||||||
} from "@/utils/jellyseerr/server/constants/issue";
|
} from "@/utils/jellyseerr/server/constants/issue";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
import type {
|
||||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
MovieResult,
|
||||||
import { useTranslation } from "react-i18next";
|
TvResult,
|
||||||
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
BottomSheetBackdropProps,
|
type BottomSheetBackdropProps,
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
BottomSheetTextInput,
|
BottomSheetTextInput,
|
||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
||||||
import React, {
|
import type React from "react";
|
||||||
useCallback,
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
useEffect,
|
import { useTranslation } from "react-i18next";
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||||
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const { mediaTitle, releaseYear, posterSrc, ...result } =
|
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
|
||||||
params as unknown as {
|
params as unknown as {
|
||||||
mediaTitle: string;
|
mediaTitle: string;
|
||||||
releaseYear: number;
|
releaseYear: number;
|
||||||
canRequest: string;
|
canRequest: string;
|
||||||
posterSrc: string;
|
posterSrc: string;
|
||||||
} & Partial<MovieResult | TvResult>;
|
mediaType: MediaType;
|
||||||
|
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
const [issueType, setIssueType] = useState<IssueType>();
|
const [issueType, setIssueType] = useState<IssueType>();
|
||||||
const [issueMessage, setIssueMessage] = useState<string>();
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
|
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
||||||
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
@@ -71,7 +73,7 @@ const Page: React.FC = () => {
|
|||||||
refetch,
|
refetch,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||||
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
queryKey: ["jellyseerr", "detail", mediaType, result.id],
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
@@ -79,9 +81,9 @@ const Page: React.FC = () => {
|
|||||||
retryOnMount: true,
|
retryOnMount: true,
|
||||||
refetchInterval: 0,
|
refetchInterval: 0,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return result.mediaType === MediaType.MOVIE
|
return mediaType === MediaType.MOVIE
|
||||||
? jellyseerrApi?.movieDetails(result.id!!)
|
? jellyseerrApi?.movieDetails(result.id!)
|
||||||
: jellyseerrApi?.tvDetails(result.id!!);
|
: jellyseerrApi?.tvDetails(result.id!);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,7 +98,7 @@ const Page: React.FC = () => {
|
|||||||
appearsOnIndex={0}
|
appearsOnIndex={0}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
[]
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const submitIssue = useCallback(() => {
|
const submitIssue = useCallback(() => {
|
||||||
@@ -111,10 +113,18 @@ const Page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||||
|
|
||||||
|
const setRequestBody = useCallback(
|
||||||
|
(body: MediaRequestBody) => {
|
||||||
|
_setRequestBody(body);
|
||||||
|
advancedReqModalRef?.current?.present?.();
|
||||||
|
},
|
||||||
|
[requestBody, _setRequestBody, advancedReqModalRef],
|
||||||
|
);
|
||||||
|
|
||||||
const request = useCallback(async () => {
|
const request = useCallback(async () => {
|
||||||
const body: MediaRequestBody = {
|
const body: MediaRequestBody = {
|
||||||
mediaId: Number(result.id!!),
|
mediaId: Number(result.id!),
|
||||||
mediaType: result.mediaType!!,
|
mediaType: mediaType!,
|
||||||
tvdbId: details?.externalIds?.tvdbId,
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
seasons: (details as TvDetails)?.seasons
|
seasons: (details as TvDetails)?.seasons
|
||||||
?.filter?.((s) => s.seasonNumber !== 0)
|
?.filter?.((s) => s.seasonNumber !== 0)
|
||||||
@@ -122,7 +132,7 @@ const Page: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (hasAdvancedRequestPermission) {
|
if (hasAdvancedRequestPermission) {
|
||||||
advancedReqModalRef?.current?.present?.(body);
|
setRequestBody(body);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,15 +142,15 @@ const Page: React.FC = () => {
|
|||||||
const isAnime = useMemo(
|
const isAnime = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
|
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
|
||||||
result.mediaType === MediaType.TV,
|
mediaType === MediaType.TV,
|
||||||
[details]
|
[details],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (details) {
|
if (details) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity className="rounded-full p-2 bg-neutral-800/80">
|
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
|
||||||
<ItemActions item={details} />
|
<ItemActions item={details} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
@@ -150,14 +160,14 @@ const Page: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex-1 relative"
|
className='flex-1 relative'
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ParallaxScrollView
|
<ParallaxScrollView
|
||||||
className="flex-1 opacity-100"
|
className='flex-1 opacity-100'
|
||||||
headerHeight={300}
|
headerHeight={300}
|
||||||
headerImage={
|
headerImage={
|
||||||
<View>
|
<View>
|
||||||
@@ -172,7 +182,7 @@ const Page: React.FC = () => {
|
|||||||
source={{
|
source={{
|
||||||
uri: jellyseerrApi?.imageProxy(
|
uri: jellyseerrApi?.imageProxy(
|
||||||
result.backdropPath,
|
result.backdropPath,
|
||||||
"w1920_and_h800_multi_faces"
|
"w1920_and_h800_multi_faces",
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -182,12 +192,12 @@ const Page: React.FC = () => {
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
|
className='flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900'
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="image-outline"
|
name='image-outline'
|
||||||
size={24}
|
size={24}
|
||||||
color="white"
|
color='white'
|
||||||
style={{ opacity: 0.4 }}
|
style={{ opacity: 0.4 }}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -195,23 +205,31 @@ const Page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col">
|
<View className='flex flex-col'>
|
||||||
<View className="space-y-4">
|
<View className='space-y-4'>
|
||||||
<View className="px-4">
|
<View className='px-4'>
|
||||||
<View className="flex flex-row justify-between w-full">
|
<View className='flex flex-row justify-between w-full'>
|
||||||
<View className="flex flex-col w-56">
|
<View className='flex flex-col w-56'>
|
||||||
<JellyserrRatings result={result as MovieResult | TvResult} />
|
<JellyserrRatings
|
||||||
|
result={
|
||||||
|
result as
|
||||||
|
| MovieResult
|
||||||
|
| TvResult
|
||||||
|
| MovieDetails
|
||||||
|
| TvDetails
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Text
|
<Text
|
||||||
uiTextView
|
uiTextView
|
||||||
selectable
|
selectable
|
||||||
className="font-bold text-2xl mb-1"
|
className='font-bold text-2xl mb-1'
|
||||||
>
|
>
|
||||||
{mediaTitle}
|
{mediaTitle}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="opacity-50">{releaseYear}</Text>
|
<Text className='opacity-50'>{releaseYear}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Image
|
<Image
|
||||||
className="absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl"
|
className='absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl'
|
||||||
cachePolicy={"memory-disk"}
|
cachePolicy={"memory-disk"}
|
||||||
transition={300}
|
transition={300}
|
||||||
source={{
|
source={{
|
||||||
@@ -219,48 +237,80 @@ const Page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className="mb-4">
|
<View>
|
||||||
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
||||||
</View>
|
</View>
|
||||||
{isLoading || isFetching ? (
|
{isLoading || isFetching ? (
|
||||||
<Button loading={true} disabled={true} color="purple"></Button>
|
<Button
|
||||||
|
loading={true}
|
||||||
|
disabled={true}
|
||||||
|
color='purple'
|
||||||
|
className='mt-4'
|
||||||
|
/>
|
||||||
) : canRequest ? (
|
) : canRequest ? (
|
||||||
<Button color="purple" onPress={request}>
|
<Button color='purple' onPress={request} className='mt-4'>
|
||||||
{t("jellyseerr.request_button")}
|
{t("jellyseerr.request_button")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
details?.mediaInfo?.jellyfinMediaId && (
|
||||||
className="bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100"
|
<View className='flex flex-row space-x-2 mt-4'>
|
||||||
color="transparent"
|
<Button
|
||||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||||
iconLeft={
|
color='transparent'
|
||||||
<Ionicons name="warning-outline" size={24} color="white" />
|
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||||
}
|
iconLeft={
|
||||||
style={{
|
<Ionicons
|
||||||
borderWidth: 1,
|
name='warning-outline'
|
||||||
borderStyle: "solid",
|
size={20}
|
||||||
}}
|
color='white'
|
||||||
>
|
/>
|
||||||
{t("jellyseerr.report_issue_button")}
|
}
|
||||||
</Button>
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-sm'>
|
||||||
|
{t("jellyseerr.report_issue_button")}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
|
||||||
|
onPress={() => {
|
||||||
|
const url =
|
||||||
|
mediaType === MediaType.MOVIE
|
||||||
|
? `/(auth)/(tabs)/(search)/items/page?id=${details?.mediaInfo.jellyfinMediaId}`
|
||||||
|
: `/(auth)/(tabs)/(search)/series/${details?.mediaInfo.jellyfinMediaId}`;
|
||||||
|
// @ts-expect-error
|
||||||
|
router.push(url);
|
||||||
|
}}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name='play-outline' size={20} color='white' />
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-sm'>Play</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
<OverviewText text={result.overview} className="mt-4" />
|
<OverviewText text={result.overview} className='mt-4' />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{result.mediaType === MediaType.TV && (
|
{mediaType === MediaType.TV && (
|
||||||
<JellyseerrSeasons
|
<JellyseerrSeasons
|
||||||
isLoading={isLoading || isFetching}
|
isLoading={isLoading || isFetching}
|
||||||
result={result as TvResult}
|
|
||||||
details={details as TvDetails}
|
details={details as TvDetails}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
hasAdvancedRequest={hasAdvancedRequestPermission}
|
hasAdvancedRequest={hasAdvancedRequestPermission}
|
||||||
onAdvancedRequest={(data) =>
|
onAdvancedRequest={(data) => setRequestBody(data)}
|
||||||
advancedReqModalRef?.current?.present(data)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<DetailFacts
|
<DetailFacts
|
||||||
className="p-2 border border-neutral-800 bg-neutral-900 rounded-xl"
|
className='p-2 border border-neutral-800 bg-neutral-900 rounded-xl'
|
||||||
details={details}
|
details={details}
|
||||||
/>
|
/>
|
||||||
<Cast details={details} />
|
<Cast details={details} />
|
||||||
@@ -269,14 +319,17 @@ const Page: React.FC = () => {
|
|||||||
</ParallaxScrollView>
|
</ParallaxScrollView>
|
||||||
<RequestModal
|
<RequestModal
|
||||||
ref={advancedReqModalRef}
|
ref={advancedReqModalRef}
|
||||||
|
requestBody={requestBody}
|
||||||
title={mediaTitle}
|
title={mediaTitle}
|
||||||
id={result.id!!}
|
id={result.id!}
|
||||||
type={result.mediaType as MediaType}
|
type={mediaType}
|
||||||
isAnime={isAnime}
|
isAnime={isAnime}
|
||||||
onRequested={() => {
|
onRequested={() => {
|
||||||
|
_setRequestBody(undefined);
|
||||||
advancedReqModalRef?.current?.close();
|
advancedReqModalRef?.current?.close();
|
||||||
refetch();
|
refetch();
|
||||||
}}
|
}}
|
||||||
|
onDismiss={() => _setRequestBody(undefined)}
|
||||||
/>
|
/>
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={bottomSheetModalRef}
|
ref={bottomSheetModalRef}
|
||||||
@@ -290,22 +343,22 @@ const Page: React.FC = () => {
|
|||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-bold text-2xl text-neutral-100">
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
{t("jellyseerr.whats_wrong")}
|
{t("jellyseerr.whats_wrong")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-col space-y-2 items-start">
|
<View className='flex flex-col space-y-2 items-start'>
|
||||||
<View className="flex flex-col">
|
<View className='flex flex-col'>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col">
|
<View className='flex flex-col'>
|
||||||
<Text className="opacity-50 mb-1 text-xs">
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
{t("jellyseerr.issue_type")}
|
{t("jellyseerr.issue_type")}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
<Text style={{}} className="" numberOfLines={1}>
|
<Text style={{}} className='' numberOfLines={1}>
|
||||||
{issueType
|
{issueType
|
||||||
? IssueTypeName[issueType]
|
? IssueTypeName[issueType]
|
||||||
: t("jellyseerr.select_an_issue")}
|
: t("jellyseerr.select_an_issue")}
|
||||||
@@ -315,8 +368,8 @@ const Page: React.FC = () => {
|
|||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
loop={false}
|
loop={false}
|
||||||
side="bottom"
|
side='bottom'
|
||||||
align="center"
|
align='center'
|
||||||
alignOffset={0}
|
alignOffset={0}
|
||||||
avoidCollisions={true}
|
avoidCollisions={true}
|
||||||
collisionPadding={0}
|
collisionPadding={0}
|
||||||
@@ -343,14 +396,14 @@ const Page: React.FC = () => {
|
|||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
|
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
||||||
<BottomSheetTextInput
|
<BottomSheetTextInput
|
||||||
multiline
|
multiline
|
||||||
maxLength={254}
|
maxLength={254}
|
||||||
style={{ color: "white" }}
|
style={{ color: "white" }}
|
||||||
clearButtonMode="always"
|
clearButtonMode='always'
|
||||||
placeholder={t("jellyseerr.describe_the_issue")}
|
placeholder={t("jellyseerr.describe_the_issue")}
|
||||||
placeholderTextColor="#9CA3AF"
|
placeholderTextColor='#9CA3AF'
|
||||||
// Issue with multiline + Textinput inside a portal
|
// Issue with multiline + Textinput inside a portal
|
||||||
// https://github.com/callstack/react-native-paper/issues/1668
|
// https://github.com/callstack/react-native-paper/issues/1668
|
||||||
defaultValue={issueMessage}
|
defaultValue={issueMessage}
|
||||||
@@ -358,7 +411,7 @@ const Page: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Button className="mt-auto" onPress={submitIssue} color="purple">
|
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
||||||
{t("jellyseerr.submit_button")}
|
{t("jellyseerr.submit_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,25 +1,30 @@
|
|||||||
import {
|
|
||||||
useLocalSearchParams,
|
|
||||||
useSegments,
|
|
||||||
} from "expo-router";
|
|
||||||
import React, { useMemo } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import {orderBy, uniqBy} from "lodash";
|
import { Text } from "@/components/common/Text";
|
||||||
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
|
||||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||||
|
import type {
|
||||||
|
MovieResult,
|
||||||
|
TvResult,
|
||||||
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useSegments } from "expo-router";
|
||||||
|
import { orderBy, uniqBy } from "lodash";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { jellyseerrApi, jellyseerrUser } = useJellyseerr();
|
const {
|
||||||
|
jellyseerrApi,
|
||||||
|
jellyseerrUser,
|
||||||
|
jellyseerrRegion: region,
|
||||||
|
jellyseerrLocale: locale,
|
||||||
|
} = useJellyseerr();
|
||||||
|
|
||||||
const { personId } = local as { personId: string };
|
const { personId } = local as { personId: string };
|
||||||
|
|
||||||
@@ -32,29 +37,29 @@ export default function page() {
|
|||||||
enabled: !!jellyseerrApi && !!personId,
|
enabled: !!jellyseerrApi && !!personId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const locale = useMemo(() => {
|
|
||||||
return jellyseerrUser?.settings?.locale || "en";
|
|
||||||
}, [jellyseerrUser]);
|
|
||||||
|
|
||||||
const region = useMemo(
|
|
||||||
() => jellyseerrUser?.settings?.region || "US",
|
|
||||||
[jellyseerrUser]
|
|
||||||
);
|
|
||||||
|
|
||||||
const castedRoles: PersonCreditCast[] = useMemo(
|
const castedRoles: PersonCreditCast[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
uniqBy(orderBy(
|
uniqBy(
|
||||||
data?.combinedCredits?.cast,
|
orderBy(
|
||||||
["voteCount", "voteAverage"],
|
data?.combinedCredits?.cast,
|
||||||
"desc"
|
["voteCount", "voteAverage"],
|
||||||
), 'id'),
|
"desc",
|
||||||
[data?.combinedCredits]
|
),
|
||||||
|
"id",
|
||||||
|
),
|
||||||
|
[data?.combinedCredits],
|
||||||
);
|
);
|
||||||
const backdrops = useMemo(
|
const backdrops = useMemo(
|
||||||
() => jellyseerrApi
|
() =>
|
||||||
? castedRoles.map((c) => jellyseerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"))
|
jellyseerrApi
|
||||||
: [],
|
? castedRoles.map((c) =>
|
||||||
[jellyseerrApi, data?.combinedCredits]
|
jellyseerrApi.imageProxy(
|
||||||
|
c.backdropPath,
|
||||||
|
"w1920_and_h800_multi_faces",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
[jellyseerrApi, data?.combinedCredits],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -67,15 +72,15 @@ export default function page() {
|
|||||||
<Image
|
<Image
|
||||||
key={data?.details?.id}
|
key={data?.details?.id}
|
||||||
id={data?.details?.id.toString()}
|
id={data?.details?.id.toString()}
|
||||||
className="rounded-full bottom-1"
|
className='rounded-full bottom-1'
|
||||||
source={{
|
source={{
|
||||||
uri: jellyseerrApi?.imageProxy(
|
uri: jellyseerrApi?.imageProxy(
|
||||||
data?.details?.profilePath,
|
data?.details?.profilePath,
|
||||||
"w600_and_h600_bestv2"
|
"w600_and_h600_bestv2",
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
cachePolicy={"memory-disk"}
|
cachePolicy={"memory-disk"}
|
||||||
contentFit="cover"
|
contentFit='cover'
|
||||||
style={{
|
style={{
|
||||||
width: 125,
|
width: 125,
|
||||||
height: 125,
|
height: 125,
|
||||||
@@ -84,27 +89,27 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
HeaderContent={() => (
|
HeaderContent={() => (
|
||||||
<>
|
<>
|
||||||
<Text className="font-bold text-2xl mb-1">
|
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
|
||||||
{data?.details?.name}
|
<Text className='opacity-50'>
|
||||||
</Text>
|
|
||||||
<Text className="opacity-50">
|
|
||||||
{t("jellyseerr.born")}{" "}
|
{t("jellyseerr.born")}{" "}
|
||||||
{new Date(data?.details?.birthday!!).toLocaleDateString(
|
{new Date(data?.details?.birthday!).toLocaleDateString(
|
||||||
`${locale}-${region}`,
|
`${locale}-${region}`,
|
||||||
{
|
{
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}
|
},
|
||||||
)}{" "}
|
)}{" "}
|
||||||
| {data?.details?.placeOfBirth}
|
| {data?.details?.placeOfBirth}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
MainContent={() => (
|
MainContent={() => (
|
||||||
<OverviewText text={data?.details?.biography} className="mt-4" />
|
<OverviewText text={data?.details?.biography} className='mt-4' />
|
||||||
|
)}
|
||||||
|
renderItem={(item, index) => (
|
||||||
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
)}
|
)}
|
||||||
renderItem={(item, index) => <JellyseerrPoster item={item as MovieResult | TvResult} />}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import type {
|
|||||||
MaterialTopTabNavigationOptions,
|
MaterialTopTabNavigationOptions,
|
||||||
} from "@react-navigation/material-top-tabs";
|
} from "@react-navigation/material-top-tabs";
|
||||||
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
|
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
|
||||||
import { ParamListBase, TabNavigationState } from "@react-navigation/native";
|
import type {
|
||||||
|
ParamListBase,
|
||||||
|
TabNavigationState,
|
||||||
|
} from "@react-navigation/native";
|
||||||
import { Stack, withLayoutContext } from "expo-router";
|
import { Stack, withLayoutContext } from "expo-router";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
@@ -21,8 +24,8 @@ const Layout = () => {
|
|||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Live TV" }} />
|
<Stack.Screen options={{ title: "Live TV" }} />
|
||||||
<Tab
|
<Tab
|
||||||
initialRouteName="programs"
|
initialRouteName='programs'
|
||||||
keyboardDismissMode="none"
|
keyboardDismissMode='none'
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarBounces: true,
|
tabBarBounces: true,
|
||||||
tabBarLabelStyle: { fontSize: 10 },
|
tabBarLabelStyle: { fontSize: 10 },
|
||||||
@@ -37,10 +40,10 @@ const Layout = () => {
|
|||||||
tabBarScrollEnabled: true,
|
tabBarScrollEnabled: true,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tab.Screen name="programs" />
|
<Tab.Screen name='programs' />
|
||||||
<Tab.Screen name="guide" />
|
<Tab.Screen name='guide' />
|
||||||
<Tab.Screen name="channels" />
|
<Tab.Screen name='channels' />
|
||||||
<Tab.Screen name="recordings" />
|
<Tab.Screen name='recordings' />
|
||||||
</Tab>
|
</Tab>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,13 +31,13 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-1">
|
<View className='flex flex-1'>
|
||||||
<FlashList
|
<FlashList
|
||||||
data={channels?.Items}
|
data={channels?.Items}
|
||||||
estimatedItemSize={76}
|
estimatedItemSize={76}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View className="flex flex-row items-center px-4 mb-2">
|
<View className='flex flex-row items-center px-4 mb-2'>
|
||||||
<View className="w-22 mr-4 rounded-lg overflow-hidden">
|
<View className='w-22 mr-4 rounded-lg overflow-hidden'>
|
||||||
<ItemImage
|
<ItemImage
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: "1/1",
|
aspectRatio: "1/1",
|
||||||
@@ -47,7 +47,7 @@ export default function page() {
|
|||||||
item={item}
|
item={item}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text className="font-bold">{item.Name}</Text>
|
<Text className='font-bold'>{item.Name}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useMemo, useState } from "react";
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
@@ -17,7 +18,6 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
const HOUR_HEIGHT = 30;
|
const HOUR_HEIGHT = 30;
|
||||||
const ITEMS_PER_PAGE = 20;
|
const ITEMS_PER_PAGE = 20;
|
||||||
@@ -71,7 +71,7 @@ export default function page() {
|
|||||||
MaxStartDate: endOfDay.toISOString(),
|
MaxStartDate: endOfDay.toISOString(),
|
||||||
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
|
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
|
||||||
ChannelIds: channels?.Items?.map((c) => c.Id).filter(
|
ChannelIds: channels?.Items?.map((c) => c.Id).filter(
|
||||||
Boolean
|
Boolean,
|
||||||
) as string[],
|
) as string[],
|
||||||
ImageTypeLimit: 1,
|
ImageTypeLimit: 1,
|
||||||
EnableImages: false,
|
EnableImages: false,
|
||||||
@@ -100,7 +100,7 @@ export default function page() {
|
|||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
key={"home"}
|
key={"home"}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
@@ -117,16 +117,16 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View className="flex flex-row">
|
<View className='flex flex-row'>
|
||||||
<View className="flex flex-col w-[64px]">
|
<View className='flex flex-col w-[64px]'>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
height: HOUR_HEIGHT,
|
height: HOUR_HEIGHT,
|
||||||
}}
|
}}
|
||||||
className="bg-neutral-800"
|
className='bg-neutral-800'
|
||||||
></View>
|
/>
|
||||||
{channels?.Items?.map((c, i) => (
|
{channels?.Items?.map((c, i) => (
|
||||||
<View className="h-16 w-16 mr-4 rounded-lg overflow-hidden" key={i}>
|
<View className='h-16 w-16 mr-4 rounded-lg overflow-hidden' key={i}>
|
||||||
<ItemImage
|
<ItemImage
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -148,7 +148,7 @@ export default function page() {
|
|||||||
setScrollX(e.nativeEvent.contentOffset.x);
|
setScrollX(e.nativeEvent.contentOffset.x);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col">
|
<View className='flex flex-col'>
|
||||||
<HourHeader height={HOUR_HEIGHT} />
|
<HourHeader height={HOUR_HEIGHT} />
|
||||||
{channels?.Items?.map((c, i) => (
|
{channels?.Items?.map((c, i) => (
|
||||||
<MemoizedLiveTVGuideRow
|
<MemoizedLiveTVGuideRow
|
||||||
@@ -180,14 +180,14 @@ const PageButtons: React.FC<PageButtonsProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
|
<View className='flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2'>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onPrevPage}
|
onPress={onPrevPage}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="flex flex-row items-center"
|
className='flex flex-row items-center'
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="chevron-back"
|
name='chevron-back'
|
||||||
size={24}
|
size={24}
|
||||||
color={currentPage === 1 ? "gray" : "white"}
|
color={currentPage === 1 ? "gray" : "white"}
|
||||||
/>
|
/>
|
||||||
@@ -199,11 +199,11 @@ const PageButtons: React.FC<PageButtonsProps> = ({
|
|||||||
{t("live_tv.previous")}
|
{t("live_tv.previous")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text className="text-white">Page {currentPage}</Text>
|
<Text className='text-white'>Page {currentPage}</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onNextPage}
|
onPress={onNextPage}
|
||||||
disabled={isNextDisabled}
|
disabled={isNextDisabled}
|
||||||
className="flex flex-row items-center"
|
className='flex flex-row items-center'
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
|
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
|
||||||
@@ -211,7 +211,7 @@ const PageButtons: React.FC<PageButtonsProps> = ({
|
|||||||
{t("live_tv.next")}
|
{t("live_tv.next")}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="chevron-forward"
|
name='chevron-forward'
|
||||||
size={24}
|
size={24}
|
||||||
color={isNextDisabled ? "gray" : "white"}
|
color={isNextDisabled ? "gray" : "white"}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
import { TAB_HEIGHT } from "@/constants/Values";
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -19,7 +19,7 @@ export default function page() {
|
|||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
key={"home"}
|
key={"home"}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
@@ -28,7 +28,7 @@ export default function page() {
|
|||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col space-y-2">
|
<View className='flex flex-col space-y-2'>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryKey={["livetv", "recommended"]}
|
queryKey={["livetv", "recommended"]}
|
||||||
title={t("live_tv.on_now")}
|
title={t("live_tv.on_now")}
|
||||||
@@ -45,7 +45,7 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
return res.data.Items || [];
|
return res.data.Items || [];
|
||||||
}}
|
}}
|
||||||
orientation="horizontal"
|
orientation='horizontal'
|
||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryKey={["livetv", "shows"]}
|
queryKey={["livetv", "shows"]}
|
||||||
@@ -67,7 +67,7 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
return res.data.Items || [];
|
return res.data.Items || [];
|
||||||
}}
|
}}
|
||||||
orientation="horizontal"
|
orientation='horizontal'
|
||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryKey={["livetv", "movies"]}
|
queryKey={["livetv", "movies"]}
|
||||||
@@ -85,7 +85,7 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
return res.data.Items || [];
|
return res.data.Items || [];
|
||||||
}}
|
}}
|
||||||
orientation="horizontal"
|
orientation='horizontal'
|
||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryKey={["livetv", "sports"]}
|
queryKey={["livetv", "sports"]}
|
||||||
@@ -103,7 +103,7 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
return res.data.Items || [];
|
return res.data.Items || [];
|
||||||
}}
|
}}
|
||||||
orientation="horizontal"
|
orientation='horizontal'
|
||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryKey={["livetv", "kids"]}
|
queryKey={["livetv", "kids"]}
|
||||||
@@ -121,7 +121,7 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
return res.data.Items || [];
|
return res.data.Items || [];
|
||||||
}}
|
}}
|
||||||
orientation="horizontal"
|
orientation='horizontal'
|
||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryKey={["livetv", "news"]}
|
queryKey={["livetv", "news"]}
|
||||||
@@ -139,7 +139,7 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
return res.data.Items || [];
|
return res.data.Items || [];
|
||||||
}}
|
}}
|
||||||
orientation="horizontal"
|
orientation='horizontal'
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { View } from "react-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<View className="flex items-center justify-center h-full -mt-12">
|
<View className='flex items-center justify-center h-full -mt-12'>
|
||||||
<Text>{t("live_tv.coming_soon")}</Text>
|
<Text>{t("live_tv.coming_soon")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo } from "react";
|
import type React from "react";
|
||||||
import { View } from "react-native";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, View } from "react-native";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -49,7 +50,7 @@ const page: React.FC = () => {
|
|||||||
quality: 90,
|
quality: 90,
|
||||||
width: 1000,
|
width: 1000,
|
||||||
}),
|
}),
|
||||||
[item]
|
[item],
|
||||||
);
|
);
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
const logoUrl = useMemo(
|
||||||
@@ -58,7 +59,7 @@ const page: React.FC = () => {
|
|||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
}),
|
}),
|
||||||
[item]
|
[item],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: allEpisodes, isLoading } = useQuery({
|
const { data: allEpisodes, isLoading } = useQuery({
|
||||||
@@ -83,23 +84,25 @@ const page: React.FC = () => {
|
|||||||
item &&
|
item &&
|
||||||
allEpisodes &&
|
allEpisodes &&
|
||||||
allEpisodes.length > 0 && (
|
allEpisodes.length > 0 && (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
<AddToFavorites item={item} type="series" />
|
<AddToFavorites item={item} />
|
||||||
<DownloadItems
|
{!Platform.isTV && (
|
||||||
size="large"
|
<DownloadItems
|
||||||
title={t("item_card.download.download_series")}
|
size='large'
|
||||||
items={allEpisodes || []}
|
title={t("item_card.download.download_series")}
|
||||||
MissingDownloadIconComponent={() => (
|
items={allEpisodes || []}
|
||||||
<Ionicons name="download" size={22} color="white" />
|
MissingDownloadIconComponent={() => (
|
||||||
)}
|
<Ionicons name='download' size={22} color='white' />
|
||||||
DownloadedIconComponent={() => (
|
)}
|
||||||
<Ionicons
|
DownloadedIconComponent={() => (
|
||||||
name="checkmark-done-outline"
|
<Ionicons
|
||||||
size={24}
|
name='checkmark-done-outline'
|
||||||
color="#9333ea"
|
size={24}
|
||||||
/>
|
color='#9333ea'
|
||||||
)}
|
/>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -122,25 +125,23 @@ const page: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
logo={
|
logo={
|
||||||
<>
|
logoUrl ? (
|
||||||
{logoUrl ? (
|
<Image
|
||||||
<Image
|
source={{
|
||||||
source={{
|
uri: logoUrl,
|
||||||
uri: logoUrl,
|
}}
|
||||||
}}
|
style={{
|
||||||
style={{
|
height: 130,
|
||||||
height: 130,
|
width: "100%",
|
||||||
width: "100%",
|
resizeMode: "contain",
|
||||||
resizeMode: "contain",
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
) : null
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col pt-4">
|
<View className='flex flex-col pt-4'>
|
||||||
<SeriesHeader item={item} />
|
<SeriesHeader item={item} />
|
||||||
<View className="mb-4">
|
<View className='mb-4'>
|
||||||
<NextUp seriesId={seriesId} />
|
<NextUp seriesId={seriesId} />
|
||||||
</View>
|
</View>
|
||||||
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo } from "react";
|
import React, { useCallback, useEffect, useMemo } from "react";
|
||||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
import { FlatList, View, useWindowDimensions } from "react-native";
|
||||||
|
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
|
SortByOption,
|
||||||
|
SortOrderOption,
|
||||||
genreFilterAtom,
|
genreFilterAtom,
|
||||||
getSortByPreference,
|
getSortByPreference,
|
||||||
getSortOrderPreference,
|
getSortOrderPreference,
|
||||||
sortByAtom,
|
sortByAtom,
|
||||||
SortByOption,
|
|
||||||
sortByPreferenceAtom,
|
sortByPreferenceAtom,
|
||||||
sortOptions,
|
sortOptions,
|
||||||
sortOrderAtom,
|
sortOrderAtom,
|
||||||
SortOrderOption,
|
|
||||||
sortOrderOptions,
|
sortOrderOptions,
|
||||||
sortOrderPreferenceAtom,
|
sortOrderPreferenceAtom,
|
||||||
tagsFilterAtom,
|
tagsFilterAtom,
|
||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
import {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
@@ -40,8 +40,8 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -58,7 +58,7 @@ const Page = () => {
|
|||||||
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
||||||
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
||||||
const [sortOrderPreference, setOderByPreference] = useAtom(
|
const [sortOrderPreference, setOderByPreference] = useAtom(
|
||||||
sortOrderPreferenceAtom
|
sortOrderPreferenceAtom,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
@@ -88,7 +88,7 @@ const Page = () => {
|
|||||||
}
|
}
|
||||||
_setSortBy(sortBy);
|
_setSortBy(sortBy);
|
||||||
},
|
},
|
||||||
[libraryId, sortByPreference]
|
[libraryId, sortByPreference],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setSortOrder = useCallback(
|
const setSortOrder = useCallback(
|
||||||
@@ -102,7 +102,7 @@ const Page = () => {
|
|||||||
}
|
}
|
||||||
_setSortOrder(sortOrder);
|
_setSortOrder(sortOrder);
|
||||||
},
|
},
|
||||||
[libraryId, sortOrderPreference]
|
[libraryId, sortOrderPreference],
|
||||||
);
|
);
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
@@ -169,7 +169,7 @@ const Page = () => {
|
|||||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||||
genres: selectedGenres,
|
genres: selectedGenres,
|
||||||
tags: selectedTags,
|
tags: selectedTags,
|
||||||
years: selectedYears.map((year) => parseInt(year)),
|
years: selectedYears.map((year) => Number.parseInt(year)),
|
||||||
includeItemTypes: itemType ? [itemType] : undefined,
|
includeItemTypes: itemType ? [itemType] : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,7 +185,7 @@ const Page = () => {
|
|||||||
selectedTags,
|
selectedTags,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||||
@@ -211,14 +211,13 @@ const Page = () => {
|
|||||||
const totalItems = lastPage.TotalRecordCount;
|
const totalItems = lastPage.TotalRecordCount;
|
||||||
const accumulatedItems = pages.reduce(
|
const accumulatedItems = pages.reduce(
|
||||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (accumulatedItems < totalItems) {
|
if (accumulatedItems < totalItems) {
|
||||||
return lastPage?.Items?.length * pages.length;
|
return lastPage?.Items?.length * pages.length;
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
},
|
},
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
enabled: !!api && !!user?.Id && !!library,
|
enabled: !!api && !!user?.Id && !!library,
|
||||||
@@ -248,8 +247,8 @@ const Page = () => {
|
|||||||
? index % nrOfCols === 0
|
? index % nrOfCols === 0
|
||||||
? "flex-end"
|
? "flex-end"
|
||||||
: (index + 1) % nrOfCols === 0
|
: (index + 1) % nrOfCols === 0
|
||||||
? "flex-start"
|
? "flex-start"
|
||||||
: "center"
|
: "center"
|
||||||
: "center",
|
: "center",
|
||||||
width: "89%",
|
width: "89%",
|
||||||
}}
|
}}
|
||||||
@@ -260,14 +259,14 @@ const Page = () => {
|
|||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
),
|
),
|
||||||
[orientation]
|
[orientation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|
||||||
const ListHeaderComponent = useCallback(
|
const ListHeaderComponent = useCallback(
|
||||||
() => (
|
() => (
|
||||||
<View className="">
|
<View className=''>
|
||||||
<FlatList
|
<FlatList
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
@@ -286,13 +285,13 @@ const Page = () => {
|
|||||||
key: "genre",
|
key: "genre",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
collectionId={libraryId}
|
id={libraryId}
|
||||||
queryKey="genreFilter"
|
queryKey='genreFilter'
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getFilterApi(
|
const response = await getFilterApi(
|
||||||
api
|
api,
|
||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: libraryId,
|
parentId: libraryId,
|
||||||
@@ -313,13 +312,13 @@ const Page = () => {
|
|||||||
key: "year",
|
key: "year",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
collectionId={libraryId}
|
id={libraryId}
|
||||||
queryKey="yearFilter"
|
queryKey='yearFilter'
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getFilterApi(
|
const response = await getFilterApi(
|
||||||
api
|
api,
|
||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: libraryId,
|
parentId: libraryId,
|
||||||
@@ -338,13 +337,13 @@ const Page = () => {
|
|||||||
key: "tags",
|
key: "tags",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
collectionId={libraryId}
|
id={libraryId}
|
||||||
queryKey="tagsFilter"
|
queryKey='tagsFilter'
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getFilterApi(
|
const response = await getFilterApi(
|
||||||
api
|
api,
|
||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: libraryId,
|
parentId: libraryId,
|
||||||
@@ -365,10 +364,18 @@ const Page = () => {
|
|||||||
key: "sortBy",
|
key: "sortBy",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
collectionId={libraryId}
|
id={libraryId}
|
||||||
queryKey="sortBy"
|
queryKey='sortBy'
|
||||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
queryFn={async () =>
|
||||||
|
sortOptions
|
||||||
|
.filter(
|
||||||
|
(s) =>
|
||||||
|
library?.CollectionType !== "movies" ||
|
||||||
|
s.key !== SortByOption.DateLastContentAdded,
|
||||||
|
)
|
||||||
|
.map((s) => s.key)
|
||||||
|
}
|
||||||
set={setSortBy}
|
set={setSortBy}
|
||||||
values={sortBy}
|
values={sortBy}
|
||||||
title={t("library.filters.sort_by")}
|
title={t("library.filters.sort_by")}
|
||||||
@@ -385,9 +392,9 @@ const Page = () => {
|
|||||||
key: "sortOrder",
|
key: "sortOrder",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
collectionId={libraryId}
|
id={libraryId}
|
||||||
queryKey="sortOrder"
|
queryKey='sortOrder'
|
||||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
set={setSortOrder}
|
set={setSortOrder}
|
||||||
values={sortOrder}
|
values={sortOrder}
|
||||||
@@ -422,34 +429,29 @@ const Page = () => {
|
|||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
isFetching,
|
isFetching,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
if (isLoading || isLibraryLoading)
|
if (isLoading || isLibraryLoading)
|
||||||
return (
|
return (
|
||||||
<View className="w-full h-full flex items-center justify-center">
|
<View className='w-full h-full flex items-center justify-center'>
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (flatData.length === 0)
|
|
||||||
return (
|
|
||||||
<View className="h-full w-full flex justify-center items-center">
|
|
||||||
<Text className="text-lg text-neutral-500">{t("library.no_items_found")}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
key={orientation}
|
key={orientation}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className="flex flex-col items-center justify-center h-full">
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
<Text className="font-bold text-xl text-neutral-500">{t("library.no_results")}</Text>
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
{t("library.no_results")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
data={flatData}
|
data={flatData}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
extraData={[orientation, nrOfCols]}
|
extraData={[orientation, nrOfCols]}
|
||||||
@@ -474,7 +476,7 @@ const Page = () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
}}
|
}}
|
||||||
></View>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function IndexLayout() {
|
|||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="index"
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
@@ -25,7 +25,7 @@ export default function IndexLayout() {
|
|||||||
headerLargeStyle: {
|
headerLargeStyle: {
|
||||||
backgroundColor: "black",
|
backgroundColor: "black",
|
||||||
},
|
},
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
!pluginSettings?.libraryOptions?.locked &&
|
!pluginSettings?.libraryOptions?.locked &&
|
||||||
@@ -33,9 +33,9 @@ export default function IndexLayout() {
|
|||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="ellipsis-horizontal-outline"
|
name='ellipsis-horizontal-outline'
|
||||||
size={24}
|
size={24}
|
||||||
color="white"
|
color='white'
|
||||||
/>
|
/>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
@@ -50,9 +50,9 @@ export default function IndexLayout() {
|
|||||||
<DropdownMenu.Label>
|
<DropdownMenu.Label>
|
||||||
{t("library.options.display")}
|
{t("library.options.display")}
|
||||||
</DropdownMenu.Label>
|
</DropdownMenu.Label>
|
||||||
<DropdownMenu.Group key="display-group">
|
<DropdownMenu.Group key='display-group'>
|
||||||
<DropdownMenu.Sub>
|
<DropdownMenu.Sub>
|
||||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
<DropdownMenu.SubTrigger key='image-style-trigger'>
|
||||||
{t("library.options.display")}
|
{t("library.options.display")}
|
||||||
</DropdownMenu.SubTrigger>
|
</DropdownMenu.SubTrigger>
|
||||||
<DropdownMenu.SubContent
|
<DropdownMenu.SubContent
|
||||||
@@ -63,7 +63,7 @@ export default function IndexLayout() {
|
|||||||
sideOffset={10}
|
sideOffset={10}
|
||||||
>
|
>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
key="display-option-1"
|
key='display-option-1'
|
||||||
value={settings.libraryOptions.display === "row"}
|
value={settings.libraryOptions.display === "row"}
|
||||||
onValueChange={() =>
|
onValueChange={() =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -75,12 +75,12 @@ export default function IndexLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemIndicator />
|
<DropdownMenu.ItemIndicator />
|
||||||
<DropdownMenu.ItemTitle key="display-title-1">
|
<DropdownMenu.ItemTitle key='display-title-1'>
|
||||||
{t("library.options.row")}
|
{t("library.options.row")}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
key="display-option-2"
|
key='display-option-2'
|
||||||
value={settings.libraryOptions.display === "list"}
|
value={settings.libraryOptions.display === "list"}
|
||||||
onValueChange={() =>
|
onValueChange={() =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -92,14 +92,14 @@ export default function IndexLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemIndicator />
|
<DropdownMenu.ItemIndicator />
|
||||||
<DropdownMenu.ItemTitle key="display-title-2">
|
<DropdownMenu.ItemTitle key='display-title-2'>
|
||||||
{t("library.options.list")}
|
{t("library.options.list")}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
</DropdownMenu.SubContent>
|
</DropdownMenu.SubContent>
|
||||||
</DropdownMenu.Sub>
|
</DropdownMenu.Sub>
|
||||||
<DropdownMenu.Sub>
|
<DropdownMenu.Sub>
|
||||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
<DropdownMenu.SubTrigger key='image-style-trigger'>
|
||||||
{t("library.options.image_style")}
|
{t("library.options.image_style")}
|
||||||
</DropdownMenu.SubTrigger>
|
</DropdownMenu.SubTrigger>
|
||||||
<DropdownMenu.SubContent
|
<DropdownMenu.SubContent
|
||||||
@@ -110,7 +110,7 @@ export default function IndexLayout() {
|
|||||||
sideOffset={10}
|
sideOffset={10}
|
||||||
>
|
>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
key="poster-option"
|
key='poster-option'
|
||||||
value={
|
value={
|
||||||
settings.libraryOptions.imageStyle === "poster"
|
settings.libraryOptions.imageStyle === "poster"
|
||||||
}
|
}
|
||||||
@@ -124,12 +124,12 @@ export default function IndexLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemIndicator />
|
<DropdownMenu.ItemIndicator />
|
||||||
<DropdownMenu.ItemTitle key="poster-title">
|
<DropdownMenu.ItemTitle key='poster-title'>
|
||||||
{t("library.options.poster")}
|
{t("library.options.poster")}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
key="cover-option"
|
key='cover-option'
|
||||||
value={settings.libraryOptions.imageStyle === "cover"}
|
value={settings.libraryOptions.imageStyle === "cover"}
|
||||||
onValueChange={() =>
|
onValueChange={() =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -141,17 +141,17 @@ export default function IndexLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemIndicator />
|
<DropdownMenu.ItemIndicator />
|
||||||
<DropdownMenu.ItemTitle key="cover-title">
|
<DropdownMenu.ItemTitle key='cover-title'>
|
||||||
{t("library.options.cover")}
|
{t("library.options.cover")}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
</DropdownMenu.SubContent>
|
</DropdownMenu.SubContent>
|
||||||
</DropdownMenu.Sub>
|
</DropdownMenu.Sub>
|
||||||
</DropdownMenu.Group>
|
</DropdownMenu.Group>
|
||||||
<DropdownMenu.Group key="show-titles-group">
|
<DropdownMenu.Group key='show-titles-group'>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
disabled={settings.libraryOptions.imageStyle === "poster"}
|
disabled={settings.libraryOptions.imageStyle === "poster"}
|
||||||
key="show-titles-option"
|
key='show-titles-option'
|
||||||
value={settings.libraryOptions.showTitles}
|
value={settings.libraryOptions.showTitles}
|
||||||
onValueChange={(newValue: string) => {
|
onValueChange={(newValue: string) => {
|
||||||
if (settings.libraryOptions.imageStyle === "poster")
|
if (settings.libraryOptions.imageStyle === "poster")
|
||||||
@@ -159,30 +159,30 @@ export default function IndexLayout() {
|
|||||||
updateSettings({
|
updateSettings({
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
...settings.libraryOptions,
|
...settings.libraryOptions,
|
||||||
showTitles: newValue === "on" ? true : false,
|
showTitles: newValue === "on",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemIndicator />
|
<DropdownMenu.ItemIndicator />
|
||||||
<DropdownMenu.ItemTitle key="show-titles-title">
|
<DropdownMenu.ItemTitle key='show-titles-title'>
|
||||||
{t("library.options.show_titles")}
|
{t("library.options.show_titles")}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
key="show-stats-option"
|
key='show-stats-option'
|
||||||
value={settings.libraryOptions.showStats}
|
value={settings.libraryOptions.showStats}
|
||||||
onValueChange={(newValue: string) => {
|
onValueChange={(newValue: string) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
...settings.libraryOptions,
|
...settings.libraryOptions,
|
||||||
showStats: newValue === "on" ? true : false,
|
showStats: newValue === "on",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemIndicator />
|
<DropdownMenu.ItemIndicator />
|
||||||
<DropdownMenu.ItemTitle key="show-stats-title">
|
<DropdownMenu.ItemTitle key='show-stats-title'>
|
||||||
{t("library.options.show_stats")}
|
{t("library.options.show_stats")}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
@@ -195,12 +195,12 @@ export default function IndexLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="[libraryId]"
|
name='[libraryId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -208,12 +208,12 @@ export default function IndexLayout() {
|
|||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="collections/[collectionId]"
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { Loader } from "@/components/Loader";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
@@ -11,9 +11,9 @@ import { FlashList } from "@shopify/flash-list";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { StyleSheet, View } from "react-native";
|
import { StyleSheet, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export default function index() {
|
export default function index() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -23,7 +23,7 @@ export default function index() {
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data, isLoading: isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["user-views", user?.Id],
|
queryKey: ["user-views", user?.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await getUserViewsApi(api!).getUserViews({
|
const response = await getUserViewsApi(api!).getUserViews({
|
||||||
@@ -41,7 +41,7 @@ export default function index() {
|
|||||||
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
||||||
.filter((l) => l.CollectionType !== "music")
|
.filter((l) => l.CollectionType !== "music")
|
||||||
.filter((l) => l.CollectionType !== "books") || [],
|
.filter((l) => l.CollectionType !== "books") || [],
|
||||||
[data, settings?.hiddenLibraries]
|
[data, settings?.hiddenLibraries],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,22 +65,24 @@ export default function index() {
|
|||||||
|
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return (
|
||||||
<View className="justify-center items-center h-full">
|
<View className='justify-center items-center h-full'>
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!libraries)
|
if (!libraries)
|
||||||
return (
|
return (
|
||||||
<View className="h-full w-full flex justify-center items-center">
|
<View className='h-full w-full flex justify-center items-center'>
|
||||||
<Text className="text-lg text-neutral-500">{t("library.no_libraries_found")}</Text>
|
<Text className='text-lg text-neutral-500'>
|
||||||
|
{t("library.no_libraries_found")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
extraData={settings}
|
extraData={settings}
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: 17,
|
paddingTop: 17,
|
||||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
||||||
@@ -97,10 +99,10 @@ export default function index() {
|
|||||||
style={{
|
style={{
|
||||||
height: StyleSheet.hairlineWidth,
|
height: StyleSheet.hairlineWidth,
|
||||||
}}
|
}}
|
||||||
className="bg-neutral-800 mx-2 my-4"
|
className='bg-neutral-800 mx-2 my-4'
|
||||||
></View>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View className="h-4" />
|
<View className='h-4' />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
estimatedItemSize={200}
|
estimatedItemSize={200}
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ import {
|
|||||||
nestedTabPageScreenOptions,
|
nestedTabPageScreenOptions,
|
||||||
} from "@/components/stacks/NestedTabPageStack";
|
} from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
export default function SearchLayout() {
|
export default function SearchLayout() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="index"
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
@@ -20,7 +20,7 @@ export default function SearchLayout() {
|
|||||||
backgroundColor: "black",
|
backgroundColor: "black",
|
||||||
},
|
},
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -28,26 +28,26 @@ export default function SearchLayout() {
|
|||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="collections/[collectionId]"
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
<Stack.Screen name='jellyseerr/page' options={commonScreenOptions} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="jellyseerr/person/[personId]"
|
name='jellyseerr/person/[personId]'
|
||||||
options={commonScreenOptions}
|
options={commonScreenOptions}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="jellyseerr/company/[companyId]"
|
name='jellyseerr/company/[companyId]'
|
||||||
options={commonScreenOptions}
|
options={commonScreenOptions}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="jellyseerr/genre/[genreId]"
|
name='jellyseerr/genre/[genreId]'
|
||||||
options={commonScreenOptions}
|
options={commonScreenOptions}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Input } from "@/components/common/Input";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import { Tag } from "@/components/GenreTags";
|
import { Tag } from "@/components/GenreTags";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import {
|
||||||
|
JellyseerrSearchSort,
|
||||||
|
JellyserrIndexPage,
|
||||||
|
} from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||||
@@ -12,26 +15,28 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
|||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
|
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
@@ -48,6 +53,8 @@ export default function search() {
|
|||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { q } = params as { q: string };
|
const { q } = params as { q: string };
|
||||||
@@ -58,17 +65,27 @@ export default function search() {
|
|||||||
const [debouncedSearch] = useDebounce(search, 500);
|
const [debouncedSearch] = useDebounce(search, 500);
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
||||||
|
useState<JellyseerrSearchSort>(
|
||||||
|
JellyseerrSearchSort[
|
||||||
|
JellyseerrSearchSort.DEFAULT
|
||||||
|
] as unknown as JellyseerrSearchSort,
|
||||||
|
);
|
||||||
|
const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<
|
||||||
|
"asc" | "desc"
|
||||||
|
>("desc");
|
||||||
|
|
||||||
const searchEngine = useMemo(() => {
|
const searchEngine = useMemo(() => {
|
||||||
return settings?.searchEngine || "Jellyfin";
|
return settings?.searchEngine || "Jellyfin";
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (q && q.length > 0) setSearch(q);
|
if (q && q.length > 0) {
|
||||||
|
setSearch(q);
|
||||||
|
}
|
||||||
}, [q]);
|
}, [q]);
|
||||||
|
|
||||||
const searchFn = useCallback(
|
const searchFn = useCallback(
|
||||||
@@ -79,62 +96,94 @@ export default function search() {
|
|||||||
types: BaseItemKind[];
|
types: BaseItemKind[];
|
||||||
query: string;
|
query: string;
|
||||||
}): Promise<BaseItemDto[]> => {
|
}): Promise<BaseItemDto[]> => {
|
||||||
if (!api || !query) return [];
|
if (!api || !query) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (searchEngine === "Jellyfin") {
|
if (searchEngine === "Jellyfin") {
|
||||||
const searchApi = await getSearchApi(api).getSearchHints({
|
const searchApi = await getItemsApi(api).getItems({
|
||||||
searchTerm: query,
|
searchTerm: query,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
includeItemTypes: types,
|
includeItemTypes: types,
|
||||||
|
recursive: true,
|
||||||
|
userId: user?.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
|
return (searchApi.data.Items as BaseItemDto[]) || [];
|
||||||
} else {
|
|
||||||
if (!settings?.marlinServerUrl) return [];
|
|
||||||
|
|
||||||
const url = `${
|
|
||||||
settings.marlinServerUrl
|
|
||||||
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
|
||||||
.map((type) => encodeURIComponent(type))
|
|
||||||
.join("&includeItemTypes=")}`;
|
|
||||||
|
|
||||||
const response1 = await axios.get(url);
|
|
||||||
|
|
||||||
const ids = response1.data.ids;
|
|
||||||
|
|
||||||
if (!ids || !ids.length) return [];
|
|
||||||
|
|
||||||
const response2 = await getItemsApi(api).getItems({
|
|
||||||
ids,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return (response2.data.Items as BaseItemDto[]) || [];
|
|
||||||
}
|
}
|
||||||
|
if (!settings?.marlinServerUrl) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${
|
||||||
|
settings.marlinServerUrl
|
||||||
|
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
||||||
|
.map((type) => encodeURIComponent(type))
|
||||||
|
.join("&includeItemTypes=")}`;
|
||||||
|
|
||||||
|
const response1 = await axios.get(url);
|
||||||
|
|
||||||
|
const ids = response1.data.ids;
|
||||||
|
|
||||||
|
if (!ids || !ids.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response2 = await getItemsApi(api).getItems({
|
||||||
|
ids,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (response2.data.Items as BaseItemDto[]) || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error during search:", error);
|
console.error("Error during search:", error);
|
||||||
return []; // Ensure an empty array is returned in case of an error
|
return []; // Ensure an empty array is returned in case of an error
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, searchEngine, settings]
|
[api, searchEngine, settings],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
type HeaderSearchBarRef = {
|
||||||
|
focus: () => void;
|
||||||
|
blur: () => void;
|
||||||
|
setText: (text: string) => void;
|
||||||
|
clearText: () => void;
|
||||||
|
cancelSearch: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchBarRef = useRef<HeaderSearchBarRef>(null);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerSearchBarOptions: {
|
headerSearchBarOptions: {
|
||||||
|
ref: searchBarRef,
|
||||||
placeholder: t("search.search"),
|
placeholder: t("search.search"),
|
||||||
onChangeText: (e: any) => {
|
onChangeText: (e: any) => {
|
||||||
router.setParams({ q: "" });
|
router.setParams({ q: "" });
|
||||||
setSearch(e.nativeEvent.text);
|
setSearch(e.nativeEvent.text);
|
||||||
},
|
},
|
||||||
hideWhenScrolling: false,
|
hideWhenScrolling: false,
|
||||||
autoFocus: true,
|
autoFocus: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = eventBus.on("searchTabPressed", () => {
|
||||||
|
// Screen not active
|
||||||
|
if (!searchBarRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Screen is active, focus search bar
|
||||||
|
searchBarRef.current?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { data: movies, isFetching: l1 } = useQuery({
|
const { data: movies, isFetching: l1 } = useQuery({
|
||||||
queryKey: ["search", "movies", debouncedSearch],
|
queryKey: ["search", "movies", debouncedSearch],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -202,20 +251,28 @@ export default function search() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
keyboardDismissMode="on-drag"
|
keyboardDismissMode='on-drag'
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col">
|
<View
|
||||||
|
className='flex flex-col'
|
||||||
|
style={{
|
||||||
|
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{jellyseerrApi && (
|
{jellyseerrApi && (
|
||||||
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
||||||
|
>
|
||||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||||
<Tag
|
<Tag
|
||||||
text={t("search.library")}
|
text={t("search.library")}
|
||||||
textClass="p-1"
|
textClass='p-1'
|
||||||
className={
|
className={
|
||||||
searchType === "Library" ? "bg-purple-600" : undefined
|
searchType === "Library" ? "bg-purple-600" : undefined
|
||||||
}
|
}
|
||||||
@@ -224,16 +281,49 @@ export default function search() {
|
|||||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||||
<Tag
|
<Tag
|
||||||
text={t("search.discover")}
|
text={t("search.discover")}
|
||||||
textClass="p-1"
|
textClass='p-1'
|
||||||
className={
|
className={
|
||||||
searchType === "Discover" ? "bg-purple-600" : undefined
|
searchType === "Discover" ? "bg-purple-600" : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
{searchType === "Discover" &&
|
||||||
|
!loading &&
|
||||||
|
noResults &&
|
||||||
|
debouncedSearch.length > 0 && (
|
||||||
|
<View className='flex flex-row justify-end items-center space-x-1'>
|
||||||
|
<FilterButton
|
||||||
|
id='search'
|
||||||
|
queryKey='jellyseerr_search'
|
||||||
|
queryFn={async () =>
|
||||||
|
Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||||
|
Number.isNaN(Number(v)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
set={(value) => setJellyseerrOrderBy(value[0])}
|
||||||
|
values={[jellyseerrOrderBy]}
|
||||||
|
title={t("library.filters.sort_by")}
|
||||||
|
renderItemLabel={(item) =>
|
||||||
|
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
||||||
|
}
|
||||||
|
showSearch={false}
|
||||||
|
/>
|
||||||
|
<FilterButton
|
||||||
|
id='order'
|
||||||
|
queryKey='jellysearr_search'
|
||||||
|
queryFn={async () => ["asc", "desc"]}
|
||||||
|
set={(value) => setJellyseerrSortOrder(value[0])}
|
||||||
|
values={[jellyseerrSortOrder]}
|
||||||
|
title={t("library.filters.sort_order")}
|
||||||
|
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
||||||
|
showSearch={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View className="mt-2">
|
<View className='mt-2'>
|
||||||
<LoadingSkeleton isLoading={loading} />
|
<LoadingSkeleton isLoading={loading} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -241,50 +331,50 @@ export default function search() {
|
|||||||
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
header={t("search.movies")}
|
header={t("search.movies")}
|
||||||
ids={movies?.map((m) => m.Id!)}
|
items={movies}
|
||||||
renderItem={(item: BaseItemDto) => (
|
renderItem={(item: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-28 mr-2"
|
className='flex flex-col w-28 mr-2'
|
||||||
item={item}
|
item={item}
|
||||||
>
|
>
|
||||||
<MoviePoster item={item} key={item.Id} />
|
<MoviePoster item={item} key={item.Id} />
|
||||||
<Text numberOfLines={2} className="mt-2">
|
<Text numberOfLines={2} className='mt-2'>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="opacity-50 text-xs">
|
<Text className='opacity-50 text-xs'>
|
||||||
{item.ProductionYear}
|
{item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={series?.map((m) => m.Id!)}
|
items={series}
|
||||||
header={t("search.series")}
|
header={t("search.series")}
|
||||||
renderItem={(item: BaseItemDto) => (
|
renderItem={(item: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
item={item}
|
item={item}
|
||||||
className="flex flex-col w-28 mr-2"
|
className='flex flex-col w-28 mr-2'
|
||||||
>
|
>
|
||||||
<SeriesPoster item={item} key={item.Id} />
|
<SeriesPoster item={item} key={item.Id} />
|
||||||
<Text numberOfLines={2} className="mt-2">
|
<Text numberOfLines={2} className='mt-2'>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="opacity-50 text-xs">
|
<Text className='opacity-50 text-xs'>
|
||||||
{item.ProductionYear}
|
{item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={episodes?.map((m) => m.Id!)}
|
items={episodes}
|
||||||
header={t("search.episodes")}
|
header={t("search.episodes")}
|
||||||
renderItem={(item: BaseItemDto) => (
|
renderItem={(item: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-44 mr-2"
|
className='flex flex-col w-44 mr-2'
|
||||||
>
|
>
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster item={item} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
@@ -292,29 +382,29 @@ export default function search() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={collections?.map((m) => m.Id!)}
|
items={collections}
|
||||||
header={t("search.collections")}
|
header={t("search.collections")}
|
||||||
renderItem={(item: BaseItemDto) => (
|
renderItem={(item: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
item={item}
|
item={item}
|
||||||
className="flex flex-col w-28 mr-2"
|
className='flex flex-col w-28 mr-2'
|
||||||
>
|
>
|
||||||
<MoviePoster item={item} key={item.Id} />
|
<MoviePoster item={item} key={item.Id} />
|
||||||
<Text numberOfLines={2} className="mt-2">
|
<Text numberOfLines={2} className='mt-2'>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={actors?.map((m) => m.Id!)}
|
items={actors}
|
||||||
header={t("search.actors")}
|
header={t("search.actors")}
|
||||||
renderItem={(item: BaseItemDto) => (
|
renderItem={(item: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-28 mr-2"
|
className='flex flex-col w-28 mr-2'
|
||||||
>
|
>
|
||||||
<MoviePoster item={item} />
|
<MoviePoster item={item} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
@@ -323,35 +413,39 @@ export default function search() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<JellyserrIndexPage searchQuery={debouncedSearch} />
|
<JellyserrIndexPage
|
||||||
|
searchQuery={debouncedSearch}
|
||||||
|
sortType={jellyseerrOrderBy}
|
||||||
|
order={jellyseerrSortOrder}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{searchType === "Library" && (
|
{searchType === "Library" &&
|
||||||
<>
|
(!loading && noResults && debouncedSearch.length > 0 ? (
|
||||||
{!loading && noResults && debouncedSearch.length > 0 ? (
|
<View>
|
||||||
<View>
|
<Text className='text-center text-lg font-bold mt-4'>
|
||||||
<Text className="text-center text-lg font-bold mt-4">
|
{t("search.no_results_found_for")}
|
||||||
{t("search.no_results_found_for")}
|
</Text>
|
||||||
</Text>
|
<Text className='text-xs text-purple-600 text-center'>
|
||||||
<Text className="text-xs text-purple-600 text-center">
|
"{debouncedSearch}"
|
||||||
"{debouncedSearch}"
|
</Text>
|
||||||
</Text>
|
</View>
|
||||||
</View>
|
) : debouncedSearch.length === 0 ? (
|
||||||
) : debouncedSearch.length === 0 ? (
|
<View className='mt-4 flex flex-col items-center space-y-2'>
|
||||||
<View className="mt-4 flex flex-col items-center space-y-2">
|
{exampleSearches.map((e) => (
|
||||||
{exampleSearches.map((e) => (
|
<TouchableOpacity
|
||||||
<TouchableOpacity
|
onPress={() => {
|
||||||
onPress={() => setSearch(e)}
|
setSearch(e);
|
||||||
key={e}
|
searchBarRef.current?.setText(e);
|
||||||
className="mb-2"
|
}}
|
||||||
>
|
key={e}
|
||||||
<Text className="text-purple-600">{e}</Text>
|
className='mb-2'
|
||||||
</TouchableOpacity>
|
>
|
||||||
))}
|
<Text className='text-purple-600'>{e}</Text>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
) : null}
|
))}
|
||||||
</>
|
</View>
|
||||||
)}
|
) : null)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import React, { useCallback, useRef } from "react";
|
import React, { useCallback, useRef } from "react";
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type NativeBottomTabNavigationEventMap,
|
||||||
createNativeBottomTabNavigator,
|
createNativeBottomTabNavigator,
|
||||||
NativeBottomTabNavigationEventMap,
|
|
||||||
} from "@bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
|
|
||||||
const { Navigator } = createNativeBottomTabNavigator();
|
const { Navigator } = createNativeBottomTabNavigator();
|
||||||
|
import type { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
||||||
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
|
||||||
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { eventBus } from "@/utils/eventBus";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
@@ -46,26 +46,33 @@ export default function TabLayout() {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [])
|
}, []),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden={false} style="light" />
|
<SystemBars hidden={false} style='light' />
|
||||||
<NativeTabs
|
<NativeTabs
|
||||||
sidebarAdaptable={false}
|
sidebarAdaptable={false}
|
||||||
ignoresTopSafeArea
|
ignoresTopSafeArea
|
||||||
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
|
tabBarStyle={{
|
||||||
|
backgroundColor: "#121212",
|
||||||
|
}}
|
||||||
tabBarActiveTintColor={Colors.primary}
|
tabBarActiveTintColor={Colors.primary}
|
||||||
scrollEdgeAppearance="default"
|
scrollEdgeAppearance='default'
|
||||||
>
|
>
|
||||||
<NativeTabs.Screen redirect name="index" />
|
<NativeTabs.Screen redirect name='index' />
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
name="(home)"
|
listeners={({ navigation }) => ({
|
||||||
|
tabPress: (e) => {
|
||||||
|
eventBus.emit("scrollToTop");
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
name='(home)'
|
||||||
options={{
|
options={{
|
||||||
title: t("tabs.home"),
|
title: t("tabs.home"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS == "android"
|
Platform.OS === "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
require("@/assets/icons/house.fill.png")
|
require("@/assets/icons/house.fill.png")
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
@@ -75,11 +82,16 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
name="(search)"
|
listeners={({ navigation }) => ({
|
||||||
|
tabPress: (e) => {
|
||||||
|
eventBus.emit("searchTabPressed");
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
name='(search)'
|
||||||
options={{
|
options={{
|
||||||
title: t("tabs.search"),
|
title: t("tabs.search"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS == "android"
|
Platform.OS === "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
require("@/assets/icons/magnifyingglass.png")
|
require("@/assets/icons/magnifyingglass.png")
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
@@ -89,11 +101,11 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
name="(favorites)"
|
name='(favorites)'
|
||||||
options={{
|
options={{
|
||||||
title: t("tabs.favorites"),
|
title: t("tabs.favorites"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS == "android"
|
Platform.OS === "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
focused
|
focused
|
||||||
? require("@/assets/icons/heart.fill.png")
|
? require("@/assets/icons/heart.fill.png")
|
||||||
@@ -105,11 +117,11 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
name="(libraries)"
|
name='(libraries)'
|
||||||
options={{
|
options={{
|
||||||
title: t("tabs.library"),
|
title: t("tabs.library"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS == "android"
|
Platform.OS === "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
require("@/assets/icons/server.rack.png")
|
require("@/assets/icons/server.rack.png")
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
@@ -119,13 +131,13 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
name="(custom-links)"
|
name='(custom-links)'
|
||||||
options={{
|
options={{
|
||||||
title: t("tabs.custom_links"),
|
title: t("tabs.custom_links"),
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
|
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS == "android"
|
Platform.OS === "android"
|
||||||
? ({ focused }) => require("@/assets/icons/list.png")
|
? ({ focused }) => require("@/assets/icons/list.png")
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
focused
|
focused
|
||||||
|
|||||||
@@ -1,34 +1,39 @@
|
|||||||
import { Stack } from "expo-router";
|
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import React, { useLayoutEffect } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (settings.defaultVideoOrientation) {
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
|
if (!settings.followDeviceOrientation && settings.defaultVideoOrientation) {
|
||||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (settings.autoRotate === true) {
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
|
if (settings.followDeviceOrientation === true) {
|
||||||
ScreenOrientation.unlockAsync();
|
ScreenOrientation.unlockAsync();
|
||||||
} else {
|
} else {
|
||||||
ScreenOrientation.lockAsync(
|
ScreenOrientation.lockAsync(
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [settings]);
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden />
|
<SystemBars hidden />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="direct-player"
|
name='direct-player'
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
autoHideHomeIndicator: true,
|
autoHideHomeIndicator: true,
|
||||||
|
|||||||
@@ -1,50 +1,55 @@
|
|||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
import { Controls } from "@/components/video-player/controls/Controls";
|
||||||
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
import { VlcPlayerView } from "@/modules/vlc-player";
|
import { VlcPlayerView } from "@/modules";
|
||||||
import {
|
import type {
|
||||||
PipStartedPayload,
|
PipStartedPayload,
|
||||||
PlaybackStatePayload,
|
PlaybackStatePayload,
|
||||||
ProgressUpdatePayload,
|
ProgressUpdatePayload,
|
||||||
VlcPlayerViewRef,
|
VlcPlayerViewRef,
|
||||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
} from "@/modules/VlcPlayer.types";
|
||||||
// import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
const downloadProvider = !Platform.isTV
|
|
||||||
? require("@/providers/DownloadProvider")
|
|
||||||
: null;
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import native from "@/utils/profiles/native";
|
import generateDeviceProfile from "@/utils/profiles/native";
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
|
import {
|
||||||
|
type BaseItemDto,
|
||||||
|
type MediaSourceInfo,
|
||||||
|
PlaybackOrder,
|
||||||
|
type PlaybackProgressInfo,
|
||||||
|
PlaybackStartInfo,
|
||||||
|
RepeatMode,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import {
|
import {
|
||||||
getPlaystateApi,
|
getPlaystateApi,
|
||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { useGlobalSearchParams, useNavigation } from "expo-router";
|
import { useGlobalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Alert, View, AppState, AppStateStatus, Platform } from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Alert, Platform, View } from "react-native";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client";
|
const downloadProvider = !Platform.isTV
|
||||||
|
? require("@/providers/DownloadProvider")
|
||||||
|
: null;
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
console.log("Direct Player");
|
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
@@ -55,6 +60,7 @@ export default function page() {
|
|||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||||
const [isPipStarted, setIsPipStarted] = useState(false);
|
const [isPipStarted, setIsPipStarted] = useState(false);
|
||||||
@@ -62,6 +68,10 @@ export default function page() {
|
|||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
const cacheProgress = useSharedValue(0);
|
const cacheProgress = useSharedValue(0);
|
||||||
|
const VolumeManager = Platform.isTV
|
||||||
|
? null
|
||||||
|
: require("react-native-volume-manager");
|
||||||
|
|
||||||
let getDownloadedItem = null;
|
let getDownloadedItem = null;
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
getDownloadedItem = downloadProvider.useDownload();
|
getDownloadedItem = downloadProvider.useDownload();
|
||||||
@@ -92,152 +102,140 @@ export default function page() {
|
|||||||
offline: string;
|
offline: string;
|
||||||
}>();
|
}>();
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
|
|
||||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
const audioIndex = audioIndexStr
|
||||||
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
? Number.parseInt(audioIndexStr, 10)
|
||||||
|
: undefined;
|
||||||
|
const subtitleIndex = subtitleIndexStr
|
||||||
|
? Number.parseInt(subtitleIndexStr, 10)
|
||||||
|
: -1;
|
||||||
const bitrateValue = bitrateValueStr
|
const bitrateValue = bitrateValueStr
|
||||||
? parseInt(bitrateValueStr, 10)
|
? Number.parseInt(bitrateValueStr, 10)
|
||||||
: BITRATES[0].value;
|
: BITRATES[0].value;
|
||||||
|
|
||||||
const {
|
const [item, setItem] = useState<BaseItemDto | null>(null);
|
||||||
data: item,
|
const [itemStatus, setItemStatus] = useState({
|
||||||
isLoading: isLoadingItem,
|
isLoading: true,
|
||||||
isError: isErrorItem,
|
isError: false,
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["item", itemId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (offline && !Platform.isTV) {
|
|
||||||
const item = await getDownloadedItem.getDownloadedItem(itemId);
|
|
||||||
if (item) return item.item;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getUserLibraryApi(api!).getItem({
|
|
||||||
itemId,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
enabled: !!itemId,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [stream, setStream] = useState<{
|
|
||||||
mediaSource: MediaSourceInfo;
|
|
||||||
url: string;
|
|
||||||
sessionId: string | undefined;
|
|
||||||
} | null>(null);
|
|
||||||
const [isLoadingStream, setIsLoadingStream] = useState(true);
|
|
||||||
const [isErrorStream, setIsErrorStream] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStream = async () => {
|
const fetchItemData = async () => {
|
||||||
setIsLoadingStream(true);
|
setItemStatus({ isLoading: true, isError: false });
|
||||||
setIsErrorStream(false);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let fetchedItem: BaseItemDto | null = null;
|
||||||
if (offline && !Platform.isTV) {
|
if (offline && !Platform.isTV) {
|
||||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||||
if (!data?.mediaSource) {
|
if (data) fetchedItem = data.item as BaseItemDto;
|
||||||
setStream(null);
|
} else {
|
||||||
return;
|
const res = await getUserLibraryApi(api!).getItem({
|
||||||
}
|
itemId,
|
||||||
|
userId: user?.Id,
|
||||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
});
|
||||||
|
fetchedItem = res.data;
|
||||||
if (item) {
|
|
||||||
setStream({
|
|
||||||
mediaSource: data.mediaSource as MediaSourceInfo,
|
|
||||||
url,
|
|
||||||
sessionId: undefined,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
setItem(fetchedItem);
|
||||||
const res = await getStreamUrl({
|
setItemStatus({ isLoading: false, isError: false });
|
||||||
api,
|
|
||||||
item,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: audioIndex,
|
|
||||||
maxStreamingBitrate: bitrateValue,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
subtitleStreamIndex: subtitleIndex,
|
|
||||||
deviceProfile: native,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) {
|
|
||||||
setStream(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { mediaSource, sessionId, url } = res;
|
|
||||||
|
|
||||||
if (!sessionId || !mediaSource || !url) {
|
|
||||||
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
|
|
||||||
setStream(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStream({
|
|
||||||
mediaSource,
|
|
||||||
sessionId,
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching stream:", error);
|
console.error("Failed to fetch item:", error);
|
||||||
setIsErrorStream(true);
|
setItemStatus({ isLoading: false, isError: true });
|
||||||
setStream(null);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingStream(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchStream();
|
if (itemId) {
|
||||||
}, [itemId, mediaSourceId]);
|
fetchItemData();
|
||||||
|
}
|
||||||
|
}, [itemId, offline, api, user?.Id]);
|
||||||
|
|
||||||
const togglePlay = useCallback(async () => {
|
interface Stream {
|
||||||
if (!api) return;
|
mediaSource: MediaSourceInfo;
|
||||||
|
sessionId: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [stream, setStream] = useState<Stream | null>(null);
|
||||||
|
const [streamStatus, setStreamStatus] = useState({
|
||||||
|
isLoading: true,
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchStreamData = async () => {
|
||||||
|
setStreamStatus({ isLoading: true, isError: false });
|
||||||
|
const native = await generateDeviceProfile();
|
||||||
|
try {
|
||||||
|
let result: Stream | null = null;
|
||||||
|
if (offline && !Platform.isTV) {
|
||||||
|
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||||
|
if (!data?.mediaSource) return;
|
||||||
|
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||||
|
if (item) {
|
||||||
|
result = { mediaSource: data.mediaSource, sessionId: "", url };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const res = await getStreamUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
|
userId: user?.Id,
|
||||||
|
audioStreamIndex: audioIndex,
|
||||||
|
maxStreamingBitrate: bitrateValue,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
subtitleStreamIndex: subtitleIndex,
|
||||||
|
deviceProfile: native,
|
||||||
|
});
|
||||||
|
if (!res) return;
|
||||||
|
const { mediaSource, sessionId, url } = res;
|
||||||
|
if (!sessionId || !mediaSource || !url) {
|
||||||
|
Alert.alert(
|
||||||
|
t("player.error"),
|
||||||
|
t("player.failed_to_get_stream_url"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result = { mediaSource, sessionId, url };
|
||||||
|
}
|
||||||
|
setStream(result);
|
||||||
|
setStreamStatus({ isLoading: false, isError: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch stream:", error);
|
||||||
|
setStreamStatus({ isLoading: false, isError: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchStreamData();
|
||||||
|
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!stream) return;
|
||||||
|
|
||||||
|
const reportPlaybackStart = async () => {
|
||||||
|
await getPlaystateApi(api!).reportPlaybackStart({
|
||||||
|
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
reportPlaybackStart();
|
||||||
|
}, [stream]);
|
||||||
|
|
||||||
|
const togglePlay = async () => {
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
|
reportPlaybackProgress();
|
||||||
} else {
|
} else {
|
||||||
videoRef.current?.play();
|
videoRef.current?.play();
|
||||||
}
|
await getPlaystateApi(api!).reportPlaybackStart({
|
||||||
|
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
||||||
if (!offline && stream) {
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: msToTicks(progress.get()),
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream.sessionId,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [
|
};
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
stream,
|
|
||||||
videoRef,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
offline,
|
|
||||||
progress,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const reportPlaybackStopped = useCallback(async () => {
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
if (offline) return;
|
if (offline) return;
|
||||||
|
|
||||||
const currentTimeInTicks = msToTicks(progress.get());
|
const currentTimeInTicks = msToTicks(progress.get());
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
await getPlaystateApi(api!).onPlaybackStopped({
|
||||||
itemId: item?.Id!,
|
itemId: item?.Id!,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
@@ -246,7 +244,15 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidateProgressCache();
|
revalidateProgressCache();
|
||||||
}, [api, item, mediaSourceId, stream]);
|
}, [
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
mediaSourceId,
|
||||||
|
stream,
|
||||||
|
progress,
|
||||||
|
offline,
|
||||||
|
revalidateProgressCache,
|
||||||
|
]);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
@@ -254,12 +260,36 @@ export default function page() {
|
|||||||
videoRef.current?.stop();
|
videoRef.current?.stop();
|
||||||
}, [videoRef, reportPlaybackStopped]);
|
}, [videoRef, reportPlaybackStopped]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||||
|
return () => {
|
||||||
|
beforeRemoveListener();
|
||||||
|
};
|
||||||
|
}, [navigation, stop]);
|
||||||
|
|
||||||
|
const currentPlayStateInfo = () => {
|
||||||
|
if (!stream) return;
|
||||||
|
return {
|
||||||
|
itemId: item?.Id!,
|
||||||
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
positionTicks: msToTicks(progress.get()),
|
||||||
|
isPaused: !isPlaying,
|
||||||
|
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: stream.sessionId,
|
||||||
|
isMuted: isMuted,
|
||||||
|
canSeek: true,
|
||||||
|
repeatMode: RepeatMode.RepeatNone,
|
||||||
|
playbackOrder: PlaybackOrder.Default,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const onProgress = useCallback(
|
const onProgress = useCallback(
|
||||||
async (data: ProgressUpdatePayload) => {
|
async (data: ProgressUpdatePayload) => {
|
||||||
if (isSeeking.get() || isPlaybackStopped) return;
|
if (isSeeking.get() || isPlaybackStopped) return;
|
||||||
|
|
||||||
const { currentTime } = data.nativeEvent;
|
const { currentTime } = data.nativeEvent;
|
||||||
|
|
||||||
if (isBuffering) {
|
if (isBuffering) {
|
||||||
setIsBuffering(false);
|
setIsBuffering(false);
|
||||||
}
|
}
|
||||||
@@ -268,108 +298,184 @@ export default function page() {
|
|||||||
|
|
||||||
if (offline) return;
|
if (offline) return;
|
||||||
|
|
||||||
const currentTimeInTicks = msToTicks(currentTime);
|
|
||||||
|
|
||||||
if (!item?.Id || !stream) return;
|
if (!item?.Id || !stream) return;
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
reportPlaybackProgress();
|
||||||
itemId: item.Id,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(currentTimeInTicks),
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream.sessionId,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[item?.Id, isSeeking, api, isPlaybackStopped, audioIndex, subtitleIndex]
|
[
|
||||||
|
item?.Id,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
|
mediaSourceId,
|
||||||
|
isPlaying,
|
||||||
|
stream,
|
||||||
|
isSeeking,
|
||||||
|
isPlaybackStopped,
|
||||||
|
isBuffering,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useWebSocket({
|
|
||||||
isPlaying: isPlaying,
|
|
||||||
togglePlay: togglePlay,
|
|
||||||
stopPlayback: stop,
|
|
||||||
offline,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onPipStarted = useCallback((e: PipStartedPayload) => {
|
const onPipStarted = useCallback((e: PipStartedPayload) => {
|
||||||
const { pipStarted } = e.nativeEvent;
|
const { pipStarted } = e.nativeEvent;
|
||||||
setIsPipStarted(pipStarted);
|
setIsPipStarted(pipStarted);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
const reportPlaybackProgress = useCallback(async () => {
|
||||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
if (!api || offline || !stream) return;
|
||||||
|
await getPlaystateApi(api).reportPlaybackProgress({
|
||||||
if (state === "Playing") {
|
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
|
||||||
setIsPlaying(true);
|
});
|
||||||
return;
|
}, [
|
||||||
}
|
api,
|
||||||
|
isPlaying,
|
||||||
if (state === "Paused") {
|
offline,
|
||||||
setIsPlaying(false);
|
stream,
|
||||||
return;
|
item?.Id,
|
||||||
}
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
if (isPlaying) {
|
mediaSourceId,
|
||||||
setIsPlaying(true);
|
progress,
|
||||||
setIsBuffering(false);
|
]);
|
||||||
} else if (isBuffering) {
|
|
||||||
setIsBuffering(true);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startPosition = useMemo(() => {
|
const startPosition = useMemo(() => {
|
||||||
if (offline) return 0;
|
if (offline) return 0;
|
||||||
|
|
||||||
return item?.UserData?.PlaybackPositionTicks
|
return item?.UserData?.PlaybackPositionTicks
|
||||||
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
||||||
: 0;
|
: 0;
|
||||||
}, [item]);
|
}, [item, offline]);
|
||||||
|
|
||||||
const [appState, setAppState] = useState(AppState.currentState);
|
const volumeUpCb = useCallback(async () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
useEffect(() => {
|
try {
|
||||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
const { volume: currentVolume } = await VolumeManager.getVolume();
|
||||||
// Handle app going to the background
|
const newVolume = Math.min(currentVolume + 0.1, 1.0);
|
||||||
if (nextAppState.match(/inactive|background/)) {
|
|
||||||
_setShowControls(false);
|
await VolumeManager.setVolume(newVolume);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error adjusting volume:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const [previousVolume, setPreviousVolume] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const toggleMuteCb = useCallback(async () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { volume: currentVolume } = await VolumeManager.getVolume();
|
||||||
|
const currentVolumePercent = currentVolume * 100;
|
||||||
|
|
||||||
|
if (currentVolumePercent > 0) {
|
||||||
|
// Currently not muted, so mute
|
||||||
|
setPreviousVolume(currentVolumePercent);
|
||||||
|
await VolumeManager.setVolume(0);
|
||||||
|
setIsMuted(true);
|
||||||
|
} else {
|
||||||
|
// Currently muted, so restore previous volume
|
||||||
|
const volumeToRestore = previousVolume || 50; // Default to 50% if no previous volume
|
||||||
|
await VolumeManager.setVolume(volumeToRestore / 100);
|
||||||
|
setPreviousVolume(null);
|
||||||
|
setIsMuted(false);
|
||||||
}
|
}
|
||||||
setAppState(nextAppState);
|
} catch (error) {
|
||||||
};
|
console.error("Error toggling mute:", error);
|
||||||
|
}
|
||||||
|
}, [previousVolume]);
|
||||||
|
const volumeDownCb = useCallback(async () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
// Use AppState.addEventListener and return a cleanup function
|
try {
|
||||||
const subscription = AppState.addEventListener(
|
const { volume: currentVolume } = await VolumeManager.getVolume();
|
||||||
"change",
|
const newVolume = Math.max(currentVolume - 0.1, 0); // Decrease by 10%
|
||||||
handleAppStateChange
|
console.log(
|
||||||
);
|
"Volume Down",
|
||||||
|
Math.round(currentVolume * 100),
|
||||||
|
"→",
|
||||||
|
Math.round(newVolume * 100),
|
||||||
|
);
|
||||||
|
await VolumeManager.setVolume(newVolume);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error adjusting volume:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return () => {
|
const setVolumeCb = useCallback(async (newVolume: number) => {
|
||||||
// Cleanup the event listener when the component is unmounted
|
if (Platform.isTV) return;
|
||||||
subscription.remove();
|
|
||||||
};
|
|
||||||
}, [appState, isPipStarted, isPlaying]);
|
|
||||||
|
|
||||||
// Preselection of audio and subtitle tracks.
|
try {
|
||||||
if (!settings) return null;
|
const clampedVolume = Math.max(0, Math.min(newVolume, 100));
|
||||||
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
console.log("Setting volume to", clampedVolume);
|
||||||
|
await VolumeManager.setVolume(clampedVolume / 100);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error setting volume:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useWebSocket({
|
||||||
|
isPlaying: isPlaying,
|
||||||
|
togglePlay: togglePlay,
|
||||||
|
stopPlayback: stop,
|
||||||
|
offline,
|
||||||
|
toggleMute: toggleMuteCb,
|
||||||
|
volumeUp: volumeUpCb,
|
||||||
|
volumeDown: volumeDownCb,
|
||||||
|
setVolume: setVolumeCb,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onPlaybackStateChanged = useCallback(
|
||||||
|
async (e: PlaybackStatePayload) => {
|
||||||
|
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||||
|
if (state === "Playing") {
|
||||||
|
setIsPlaying(true);
|
||||||
|
reportPlaybackProgress();
|
||||||
|
if (!Platform.isTV) await activateKeepAwakeAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "Paused") {
|
||||||
|
setIsPlaying(false);
|
||||||
|
reportPlaybackProgress();
|
||||||
|
if (!Platform.isTV) await deactivateKeepAwake();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
setIsPlaying(true);
|
||||||
|
setIsBuffering(false);
|
||||||
|
} else if (isBuffering) {
|
||||||
|
setIsBuffering(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[reportPlaybackProgress],
|
||||||
|
);
|
||||||
|
|
||||||
const allAudio =
|
const allAudio =
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
stream?.mediaSource.MediaStreams?.filter(
|
||||||
(audio) => audio.Type === "Audio"
|
(audio) => audio.Type === "Audio",
|
||||||
) || [];
|
) || [];
|
||||||
|
|
||||||
|
// Move all the external subtitles last, because vlc places them last.
|
||||||
const allSubs =
|
const allSubs =
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
stream?.mediaSource.MediaStreams?.filter(
|
||||||
(sub) => sub.Type === "Subtitle"
|
(sub) => sub.Type === "Subtitle",
|
||||||
) || [];
|
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
|
||||||
|
|
||||||
|
const externalSubtitles = allSubs
|
||||||
|
.filter((sub: any) => sub.DeliveryMethod === "External")
|
||||||
|
.map((sub: any) => ({
|
||||||
|
name: sub.DisplayTitle,
|
||||||
|
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
|
||||||
|
}));
|
||||||
|
|
||||||
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
||||||
|
|
||||||
const chosenSubtitleTrack = allSubs.find(
|
const chosenSubtitleTrack = allSubs.find(
|
||||||
(sub) => sub.Index === subtitleIndex
|
(sub) => sub.Index === subtitleIndex,
|
||||||
);
|
);
|
||||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||||
|
|
||||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||||
|
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||||
if (
|
if (
|
||||||
chosenSubtitleTrack &&
|
chosenSubtitleTrack &&
|
||||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||||
@@ -384,35 +490,29 @@ export default function page() {
|
|||||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
useEffect(() => {
|
|
||||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
|
||||||
return () => {
|
|
||||||
beforeRemoveListener();
|
|
||||||
};
|
|
||||||
}, [navigation]);
|
|
||||||
|
|
||||||
if (!item || isLoadingItem || !stream)
|
// Add useEffect to handle mounting
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
return () => setIsMounted(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (itemStatus.isLoading || streamStatus.isLoading) {
|
||||||
return (
|
return (
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isErrorItem || isErrorStream)
|
if (itemStatus.isError || streamStatus.isError)
|
||||||
return (
|
return (
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
||||||
<Text className="text-white">{t("player.error")}</Text>
|
<Text className='text-white'>{t("player.error")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
const externalSubtitles = allSubs
|
|
||||||
.filter((sub: any) => sub.DeliveryMethod === "External")
|
|
||||||
.map((sub: any) => ({
|
|
||||||
name: sub.DisplayTitle,
|
|
||||||
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
<View style={{ flex: 1, backgroundColor: "black" }}>
|
||||||
<View
|
<View
|
||||||
@@ -442,7 +542,6 @@ export default function page() {
|
|||||||
progressUpdateInterval={1000}
|
progressUpdateInterval={1000}
|
||||||
onVideoStateChange={onPlaybackStateChanged}
|
onVideoStateChange={onPlaybackStateChanged}
|
||||||
onPipStarted={onPipStarted}
|
onPipStarted={onPipStarted}
|
||||||
onVideoLoadStart={() => {}}
|
|
||||||
onVideoLoadEnd={() => {
|
onVideoLoadEnd={() => {
|
||||||
setIsVideoLoaded(true);
|
setIsVideoLoaded(true);
|
||||||
}}
|
}}
|
||||||
@@ -450,13 +549,13 @@ export default function page() {
|
|||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.error"),
|
t("player.error"),
|
||||||
t("player.an_error_occured_while_playing_the_video")
|
t("player.an_error_occured_while_playing_the_video"),
|
||||||
);
|
);
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{videoRef.current && (
|
{videoRef.current && !isPipStarted && isMounted === true ? (
|
||||||
<Controls
|
<Controls
|
||||||
mediaSource={stream?.mediaSource}
|
mediaSource={stream?.mediaSource}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -483,10 +582,9 @@ export default function page() {
|
|||||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
||||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
setSubtitleURL={videoRef.current.setSubtitleURL}
|
||||||
setAudioTrack={videoRef.current.setAudioTrack}
|
setAudioTrack={videoRef.current.setAudioTrack}
|
||||||
stop={stop}
|
|
||||||
isVlc
|
isVlc
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ScrollViewStyleReset } from "expo-router/html";
|
import { ScrollViewStyleReset } from "expo-router/html";
|
||||||
import { type PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This file is web-only and used to configure the root HTML for every web page during static rendering.
|
* This file is web-only and used to configure the root HTML for every web page during static rendering.
|
||||||
@@ -7,13 +7,13 @@ import { type PropsWithChildren } from "react";
|
|||||||
*/
|
*/
|
||||||
export default function Root({ children }: PropsWithChildren) {
|
export default function Root({ children }: PropsWithChildren) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang='en'>
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet='utf-8' />
|
||||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
<meta httpEquiv='X-UA-Compatible' content='IE=edge' />
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name='viewport'
|
||||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
content='width=device-width, initial-scale=1, shrink-to-fit=no'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ export default function NotFoundScreen() {
|
|||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Oops!" }} />
|
<Stack.Screen options={{ title: "Oops!" }} />
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
<ThemedText type="title">This screen doesn't exist.</ThemedText>
|
<ThemedText type='title'>This screen doesn't exist.</ThemedText>
|
||||||
<Link href={"/home"} style={styles.link}>
|
<Link href={"/home"} style={styles.link}>
|
||||||
<ThemedText type="link">Go to home screen!</ThemedText>
|
<ThemedText type='link'>Go to home screen!</ThemedText>
|
||||||
</Link>
|
</Link>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
</>
|
</>
|
||||||
|
|||||||
260
app/_layout.tsx
@@ -1,28 +1,33 @@
|
|||||||
import "@/augmentations";
|
import "@/augmentations";
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
import {
|
import {
|
||||||
|
JellyfinProvider,
|
||||||
|
apiAtom,
|
||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
getTokenFromStorage,
|
getTokenFromStorage,
|
||||||
JellyfinProvider,
|
|
||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import {
|
|
||||||
SplashScreenProvider,
|
|
||||||
useSplashScreenLoading,
|
|
||||||
} from "@/providers/SplashScreenProvider";
|
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
||||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
import {
|
||||||
import { LogProvider, writeToLog } from "@/utils/log";
|
BACKGROUND_FETCH_TASK,
|
||||||
|
BACKGROUND_FETCH_TASK_SESSIONS,
|
||||||
|
registerBackgroundFetchAsyncSessions,
|
||||||
|
} from "@/utils/background-tasks";
|
||||||
|
import {
|
||||||
|
LogProvider,
|
||||||
|
writeDebugLog,
|
||||||
|
writeErrorLog,
|
||||||
|
writeToLog,
|
||||||
|
} from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { Platform } from "react-native";
|
||||||
const BackGroundDownloader = !Platform.isTV
|
const BackGroundDownloader = !Platform.isTV
|
||||||
? require("@kesha-antonov/react-native-background-downloader")
|
? require("@kesha-antonov/react-native-background-downloader")
|
||||||
: null;
|
: null;
|
||||||
@@ -31,21 +36,31 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
const BackgroundFetch = !Platform.isTV
|
const BackgroundFetch = !Platform.isTV
|
||||||
? require("expo-background-fetch")
|
? require("expo-background-fetch")
|
||||||
: null;
|
: null;
|
||||||
|
import * as Device from "expo-device";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useFonts } from "expo-font";
|
|
||||||
import { useKeepAwake } from "expo-keep-awake";
|
|
||||||
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||||
import { router, Stack } from "expo-router";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { Stack, router, useSegments } from "expo-router";
|
||||||
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
||||||
import { getLocales } from "expo-localization";
|
import { getLocales } from "expo-localization";
|
||||||
import { Provider as JotaiProvider } from "jotai";
|
import { Provider as JotaiProvider } from "jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { I18nextProvider, useTranslation } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
import { Appearance, AppState } from "react-native";
|
import { AppState, Appearance } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { store } from "@/utils/store";
|
||||||
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
|
import type { EventSubscription } from "expo-modules-core";
|
||||||
|
import type {
|
||||||
|
Notification,
|
||||||
|
NotificationResponse,
|
||||||
|
} from "expo-notifications/build/Notifications.types";
|
||||||
|
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
import { Toaster } from "sonner-native";
|
import { Toaster } from "sonner-native";
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
@@ -58,6 +73,15 @@ if (!Platform.isTV) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the splash screen visible while we fetch resources
|
||||||
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
|
// Set the animation options. This is optional.
|
||||||
|
SplashScreen.setOptions({
|
||||||
|
duration: 500,
|
||||||
|
fade: true,
|
||||||
|
});
|
||||||
|
|
||||||
function useNotificationObserver() {
|
function useNotificationObserver() {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
@@ -77,13 +101,13 @@ function useNotificationObserver() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redirect(response?.notification);
|
redirect(response?.notification);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const subscription = Notifications.addNotificationResponseReceivedListener(
|
const subscription = Notifications.addNotificationResponseReceivedListener(
|
||||||
(response: { notification: any }) => {
|
(response: { notification: any }) => {
|
||||||
redirect(response.notification);
|
redirect(response.notification);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -94,6 +118,22 @@ function useNotificationObserver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
|
TaskManager.defineTask(BACKGROUND_FETCH_TASK_SESSIONS, async () => {
|
||||||
|
console.log("TaskManager ~ sessions trigger");
|
||||||
|
|
||||||
|
const api = store.get(apiAtom);
|
||||||
|
if (api === null || api === undefined) return;
|
||||||
|
|
||||||
|
const response = await getSessionApi(api).getSessions({
|
||||||
|
activeWithinSeconds: 360,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = response.data.filter((s) => s.NowPlayingItem);
|
||||||
|
Notifications.setBadgeCountAsync(result.length);
|
||||||
|
|
||||||
|
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||||
|
});
|
||||||
|
|
||||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||||
console.log("TaskManager ~ trigger");
|
console.log("TaskManager ~ trigger");
|
||||||
|
|
||||||
@@ -124,9 +164,9 @@ if (!Platform.isTV) {
|
|||||||
|
|
||||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
||||||
|
|
||||||
for (let job of jobs) {
|
for (const job of jobs) {
|
||||||
if (job.status === "completed") {
|
if (job.status === "completed") {
|
||||||
const downloadUrl = url + "download/" + job.id;
|
const downloadUrl = `${url}download/${job.id}`;
|
||||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
|
||||||
if (tasks.find((task: { id: string }) => task.id === job.id)) {
|
if (tasks.find((task: { id: string }) => task.id === job.id)) {
|
||||||
@@ -159,7 +199,7 @@ if (!Platform.isTV) {
|
|||||||
title: job.item.Name,
|
title: job.item.Name,
|
||||||
body: "Download completed",
|
body: "Download completed",
|
||||||
data: {
|
data: {
|
||||||
url: `/downloads`,
|
url: "/downloads",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
trigger: null,
|
trigger: null,
|
||||||
@@ -173,7 +213,7 @@ if (!Platform.isTV) {
|
|||||||
title: job.item.Name,
|
title: job.item.Name,
|
||||||
body: "Download failed",
|
body: "Download failed",
|
||||||
data: {
|
data: {
|
||||||
url: `/downloads`,
|
url: "/downloads",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
trigger: null,
|
trigger: null,
|
||||||
@@ -192,7 +232,7 @@ if (!Platform.isTV) {
|
|||||||
const checkAndRequestPermissions = async () => {
|
const checkAndRequestPermissions = async () => {
|
||||||
try {
|
try {
|
||||||
const hasAskedBefore = storage.getString(
|
const hasAskedBefore = storage.getString(
|
||||||
"hasAskedForNotificationPermission"
|
"hasAskedForNotificationPermission",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasAskedBefore !== "true") {
|
if (hasAskedBefore !== "true") {
|
||||||
@@ -214,7 +254,7 @@ const checkAndRequestPermissions = async () => {
|
|||||||
writeToLog(
|
writeToLog(
|
||||||
"ERROR",
|
"ERROR",
|
||||||
"Error checking/requesting notification permissions:",
|
"Error checking/requesting notification permissions:",
|
||||||
error
|
error,
|
||||||
);
|
);
|
||||||
console.error("Error checking/requesting notification permissions:", error);
|
console.error("Error checking/requesting notification permissions:", error);
|
||||||
}
|
}
|
||||||
@@ -224,17 +264,15 @@ export default function RootLayout() {
|
|||||||
Appearance.setColorScheme("dark");
|
Appearance.setColorScheme("dark");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SplashScreenProvider>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<JotaiProvider>
|
||||||
<JotaiProvider>
|
<ActionSheetProvider>
|
||||||
<ActionSheetProvider>
|
<I18nextProvider i18n={i18n}>
|
||||||
<I18nextProvider i18n={i18n}>
|
<Layout />
|
||||||
<Layout />
|
</I18nextProvider>
|
||||||
</I18nextProvider>
|
</ActionSheetProvider>
|
||||||
</ActionSheetProvider>
|
</JotaiProvider>
|
||||||
</JotaiProvider>
|
</GestureHandlerRootView>
|
||||||
</GestureHandlerRootView>
|
|
||||||
</SplashScreenProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,35 +290,145 @@ const queryClient = new QueryClient({
|
|||||||
|
|
||||||
function Layout() {
|
function Layout() {
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
const appState = useRef(AppState.currentState);
|
const appState = useRef(AppState.currentState);
|
||||||
|
const segments = useSegments();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
i18n.changeLanguage(
|
i18n.changeLanguage(
|
||||||
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en"
|
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en",
|
||||||
);
|
);
|
||||||
}, [settings?.preferedLanguage, i18n]);
|
}, [settings?.preferedLanguage, i18n]);
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
useKeepAwake();
|
|
||||||
useNotificationObserver();
|
useNotificationObserver();
|
||||||
|
|
||||||
const { i18n } = useTranslation();
|
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>();
|
||||||
|
const notificationListener = useRef<EventSubscription>();
|
||||||
|
const responseListener = useRef<EventSubscription>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAndRequestPermissions();
|
if (expoPushToken && api && user) {
|
||||||
|
api
|
||||||
|
?.post("/Streamyfin/device", {
|
||||||
|
token: expoPushToken.data,
|
||||||
|
deviceId: getOrSetDeviceId(),
|
||||||
|
userId: user.Id,
|
||||||
|
})
|
||||||
|
.then((_) => console.log("Posted expo push token"))
|
||||||
|
.catch((_) =>
|
||||||
|
writeErrorLog("Failed to push expo push token to plugin"),
|
||||||
|
);
|
||||||
|
} else console.log("No token available");
|
||||||
|
}, [api, expoPushToken, user]);
|
||||||
|
|
||||||
|
async function registerNotifications() {
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
console.log("Setting android notification channel 'default'");
|
||||||
|
await Notifications?.setNotificationChannelAsync("default", {
|
||||||
|
name: "default",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkAndRequestPermissions();
|
||||||
|
|
||||||
|
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
|
||||||
|
await registerBackgroundFetchAsyncSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// only create push token for real devices (pointless for emulators)
|
||||||
|
if (Device.isDevice) {
|
||||||
|
Notifications?.getExpoPushTokenAsync()
|
||||||
|
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
|
||||||
|
.catch((reason: any) => console.log("Failed to get token", reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerNotifications();
|
||||||
|
|
||||||
|
notificationListener.current =
|
||||||
|
Notifications?.addNotificationReceivedListener(
|
||||||
|
(notification: Notification) => {
|
||||||
|
console.log(
|
||||||
|
"Notification received while app running",
|
||||||
|
notification,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
responseListener.current =
|
||||||
|
Notifications?.addNotificationResponseReceivedListener(
|
||||||
|
(response: NotificationResponse) => {
|
||||||
|
// Currently the notifications supported by the plugin will send data for deep links.
|
||||||
|
const { title, data } = response.notification.request.content;
|
||||||
|
|
||||||
|
writeDebugLog(
|
||||||
|
`Notification ${title} opened`,
|
||||||
|
response.notification.request.content,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
const type = data?.type?.toLower?.();
|
||||||
|
const itemId = data?.id;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "movie":
|
||||||
|
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
|
||||||
|
break;
|
||||||
|
case "episode":
|
||||||
|
// We just clicked a notification for an individual episode.
|
||||||
|
if (itemId) {
|
||||||
|
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
|
||||||
|
}
|
||||||
|
// summarized season notification for multiple episodes. Bring them to series season
|
||||||
|
else {
|
||||||
|
const seriesId = data.seriesId;
|
||||||
|
const seasonIndex = data.seasonIndex;
|
||||||
|
|
||||||
|
if (seasonIndex) {
|
||||||
|
router.push(
|
||||||
|
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
router.push(`/(auth)/(tabs)/home/series/${seriesId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
notificationListener.current &&
|
||||||
|
Notifications?.removeNotificationSubscription(
|
||||||
|
notificationListener.current,
|
||||||
|
);
|
||||||
|
responseListener.current &&
|
||||||
|
Notifications?.removeNotificationSubscription(
|
||||||
|
responseListener.current,
|
||||||
|
);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
if (segments.includes("direct-player" as never)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If the user has auto rotate enabled, unlock the orientation
|
// If the user has auto rotate enabled, unlock the orientation
|
||||||
if (settings.autoRotate === true) {
|
if (settings.followDeviceOrientation === true) {
|
||||||
ScreenOrientation.unlockAsync();
|
ScreenOrientation.unlockAsync();
|
||||||
} else {
|
} else {
|
||||||
// If the user has auto rotate disabled, lock the orientation to portrait
|
// If the user has auto rotate disabled, lock the orientation to portrait
|
||||||
ScreenOrientation.lockAsync(
|
ScreenOrientation.lockAsync(
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [settings]);
|
}, [settings.followDeviceOrientation, segments]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = AppState.addEventListener(
|
const subscription = AppState.addEventListener(
|
||||||
@@ -292,7 +440,7 @@ function Layout() {
|
|||||||
) {
|
) {
|
||||||
BackGroundDownloader.checkForExistingDownloads();
|
BackGroundDownloader.checkForExistingDownloads();
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
BackGroundDownloader.checkForExistingDownloads();
|
BackGroundDownloader.checkForExistingDownloads();
|
||||||
@@ -303,16 +451,6 @@ function Layout() {
|
|||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [loaded] = useFonts({
|
|
||||||
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
|
||||||
});
|
|
||||||
|
|
||||||
useSplashScreenLoading(!loaded);
|
|
||||||
|
|
||||||
if (!loaded) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<JobQueueProvider>
|
<JobQueueProvider>
|
||||||
@@ -322,11 +460,11 @@ function Layout() {
|
|||||||
<WebSocketProvider>
|
<WebSocketProvider>
|
||||||
<DownloadProvider>
|
<DownloadProvider>
|
||||||
<BottomSheetModalProvider>
|
<BottomSheetModalProvider>
|
||||||
<SystemBars style="light" hidden={false} />
|
<SystemBars style='light' hidden={false} />
|
||||||
<ThemeProvider value={DarkTheme}>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack>
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="(auth)/(tabs)"
|
name='(auth)/(tabs)'
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
title: "",
|
title: "",
|
||||||
@@ -334,7 +472,7 @@ function Layout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="(auth)/player"
|
name='(auth)/player'
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
title: "",
|
title: "",
|
||||||
@@ -342,14 +480,14 @@ function Layout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="login"
|
name='login'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
title: "",
|
title: "",
|
||||||
headerTransparent: true,
|
headerTransparent: true,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name='+not-found' />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Toaster
|
<Toaster
|
||||||
duration={4000}
|
duration={4000}
|
||||||
@@ -380,7 +518,7 @@ function Layout() {
|
|||||||
function saveDownloadedItemInfo(item: BaseItemDto) {
|
function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||||
try {
|
try {
|
||||||
const downloadedItems = storage.getString("downloadedItems");
|
const downloadedItems = storage.getString("downloadedItems");
|
||||||
let items: BaseItemDto[] = downloadedItems
|
const items: BaseItemDto[] = downloadedItems
|
||||||
? JSON.parse(downloadedItems)
|
? JSON.parse(downloadedItems)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
|||||||
166
app/login.tsx
@@ -1,16 +1,17 @@
|
|||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Input } from "@/components/common/Input";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
|
import { Input } from "@/components/common/Input";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
@@ -19,17 +20,20 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { Keyboard } from "react-native";
|
||||||
|
|
||||||
|
import { t } from "i18next";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { t } from 'i18next';
|
|
||||||
const CredentialsSchema = z.object({
|
const CredentialsSchema = z.object({
|
||||||
username: z.string().min(1, t("login.username_required")),});
|
username: z.string().min(1, t("login.username_required")),
|
||||||
|
});
|
||||||
|
|
||||||
const Login: React.FC = () => {
|
const Login: React.FC = () => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const params = useLocalSearchParams();
|
||||||
const { setServer, login, removeServer, initiateQuickConnect } =
|
const { setServer, login, removeServer, initiateQuickConnect } =
|
||||||
useJellyfin();
|
useJellyfin();
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const params = useLocalSearchParams();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
apiUrl: _apiUrl,
|
apiUrl: _apiUrl,
|
||||||
@@ -37,6 +41,8 @@ const CredentialsSchema = z.object({
|
|||||||
password: _password,
|
password: _password,
|
||||||
} = params as { apiUrl: string; username: string; password: string };
|
} = params as { apiUrl: string; username: string; password: string };
|
||||||
|
|
||||||
|
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
||||||
const [serverName, setServerName] = useState<string>("");
|
const [serverName, setServerName] = useState<string>("");
|
||||||
const [credentials, setCredentials] = useState<{
|
const [credentials, setCredentials] = useState<{
|
||||||
@@ -47,12 +53,13 @@ const CredentialsSchema = z.object({
|
|||||||
password: _password,
|
password: _password,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A way to auto login based on a link
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
// we might re-use the checkUrl function here to check the url as well
|
|
||||||
// however, I don't think it should be necessary for now
|
|
||||||
if (_apiUrl) {
|
if (_apiUrl) {
|
||||||
setServer({
|
await setServer({
|
||||||
address: _apiUrl,
|
address: _apiUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,7 +73,6 @@ const CredentialsSchema = z.object({
|
|||||||
})();
|
})();
|
||||||
}, [_apiUrl, _username, _password]);
|
}, [_apiUrl, _username, _password]);
|
||||||
|
|
||||||
const navigation = useNavigation();
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerTitle: serverName,
|
headerTitle: serverName,
|
||||||
@@ -76,18 +82,20 @@ const CredentialsSchema = z.object({
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
removeServer();
|
removeServer();
|
||||||
}}
|
}}
|
||||||
className="flex flex-row items-center"
|
className='flex flex-row items-center'
|
||||||
>
|
>
|
||||||
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
|
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
||||||
<Text className="ml-2 text-purple-600">{t("login.change_server")}</Text>
|
<Text className='ml-2 text-purple-600'>
|
||||||
|
{t("login.change_server")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : null,
|
) : null,
|
||||||
});
|
});
|
||||||
}, [serverName, navigation, api?.basePath]);
|
}, [serverName, navigation, api?.basePath]);
|
||||||
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = CredentialsSchema.safeParse(credentials);
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
@@ -98,15 +106,16 @@ const CredentialsSchema = z.object({
|
|||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
Alert.alert(t("login.connection_failed"), error.message);
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
} else {
|
} else {
|
||||||
Alert.alert(t("login.connection_failed"), t("login.an_unexpected_error_occured"));
|
Alert.alert(
|
||||||
|
t("login.connection_failed"),
|
||||||
|
t("login.an_unexpected_error_occured"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks the availability and validity of a Jellyfin server URL.
|
* Checks the availability and validity of a Jellyfin server URL.
|
||||||
*
|
*
|
||||||
@@ -168,26 +177,33 @@ const CredentialsSchema = z.object({
|
|||||||
if (result === undefined) {
|
if (result === undefined) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.could_not_connect_to_server")
|
t("login.could_not_connect_to_server"),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setServer({ address: url });
|
await setServer({ address: url });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleQuickConnect = async () => {
|
const handleQuickConnect = async () => {
|
||||||
try {
|
try {
|
||||||
const code = await initiateQuickConnect();
|
const code = await initiateQuickConnect();
|
||||||
if (code) {
|
if (code) {
|
||||||
Alert.alert(t("login.quick_connect"), t("login.enter_code_to_login", {code: code}), [
|
Alert.alert(
|
||||||
{
|
t("login.quick_connect"),
|
||||||
text: t("login.got_it"),
|
t("login.enter_code_to_login", { code: code }),
|
||||||
},
|
[
|
||||||
]);
|
{
|
||||||
|
text: t("login.got_it"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Alert.alert(t("login.error_title"), t("login.failed_to_initiate_quick_connect"));
|
Alert.alert(
|
||||||
|
t("login.error_title"),
|
||||||
|
t("login.failed_to_initiate_quick_connect"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -198,20 +214,20 @@ const CredentialsSchema = z.object({
|
|||||||
>
|
>
|
||||||
{api?.basePath ? (
|
{api?.basePath ? (
|
||||||
<>
|
<>
|
||||||
<View className="flex flex-col h-full relative items-center justify-center">
|
<View className='flex flex-col h-full relative items-center justify-center'>
|
||||||
<View className="px-4 -mt-20 w-full">
|
<View className='px-4 -mt-20 w-full'>
|
||||||
<View className="flex flex-col space-y-2">
|
<View className='flex flex-col space-y-2'>
|
||||||
<Text className="text-2xl font-bold -mb-2">
|
<Text className='text-2xl font-bold -mb-2'>
|
||||||
<>
|
|
||||||
{serverName ? (
|
{serverName ? (
|
||||||
<>
|
<>
|
||||||
{t("login.login_to_title") + " "}
|
{`${t("login.login_to_title")} `}
|
||||||
<Text className="text-purple-600">{serverName}</Text>
|
<Text className='text-purple-600'>{serverName}</Text>
|
||||||
</>
|
</>
|
||||||
) : t("login.login_title")}
|
) : (
|
||||||
</>
|
t("login.login_title")
|
||||||
</Text>
|
)}
|
||||||
<Text className="text-xs text-neutral-400">
|
</Text>
|
||||||
|
<Text className='text-xs text-neutral-400'>
|
||||||
{api.basePath}
|
{api.basePath}
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
@@ -220,13 +236,13 @@ const CredentialsSchema = z.object({
|
|||||||
setCredentials({ ...credentials, username: text })
|
setCredentials({ ...credentials, username: text })
|
||||||
}
|
}
|
||||||
value={credentials.username}
|
value={credentials.username}
|
||||||
autoFocus
|
keyboardType='default'
|
||||||
secureTextEntry={false}
|
returnKeyType='done'
|
||||||
keyboardType="default"
|
autoCapitalize='none'
|
||||||
returnKeyType="done"
|
// Changed from username to oneTimeCode because it is a known issue in RN
|
||||||
autoCapitalize="none"
|
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
|
||||||
textContentType="username"
|
textContentType='oneTimeCode'
|
||||||
clearButtonMode="while-editing"
|
clearButtonMode='while-editing'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -237,42 +253,42 @@ const CredentialsSchema = z.object({
|
|||||||
}
|
}
|
||||||
value={credentials.password}
|
value={credentials.password}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
keyboardType="default"
|
keyboardType='default'
|
||||||
returnKeyType="done"
|
returnKeyType='done'
|
||||||
autoCapitalize="none"
|
autoCapitalize='none'
|
||||||
textContentType="password"
|
textContentType='password'
|
||||||
clearButtonMode="while-editing"
|
clearButtonMode='while-editing'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
<View className="flex flex-row items-center justify-between">
|
<View className='flex flex-row items-center justify-between'>
|
||||||
<Button
|
<Button
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
className="flex-1 mr-2"
|
className='flex-1 mr-2'
|
||||||
>
|
>
|
||||||
{t("login.login_button")}
|
{t("login.login_button")}
|
||||||
</Button>
|
</Button>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handleQuickConnect}
|
onPress={handleQuickConnect}
|
||||||
className="p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center"
|
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="cellphone-lock"
|
name='cellphone-lock'
|
||||||
size={24}
|
size={24}
|
||||||
color="white"
|
color='white'
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="absolute bottom-0 left-0 w-full px-4 mb-2"></View>
|
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<View className="flex flex-col h-full items-center justify-center w-full">
|
<View className='flex flex-col h-full items-center justify-center w-full'>
|
||||||
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
|
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
||||||
<Image
|
<Image
|
||||||
style={{
|
style={{
|
||||||
width: 100,
|
width: 100,
|
||||||
@@ -282,41 +298,43 @@ const CredentialsSchema = z.object({
|
|||||||
}}
|
}}
|
||||||
source={require("@/assets/images/StreamyFinFinal.png")}
|
source={require("@/assets/images/StreamyFinFinal.png")}
|
||||||
/>
|
/>
|
||||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
<Text className='text-3xl font-bold'>Streamyfin</Text>
|
||||||
<Text className="text-neutral-500">
|
<Text className='text-neutral-500'>
|
||||||
{t("server.enter_url_to_jellyfin_server")}
|
{t("server.enter_url_to_jellyfin_server")}
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
aria-label="Server URL"
|
aria-label='Server URL'
|
||||||
placeholder={t("server.server_url_placeholder")}
|
placeholder={t("server.server_url_placeholder")}
|
||||||
onChangeText={setServerURL}
|
onChangeText={setServerURL}
|
||||||
value={serverURL}
|
value={serverURL}
|
||||||
keyboardType="url"
|
keyboardType='url'
|
||||||
returnKeyType="done"
|
returnKeyType='done'
|
||||||
autoCapitalize="none"
|
autoCapitalize='none'
|
||||||
textContentType="URL"
|
textContentType='URL'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
loading={loadingServerCheck}
|
loading={loadingServerCheck}
|
||||||
disabled={loadingServerCheck}
|
disabled={loadingServerCheck}
|
||||||
onPress={async () => await handleConnect(serverURL)}
|
onPress={async () => {
|
||||||
className="w-full grow"
|
await handleConnect(serverURL);
|
||||||
|
}}
|
||||||
|
className='w-full grow'
|
||||||
>
|
>
|
||||||
{t("server.connect_button")}
|
{t("server.connect_button")}
|
||||||
</Button>
|
</Button>
|
||||||
<JellyfinServerDiscovery
|
<JellyfinServerDiscovery
|
||||||
onServerSelect={(server) => {
|
onServerSelect={async (server) => {
|
||||||
setServerURL(server.address);
|
setServerURL(server.address);
|
||||||
if (server.serverName) {
|
if (server.serverName) {
|
||||||
setServerName(server.serverName);
|
setServerName(server.serverName);
|
||||||
}
|
}
|
||||||
handleConnect(server.address);
|
await handleConnect(server.address);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<PreviousServersList
|
<PreviousServersList
|
||||||
onServerSelect={(s) => {
|
onServerSelect={async (s) => {
|
||||||
handleConnect(s.address);
|
await handleConnect(s.address);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 79 KiB |
BIN
assets/images/icon-ios-light.png
Normal file
|
After Width: | Height: | Size: 305 KiB |
BIN
assets/images/icon-ios-tinted.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
assets/images/icon-mono.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
assets/images/icon-plain.png
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
assets/images/notification.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
@@ -1,17 +1,21 @@
|
|||||||
import { Api, AUTHORIZATION_HEADER } from "@jellyfin/sdk";
|
import type { StreamyfinPluginConfig } from "@/utils/atoms/settings";
|
||||||
import { AxiosRequestConfig, AxiosResponse } from "axios";
|
import { AUTHORIZATION_HEADER, Api } from "@jellyfin/sdk";
|
||||||
import { StreamyfinPluginConfig } from "@/utils/atoms/settings";
|
import type { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||||
|
|
||||||
declare module "@jellyfin/sdk" {
|
declare module "@jellyfin/sdk" {
|
||||||
interface Api {
|
interface Api {
|
||||||
get<T, D = any>(
|
get<T, D = any>(
|
||||||
url: string,
|
url: string,
|
||||||
config?: AxiosRequestConfig<D>
|
config?: AxiosRequestConfig<D>,
|
||||||
): Promise<AxiosResponse<T>>;
|
): Promise<AxiosResponse<T>>;
|
||||||
post<T, D = any>(
|
post<T, D = any>(
|
||||||
url: string,
|
url: string,
|
||||||
data: D,
|
data: D,
|
||||||
config?: AxiosRequestConfig<D>
|
config?: AxiosRequestConfig<D>,
|
||||||
|
): Promise<AxiosResponse<T>>;
|
||||||
|
delete<T, D = any>(
|
||||||
|
url: string,
|
||||||
|
config?: AxiosRequestConfig<D>,
|
||||||
): Promise<AxiosResponse<T>>;
|
): Promise<AxiosResponse<T>>;
|
||||||
getStreamyfinPluginConfig(): Promise<AxiosResponse<StreamyfinPluginConfig>>;
|
getStreamyfinPluginConfig(): Promise<AxiosResponse<StreamyfinPluginConfig>>;
|
||||||
}
|
}
|
||||||
@@ -19,7 +23,7 @@ declare module "@jellyfin/sdk" {
|
|||||||
|
|
||||||
Api.prototype.get = function <T, D = any>(
|
Api.prototype.get = function <T, D = any>(
|
||||||
url: string,
|
url: string,
|
||||||
config: AxiosRequestConfig<D> = {}
|
config: AxiosRequestConfig<D> = {},
|
||||||
): Promise<AxiosResponse<T>> {
|
): Promise<AxiosResponse<T>> {
|
||||||
return this.axiosInstance.get<T>(`${this.basePath}${url}`, {
|
return this.axiosInstance.get<T>(`${this.basePath}${url}`, {
|
||||||
...(config ?? {}),
|
...(config ?? {}),
|
||||||
@@ -30,11 +34,20 @@ Api.prototype.get = function <T, D = any>(
|
|||||||
Api.prototype.post = function <T, D = any>(
|
Api.prototype.post = function <T, D = any>(
|
||||||
url: string,
|
url: string,
|
||||||
data: D,
|
data: D,
|
||||||
config: AxiosRequestConfig<D>
|
config: AxiosRequestConfig<D>,
|
||||||
): Promise<AxiosResponse<T>> {
|
): Promise<AxiosResponse<T>> {
|
||||||
return this.axiosInstance.post<T>(`${this.basePath}${url}`, {
|
return this.axiosInstance.post<T>(`${this.basePath}${url}`, data, {
|
||||||
|
...(config || {}),
|
||||||
|
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Api.prototype.delete = function <T, D = any>(
|
||||||
|
url: string,
|
||||||
|
config: AxiosRequestConfig<D>,
|
||||||
|
): Promise<AxiosResponse<T>> {
|
||||||
|
return this.axiosInstance.delete<T>(`${this.basePath}${url}`, {
|
||||||
...(config || {}),
|
...(config || {}),
|
||||||
data,
|
|
||||||
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
|
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
import {MMKV} from "react-native-mmkv";
|
import { MMKV } from "react-native-mmkv";
|
||||||
|
|
||||||
declare module "react-native-mmkv" {
|
declare module "react-native-mmkv" {
|
||||||
interface MMKV {
|
interface MMKV {
|
||||||
get<T>(key: string): T | undefined
|
get<T>(key: string): T | undefined;
|
||||||
setAny(key: string, value: any | undefined): void
|
setAny(key: string, value: any | undefined): void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MMKV.prototype.get = function <T> (key: string): T | undefined {
|
MMKV.prototype.get = function <T>(key: string): T | undefined {
|
||||||
const serializedItem = this.getString(key);
|
const serializedItem = this.getString(key);
|
||||||
return serializedItem ? JSON.parse(serializedItem) : undefined;
|
return serializedItem ? JSON.parse(serializedItem) : undefined;
|
||||||
}
|
};
|
||||||
|
|
||||||
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
this.delete(key)
|
this.delete(key);
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
this.set(key, JSON.stringify(value));
|
this.set(key, JSON.stringify(value));
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Number.prototype.bytesToReadable = function (decimals: number = 2) {
|
Number.prototype.bytesToReadable = function (decimals = 2) {
|
||||||
const bytes = this.valueOf();
|
const bytes = this.valueOf();
|
||||||
if (bytes === 0) return '0 Bytes';
|
if (bytes === 0) return "0 Bytes";
|
||||||
|
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const dm = decimals < 0 ? 0 : decimals;
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||||
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
Number.prototype.secondsToMilliseconds = function () {
|
Number.prototype.secondsToMilliseconds = function () {
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String.prototype.toTitle = function () {
|
String.prototype.toTitle = function () {
|
||||||
return this
|
return this.replaceAll("_", " ").replace(
|
||||||
.replaceAll("_", " ")
|
/\w\S*/g,
|
||||||
.replace(
|
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
||||||
/\w\S*/g,
|
);
|
||||||
text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()
|
};
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = function (api) {
|
module.exports = (api) => {
|
||||||
api.cache(true);
|
api.cache(true);
|
||||||
return {
|
return {
|
||||||
presets: ["babel-preset-expo"],
|
presets: ["babel-preset-expo"],
|
||||||
|
|||||||
61
biome.json
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||||
|
"organizeImports": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignore": [
|
||||||
|
"node_modules",
|
||||||
|
"ios",
|
||||||
|
"android",
|
||||||
|
"Streamyfin.app",
|
||||||
|
"utils/jellyseerr",
|
||||||
|
".expo"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"useImportType": "off",
|
||||||
|
"noNonNullAssertion": "off",
|
||||||
|
"noParameterAssign": "off",
|
||||||
|
"useLiteralEnumMembers": "off"
|
||||||
|
},
|
||||||
|
"complexity": {
|
||||||
|
"noForEach": "off"
|
||||||
|
},
|
||||||
|
"recommended": true,
|
||||||
|
"correctness": { "useExhaustiveDependencies": "off" },
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "off",
|
||||||
|
"noArrayIndexKey": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"formatWithErrors": true,
|
||||||
|
"attributePosition": "auto",
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineEnding": "lf",
|
||||||
|
"lineWidth": 80
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"arrowParentheses": "always",
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"jsxQuoteStyle": "single",
|
||||||
|
"quoteProperties": "asNeeded",
|
||||||
|
"semicolons": "always",
|
||||||
|
"lineWidth": 80
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
"formatter": {
|
||||||
|
"trailingCommas": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,113 +1,23 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import type { FC } from "react";
|
||||||
import { useAtom } from "jotai";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { useMemo } from "react";
|
|
||||||
import { TouchableOpacityProps, View, ViewProps } from "react-native";
|
|
||||||
import { RoundButton } from "./RoundButton";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
type: "item" | "series";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddToFavorites: React.FC<Props> = ({ item, type, ...props }) => {
|
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
||||||
const queryClient = useQueryClient();
|
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const isFavorite = useMemo(() => {
|
|
||||||
return item.UserData?.IsFavorite;
|
|
||||||
}, [item.UserData?.IsFavorite]);
|
|
||||||
|
|
||||||
const updateItemInQueries = (newData: Partial<BaseItemDto>) => {
|
|
||||||
queryClient.setQueryData<BaseItemDto | undefined>(
|
|
||||||
[type, item.Id],
|
|
||||||
(old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
...newData,
|
|
||||||
UserData: { ...old.UserData, ...newData.UserData },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const markFavoriteMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
if (api && user) {
|
|
||||||
await getUserLibraryApi(api).markFavoriteItem({
|
|
||||||
userId: user.Id,
|
|
||||||
itemId: item.Id!,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMutate: async () => {
|
|
||||||
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
|
||||||
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
|
||||||
type,
|
|
||||||
item.Id,
|
|
||||||
]);
|
|
||||||
updateItemInQueries({ UserData: { IsFavorite: true } });
|
|
||||||
|
|
||||||
return { previousItem };
|
|
||||||
},
|
|
||||||
onError: (err, variables, context) => {
|
|
||||||
if (context?.previousItem) {
|
|
||||||
queryClient.setQueryData([type, item.Id], context.previousItem);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const unmarkFavoriteMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
if (api && user) {
|
|
||||||
await getUserLibraryApi(api).unmarkFavoriteItem({
|
|
||||||
userId: user.Id,
|
|
||||||
itemId: item.Id!,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMutate: async () => {
|
|
||||||
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
|
||||||
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
|
||||||
type,
|
|
||||||
item.Id,
|
|
||||||
]);
|
|
||||||
updateItemInQueries({ UserData: { IsFavorite: false } });
|
|
||||||
|
|
||||||
return { previousItem };
|
|
||||||
},
|
|
||||||
onError: (err, variables, context) => {
|
|
||||||
if (context?.previousItem) {
|
|
||||||
queryClient.setQueryData([type, item.Id], context.previousItem);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<RoundButton
|
<RoundButton
|
||||||
size="large"
|
size='large'
|
||||||
icon={isFavorite ? "heart" : "heart-outline"}
|
icon={isFavorite ? "heart" : "heart-outline"}
|
||||||
fillColor={isFavorite ? "primary" : undefined}
|
fillColor={isFavorite ? "primary" : undefined}
|
||||||
onPress={() => {
|
onPress={toggleFavorite}
|
||||||
if (isFavorite) {
|
|
||||||
unmarkFavoriteMutation.mutate();
|
|
||||||
} else {
|
|
||||||
markFavoriteMutation.mutate();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "./common/Text";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
@@ -20,31 +20,31 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
if (Platform.isTV) return null;
|
if (Platform.isTV) return null;
|
||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
[source]
|
[source],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedAudioSteam = useMemo(
|
const selectedAudioSteam = useMemo(
|
||||||
() => audioStreams?.find((x) => x.Index === selected),
|
() => audioStreams?.find((x) => x.Index === selected),
|
||||||
[audioStreams, selected]
|
[audioStreams, selected],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex shrink"
|
className='flex shrink'
|
||||||
style={{
|
style={{
|
||||||
minWidth: 50,
|
minWidth: 50,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col" {...props}>
|
<View className='flex flex-col' {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
{t("item_card.audio")}
|
{t("item_card.audio")}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
<Text className="" numberOfLines={1}>
|
<Text className='' numberOfLines={1}>
|
||||||
{selectedAudioSteam?.DisplayTitle}
|
{selectedAudioSteam?.DisplayTitle}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -52,8 +52,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
loop={true}
|
loop={true}
|
||||||
side="bottom"
|
side='bottom'
|
||||||
align="start"
|
align='start'
|
||||||
alignOffset={0}
|
alignOffset={0}
|
||||||
avoidCollisions={true}
|
avoidCollisions={true}
|
||||||
collisionPadding={8}
|
collisionPadding={8}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { View, ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -22,7 +22,7 @@ export const Badge: React.FC<Props> = ({
|
|||||||
${variant === "gray" && "bg-neutral-800"}
|
${variant === "gray" && "bg-neutral-800"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{iconLeft && <View className="mr-1">{iconLeft}</View>}
|
{iconLeft && <View className='mr-1'>{iconLeft}</View>}
|
||||||
<Text
|
<Text
|
||||||
className={`
|
className={`
|
||||||
text-xs
|
text-xs
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "./common/Text";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
export type Bitrate = {
|
export type Bitrate = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -40,7 +40,11 @@ export const BITRATES: Bitrate[] = [
|
|||||||
key: "250 Kb/s",
|
key: "250 Kb/s",
|
||||||
value: 250000,
|
value: 250000,
|
||||||
},
|
},
|
||||||
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
|
].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(b.value || Number.POSITIVE_INFINITY) -
|
||||||
|
(a.value || Number.POSITIVE_INFINITY),
|
||||||
|
);
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
onChange: (value: Bitrate) => void;
|
onChange: (value: Bitrate) => void;
|
||||||
@@ -58,10 +62,14 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (inverted)
|
if (inverted)
|
||||||
return BITRATES.sort(
|
return BITRATES.sort(
|
||||||
(a, b) => (a.value || Infinity) - (b.value || Infinity)
|
(a, b) =>
|
||||||
|
(a.value || Number.POSITIVE_INFINITY) -
|
||||||
|
(b.value || Number.POSITIVE_INFINITY),
|
||||||
);
|
);
|
||||||
return BITRATES.sort(
|
return BITRATES.sort(
|
||||||
(a, b) => (b.value || Infinity) - (a.value || Infinity)
|
(a, b) =>
|
||||||
|
(b.value || Number.POSITIVE_INFINITY) -
|
||||||
|
(a.value || Number.POSITIVE_INFINITY),
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -69,7 +77,7 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex shrink"
|
className='flex shrink'
|
||||||
style={{
|
style={{
|
||||||
minWidth: 60,
|
minWidth: 60,
|
||||||
maxWidth: 200,
|
maxWidth: 200,
|
||||||
@@ -77,12 +85,12 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col" {...props}>
|
<View className='flex flex-col' {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
{t("item_card.quality")}
|
{t("item_card.quality")}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
<Text style={{}} className="" numberOfLines={1}>
|
<Text style={{}} className='' numberOfLines={1}>
|
||||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -90,8 +98,8 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
loop={false}
|
loop={false}
|
||||||
side="bottom"
|
side='bottom'
|
||||||
align="center"
|
align='center'
|
||||||
alignOffset={0}
|
alignOffset={0}
|
||||||
avoidCollisions={true}
|
avoidCollisions={true}
|
||||||
collisionPadding={0}
|
collisionPadding={0}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
import type React from "react";
|
||||||
|
import { type PropsWithChildren, type ReactNode, useMemo } from "react";
|
||||||
import { Platform, Text, TouchableOpacity, View } from "react-native";
|
import { Platform, Text, TouchableOpacity, View } from "react-native";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<View className="p-0.5">
|
<View className='p-0.5'>
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
@@ -72,7 +73,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
flex flex-row items-center justify-between w-full
|
flex flex-row items-center justify-between w-full
|
||||||
${justify === "between" ? "justify-between" : "justify-center"}`}
|
${justify === "between" ? "justify-between" : "justify-center"}`}
|
||||||
>
|
>
|
||||||
{iconLeft ? iconLeft : <View className="w-4"></View>}
|
{iconLeft ? iconLeft : <View className='w-4' />}
|
||||||
<Text
|
<Text
|
||||||
className={`
|
className={`
|
||||||
text-white font-bold text-base
|
text-white font-bold text-base
|
||||||
@@ -84,7 +85,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
{iconRight ? iconRight : <View className="w-4"></View>}
|
{iconRight ? iconRight : <View className='w-4' />}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import React, { useCallback, useEffect } from "react";
|
import React, { useCallback, useEffect } from "react";
|
||||||
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, type ViewProps } from "react-native";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
CastButton,
|
CastButton,
|
||||||
CastContext,
|
CastContext,
|
||||||
@@ -45,18 +45,18 @@ export function Chromecast({
|
|||||||
const AndroidCastButton = useCallback(
|
const AndroidCastButton = useCallback(
|
||||||
() =>
|
() =>
|
||||||
Platform.OS === "android" ? (
|
Platform.OS === "android" ? (
|
||||||
<CastButton tintColor="transparent" />
|
<CastButton tintColor='transparent' />
|
||||||
) : (
|
) : (
|
||||||
<></>
|
<></>
|
||||||
),
|
),
|
||||||
[Platform.OS]
|
[Platform.OS],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (background === "transparent")
|
if (background === "transparent")
|
||||||
return (
|
return (
|
||||||
<RoundButton
|
<RoundButton
|
||||||
size="large"
|
size='large'
|
||||||
className="mr-2"
|
className='mr-2'
|
||||||
background={false}
|
background={false}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
@@ -65,13 +65,13 @@ export function Chromecast({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<AndroidCastButton />
|
<AndroidCastButton />
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
<Feather name='cast' size={22} color={"white"} />
|
||||||
</RoundButton>
|
</RoundButton>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RoundButton
|
<RoundButton
|
||||||
size="large"
|
size='large'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
else CastContext.showCastDialog();
|
else CastContext.showCastDialog();
|
||||||
@@ -79,7 +79,7 @@ export function Chromecast({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<AndroidCastButton />
|
<AndroidCastButton />
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
<Feather name='cast' size={22} color={"white"} />
|
||||||
</RoundButton>
|
</RoundButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import type React from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { WatchedIndicator } from "./WatchedIndicator";
|
import { WatchedIndicator } from "./WatchedIndicator";
|
||||||
import React from "react";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
|
|
||||||
type ContinueWatchingPosterProps = {
|
type ContinueWatchingPosterProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -27,33 +27,39 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
* Get horizontal poster for movie and episode, with failover to primary.
|
* Get horizontal poster for movie and episode, with failover to primary.
|
||||||
*/
|
*/
|
||||||
const url = useMemo(() => {
|
const url = useMemo(() => {
|
||||||
if (!api) return;
|
if (!api) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (item.Type === "Episode" && useEpisodePoster) {
|
if (item.Type === "Episode" && useEpisodePoster) {
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}
|
}
|
||||||
if (item.Type === "Episode") {
|
if (item.Type === "Episode") {
|
||||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag)
|
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
else
|
}
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}
|
}
|
||||||
if (item.Type === "Movie") {
|
if (item.Type === "Movie") {
|
||||||
if (item.ImageTags?.["Thumb"])
|
if (item.ImageTags?.Thumb) {
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||||
else
|
}
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}
|
}
|
||||||
if (item.Type === "Program") {
|
if (item.Type === "Program") {
|
||||||
if (item.ImageTags?.["Thumb"])
|
if (item.ImageTags?.Thumb) {
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||||
else
|
}
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.ImageTags?.["Thumb"])
|
if (item.ImageTags?.Thumb) {
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||||
else
|
}
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
const progress = useMemo(() => {
|
const progress = useMemo(() => {
|
||||||
@@ -64,15 +70,12 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
const total = endDate.getTime() - startDate.getTime();
|
const total = endDate.getTime() - startDate.getTime();
|
||||||
const elapsed = now.getTime() - startDate.getTime();
|
const elapsed = now.getTime() - startDate.getTime();
|
||||||
return (elapsed / total) * 100;
|
return (elapsed / total) * 100;
|
||||||
} else {
|
|
||||||
return item.UserData?.PlayedPercentage || 0;
|
|
||||||
}
|
}
|
||||||
|
return item.UserData?.PlayedPercentage || 0;
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
if (!url)
|
if (!url)
|
||||||
return (
|
return <View className='aspect-video border border-neutral-800 w-44' />;
|
||||||
<View className="aspect-video border border-neutral-800 w-44"></View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -81,7 +84,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
${size === "small" ? "w-32" : "w-44"}
|
${size === "small" ? "w-32" : "w-44"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<View className="w-full h-full flex items-center justify-center">
|
<View className='w-full h-full flex items-center justify-center'>
|
||||||
<Image
|
<Image
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
id={item.Id}
|
id={item.Id}
|
||||||
@@ -89,12 +92,12 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
uri: url,
|
uri: url,
|
||||||
}}
|
}}
|
||||||
cachePolicy={"memory-disk"}
|
cachePolicy={"memory-disk"}
|
||||||
contentFit="cover"
|
contentFit='cover'
|
||||||
className="w-full h-full"
|
className='w-full h-full'
|
||||||
/>
|
/>
|
||||||
{showPlayButton && (
|
{showPlayButton && (
|
||||||
<View className="absolute inset-0 flex items-center justify-center">
|
<View className='absolute inset-0 flex items-center justify-center'>
|
||||||
<Ionicons name="play-circle" size={40} color="white" />
|
<Ionicons name='play-circle' size={40} color='white' />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -102,14 +105,16 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
{progress > 0 && (
|
{progress > 0 && (
|
||||||
<>
|
<>
|
||||||
<View
|
<View
|
||||||
className={`absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full`}
|
className={
|
||||||
></View>
|
"absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full"
|
||||||
|
}
|
||||||
|
/>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: `${progress}%`,
|
width: `${progress}%`,
|
||||||
}}
|
}}
|
||||||
className={`absolute bottom-0 left-0 h-1 bg-purple-600 w-full`}
|
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
|
||||||
></View>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||||
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
|
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
||||||
@@ -10,29 +9,30 @@ import download from "@/utils/profiles/download";
|
|||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
BottomSheetBackdropProps,
|
type BottomSheetBackdropProps,
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Href, router, useFocusEffect } from "expo-router";
|
import { type Href, router, useFocusEffect } from "expo-router";
|
||||||
|
import { t } from "i18next";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
import type React from "react";
|
||||||
import { Alert, View, ViewProps } from "react-native";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { Alert, Platform, View, type ViewProps } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
import { type Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Text } from "./common/Text";
|
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import ProgressCircle from "./ProgressCircle";
|
import ProgressCircle from "./ProgressCircle";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||||
import { t } from "i18next";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface DownloadProps extends ViewProps {
|
interface DownloadProps extends ViewProps {
|
||||||
items: BaseItemDto[];
|
items: BaseItemDto[];
|
||||||
@@ -58,7 +58,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
||||||
const { startRemuxing } = useRemuxHlsToMp4();
|
//const { startRemuxing } = useRemuxHlsToMp4();
|
||||||
|
|
||||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||||
MediaSourceInfo | undefined | null
|
MediaSourceInfo | undefined | null
|
||||||
@@ -66,18 +66,20 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||||
useState<number>(0);
|
useState<number>(0);
|
||||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(settings?.defaultBitrate ?? {
|
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(
|
||||||
key: "Max",
|
settings?.defaultBitrate ?? {
|
||||||
value: undefined,
|
key: "Max",
|
||||||
});
|
value: undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const userCanDownload = useMemo(
|
const userCanDownload = useMemo(
|
||||||
() => user?.Policy?.EnableContentDownloading,
|
() => user?.Policy?.EnableContentDownloading,
|
||||||
[user]
|
[user],
|
||||||
);
|
);
|
||||||
const usingOptimizedServer = useMemo(
|
const usingOptimizedServer = useMemo(
|
||||||
() => settings?.downloadMethod === DownloadMethod.Optimized,
|
() => settings?.downloadMethod === DownloadMethod.Optimized,
|
||||||
[settings]
|
[settings],
|
||||||
);
|
);
|
||||||
|
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
@@ -97,7 +99,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
const itemsNotDownloaded = useMemo(
|
const itemsNotDownloaded = useMemo(
|
||||||
() =>
|
() =>
|
||||||
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
|
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
|
||||||
[items, downloadedFiles]
|
[items, downloadedFiles],
|
||||||
);
|
);
|
||||||
|
|
||||||
const allItemsDownloaded = useMemo(() => {
|
const allItemsDownloaded = useMemo(() => {
|
||||||
@@ -106,11 +108,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
}, [items, itemsNotDownloaded]);
|
}, [items, itemsNotDownloaded]);
|
||||||
const itemsProcesses = useMemo(
|
const itemsProcesses = useMemo(
|
||||||
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
|
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
|
||||||
[processes, itemIds]
|
[processes, itemIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
const progress = useMemo(() => {
|
const progress = useMemo(() => {
|
||||||
if (itemIds.length == 1)
|
if (itemIds.length === 1)
|
||||||
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
|
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
|
||||||
return (
|
return (
|
||||||
((itemIds.length -
|
((itemIds.length -
|
||||||
@@ -123,7 +125,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
const itemsQueued = useMemo(() => {
|
const itemsQueued = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
itemsNotDownloaded.length > 0 &&
|
itemsNotDownloaded.length > 0 &&
|
||||||
itemsNotDownloaded.every((p) => queue.some((q) => p.Id == q.item.Id))
|
itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id))
|
||||||
);
|
);
|
||||||
}, [queue, itemsNotDownloaded]);
|
}, [queue, itemsNotDownloaded]);
|
||||||
const navigateToDownloads = () => router.push("/downloads");
|
const navigateToDownloads = () => router.push("/downloads");
|
||||||
@@ -138,7 +140,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
params: {
|
params: {
|
||||||
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
||||||
},
|
},
|
||||||
} as Href)
|
} as Href),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,20 +151,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
}
|
}
|
||||||
closeModal();
|
closeModal();
|
||||||
|
|
||||||
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
|
initiateDownload(...itemsNotDownloaded);
|
||||||
else {
|
|
||||||
queueActions.enqueue(
|
|
||||||
queue,
|
|
||||||
setQueue,
|
|
||||||
...itemsNotDownloaded.map((item) => ({
|
|
||||||
id: item.Id!,
|
|
||||||
execute: async () => await initiateDownload(item),
|
|
||||||
item,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
toast.error(t("home.downloads.toasts.you_are_not_allowed_to_download_files"));
|
toast.error(
|
||||||
|
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
queue,
|
queue,
|
||||||
@@ -185,7 +178,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
|
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"DownloadItem ~ initiateDownload: No api or user or item"
|
"DownloadItem ~ initiateDownload: No api or user or item",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let mediaSource = selectedMediaSource;
|
let mediaSource = selectedMediaSource;
|
||||||
@@ -198,7 +191,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
mediaSource = defaults.mediaSource;
|
mediaSource = defaults.mediaSource;
|
||||||
audioIndex = defaults.audioIndex;
|
audioIndex = defaults.audioIndex;
|
||||||
subtitleIndex = defaults.subtitleIndex;
|
subtitleIndex = defaults.subtitleIndex;
|
||||||
// Keep using the selected bitrate for consistency across all downloads
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
const res = await getStreamUrl({
|
||||||
@@ -211,12 +203,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
mediaSourceId: mediaSource?.Id,
|
mediaSourceId: mediaSource?.Id,
|
||||||
subtitleStreamIndex: subtitleIndex,
|
subtitleStreamIndex: subtitleIndex,
|
||||||
deviceProfile: download,
|
deviceProfile: download,
|
||||||
|
download: true,
|
||||||
|
// deviceId: mediaSource?.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res) {
|
if (!res) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.downloads.something_went_wrong"),
|
t("home.downloads.something_went_wrong"),
|
||||||
t("home.downloads.could_not_get_stream_url_from_jellyfin")
|
t("home.downloads.could_not_get_stream_url_from_jellyfin"),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -226,12 +220,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
if (!url || !source) throw new Error("No url");
|
if (!url || !source) throw new Error("No url");
|
||||||
|
|
||||||
saveDownloadItemInfoToDiskTmp(item, source, url);
|
saveDownloadItemInfoToDiskTmp(item, source, url);
|
||||||
|
await startBackgroundDownload(url, item, source, maxBitrate);
|
||||||
if (usingOptimizedServer) {
|
|
||||||
await startBackgroundDownload(url, item, source);
|
|
||||||
} else {
|
|
||||||
await startRemuxing(item, url, source);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -245,8 +234,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
maxBitrate,
|
maxBitrate,
|
||||||
usingOptimizedServer,
|
usingOptimizedServer,
|
||||||
startBackgroundDownload,
|
startBackgroundDownload,
|
||||||
startRemuxing,
|
],
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
@@ -257,7 +245,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
appearsOnIndex={0}
|
appearsOnIndex={0}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
[]
|
[],
|
||||||
);
|
);
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
@@ -270,31 +258,35 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
setSelectedAudioStream(audioIndex ?? 0);
|
setSelectedAudioStream(audioIndex ?? 0);
|
||||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||||
setMaxBitrate(bitrate);
|
setMaxBitrate(bitrate);
|
||||||
}, [items, itemsNotDownloaded, settings])
|
}, [items, itemsNotDownloaded, settings]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderButtonContent = () => {
|
const renderButtonContent = () => {
|
||||||
if (processes && itemsProcesses.length > 0) {
|
if (processes.length > 0 && itemsProcesses.length > 0) {
|
||||||
return progress === 0 ? (
|
return progress === 0 ? (
|
||||||
<Loader />
|
<Loader />
|
||||||
) : (
|
) : (
|
||||||
<View className="-rotate-45">
|
<View className='-rotate-45'>
|
||||||
<ProgressCircle
|
<ProgressCircle
|
||||||
size={24}
|
size={24}
|
||||||
fill={progress}
|
fill={progress}
|
||||||
width={4}
|
width={4}
|
||||||
tintColor="#9334E9"
|
tintColor='#9334E9'
|
||||||
backgroundColor="#bdc3c7"
|
backgroundColor='#bdc3c7'
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
} else if (itemsQueued) {
|
|
||||||
return <Ionicons name="hourglass" size={24} color="white" />;
|
|
||||||
} else if (allItemsDownloaded) {
|
|
||||||
return <DownloadedIconComponent />;
|
|
||||||
} else {
|
|
||||||
return <MissingDownloadIconComponent />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (itemsQueued) {
|
||||||
|
return <Ionicons name='hourglass' size={24} color='white' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allItemsDownloaded) {
|
||||||
|
return <DownloadedIconComponent />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MissingDownloadIconComponent />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onButtonPress = () => {
|
const onButtonPress = () => {
|
||||||
@@ -327,16 +319,19 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-bold text-2xl text-neutral-100">
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-neutral-300">
|
<Text className='text-neutral-300'>
|
||||||
{subtitle || t("item_card.download.download_x_item", {item_count: itemsNotDownloaded.length})}
|
{subtitle ||
|
||||||
|
t("item_card.download.download_x_item", {
|
||||||
|
item_count: itemsNotDownloaded.length,
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-col space-y-2 w-full items-start">
|
<View className='flex flex-col space-y-2 w-full items-start'>
|
||||||
<BitrateSelector
|
<BitrateSelector
|
||||||
inverted
|
inverted
|
||||||
onChange={setMaxBitrate}
|
onChange={setMaxBitrate}
|
||||||
@@ -350,7 +345,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
selected={selectedMediaSource}
|
selected={selectedMediaSource}
|
||||||
/>
|
/>
|
||||||
{selectedMediaSource && (
|
{selectedMediaSource && (
|
||||||
<View className="flex flex-col space-y-2">
|
<View className='flex flex-col space-y-2'>
|
||||||
<AudioTrackSelector
|
<AudioTrackSelector
|
||||||
source={selectedMediaSource}
|
source={selectedMediaSource}
|
||||||
onChange={setSelectedAudioStream}
|
onChange={setSelectedAudioStream}
|
||||||
@@ -367,14 +362,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
className="mt-auto"
|
className='mt-auto'
|
||||||
onPress={acceptDownloadOptions}
|
onPress={acceptDownloadOptions}
|
||||||
color="purple"
|
color='purple'
|
||||||
>
|
>
|
||||||
{t("item_card.download.download_button")}
|
{t("item_card.download.download_button")}
|
||||||
</Button>
|
</Button>
|
||||||
<View className="opacity-70 text-center w-full flex items-center">
|
<View className='opacity-70 text-center w-full flex items-center'>
|
||||||
<Text className="text-xs">
|
<Text className='text-xs'>
|
||||||
{usingOptimizedServer
|
{usingOptimizedServer
|
||||||
? t("item_card.download.using_optimized_server")
|
? t("item_card.download.using_optimized_server")
|
||||||
: t("item_card.download.using_default_method")}
|
: t("item_card.download.using_default_method")}
|
||||||
@@ -391,19 +386,23 @@ export const DownloadSingleItem: React.FC<{
|
|||||||
size?: "default" | "large";
|
size?: "default" | "large";
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}> = ({ item, size = "default" }) => {
|
}> = ({ item, size = "default" }) => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DownloadItems
|
<DownloadItems
|
||||||
size={size}
|
size={size}
|
||||||
title={item.Type == "Episode"
|
title={
|
||||||
? t("item_card.download.download_episode")
|
item.Type === "Episode"
|
||||||
: t("item_card.download.download_movie")}
|
? t("item_card.download.download_episode")
|
||||||
|
: t("item_card.download.download_movie")
|
||||||
|
}
|
||||||
subtitle={item.Name!}
|
subtitle={item.Name!}
|
||||||
items={[item]}
|
items={[item]}
|
||||||
MissingDownloadIconComponent={() => (
|
MissingDownloadIconComponent={() => (
|
||||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
<Ionicons name='cloud-download-outline' size={24} color='white' />
|
||||||
)}
|
)}
|
||||||
DownloadedIconComponent={() => (
|
DownloadedIconComponent={() => (
|
||||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
<Ionicons name='cloud-download' size={26} color='#9333ea' />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,44 +1,57 @@
|
|||||||
// GenreTags.tsx
|
// GenreTags.tsx
|
||||||
import React from "react";
|
import type React from "react";
|
||||||
import {StyleProp, TextStyle, View, ViewProps} from "react-native";
|
import {
|
||||||
|
type StyleProp,
|
||||||
|
type TextStyle,
|
||||||
|
View,
|
||||||
|
type ViewProps,
|
||||||
|
} from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface TagProps {
|
interface TagProps {
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
textClass?: ViewProps["className"]
|
textClass?: ViewProps["className"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"], textStyle?: StyleProp<TextStyle>} & ViewProps> = ({
|
export const Tag: React.FC<
|
||||||
text,
|
{
|
||||||
textClass,
|
text: string;
|
||||||
textStyle,
|
textClass?: ViewProps["className"];
|
||||||
...props
|
textStyle?: StyleProp<TextStyle>;
|
||||||
}) => {
|
} & ViewProps
|
||||||
|
> = ({ text, textClass, textStyle, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
|
<View className='bg-neutral-800 rounded-full px-2 py-1' {...props}>
|
||||||
<Text className={textClass} style={textStyle}>{text}</Text>
|
<Text className={textClass} style={textStyle}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-xs", ...props }) => {
|
export const Tags: React.FC<
|
||||||
|
TagProps & { tagProps?: ViewProps } & ViewProps
|
||||||
|
> = ({ tags, textClass = "text-xs", tagProps, ...props }) => {
|
||||||
if (!tags || tags.length === 0) return null;
|
if (!tags || tags.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
|
<View
|
||||||
|
className={`flex flex-row flex-wrap gap-1 ${props.className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{tags.map((tag, idx) => (
|
{tags.map((tag, idx) => (
|
||||||
<View key={idx}>
|
<View key={idx}>
|
||||||
<Tag key={idx} textClass={textClass} text={tag}/>
|
<Tag key={idx} textClass={textClass} text={tag} {...tagProps} />
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GenreTags: React.FC<{ genres?: string[]}> = ({ genres }) => {
|
export const GenreTags: React.FC<{ genres?: string[] }> = ({ genres }) => {
|
||||||
return (
|
return (
|
||||||
<View className="mt-2">
|
<View className='mt-2'>
|
||||||
<Tags tags={genres}/>
|
<Tags tags={genres} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from "react";
|
import { tc } from "@/utils/textTools";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import type React from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { tc } from "@/utils/textTools";
|
|
||||||
|
|
||||||
type ItemCardProps = {
|
type ItemCardProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -10,13 +10,13 @@ type ItemCardProps = {
|
|||||||
|
|
||||||
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
||||||
return (
|
return (
|
||||||
<View className="mt-2 flex flex-col">
|
<View className='mt-2 flex flex-col'>
|
||||||
{item.Type === "Episode" ? (
|
{item.Type === "Episode" ? (
|
||||||
<>
|
<>
|
||||||
<Text numberOfLines={1} className="">
|
<Text numberOfLines={1} ellipsizeMode='tail' className=''>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text numberOfLines={1} className="text-xs opacity-50">
|
<Text numberOfLines={1} className='text-xs opacity-50'>
|
||||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||||
{" - "}
|
{" - "}
|
||||||
{item.SeriesName}
|
{item.SeriesName}
|
||||||
@@ -24,8 +24,10 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text numberOfLines={2}>{item.Name}</Text>
|
<Text numberOfLines={1} ellipsizeMode='tail'>
|
||||||
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-xs opacity-50'>{item.ProductionYear}</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
@@ -15,26 +15,28 @@ import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarous
|
|||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
import { useImageColors } from "@/hooks/useImageColors";
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
import { AddToFavorites } from "./AddToFavorites";
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
import { AddToFavorites } from "./AddToFavorites";
|
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||||
|
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||||
|
|
||||||
export type SelectedOptions = {
|
export type SelectedOptions = {
|
||||||
bitrate: Bitrate;
|
bitrate: Bitrate;
|
||||||
@@ -50,6 +52,8 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
useImageColors({ item });
|
useImageColors({ item });
|
||||||
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||||
@@ -86,17 +90,23 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
item && (
|
item && (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
<Chromecast.Chromecast
|
<Chromecast.Chromecast
|
||||||
background="blur"
|
background='blur'
|
||||||
width={22}
|
width={22}
|
||||||
height={22}
|
height={22}
|
||||||
/>
|
/>
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
<DownloadSingleItem item={item} size="large" />
|
{!Platform.isTV && (
|
||||||
<PlayedStatus items={[item]} size="large" />
|
<DownloadSingleItem item={item} size='large' />
|
||||||
<AddToFavorites item={item} type="item" />
|
)}
|
||||||
|
{user?.Policy?.IsAdministrator && (
|
||||||
|
<PlayInRemoteSessionButton item={item} size='large' />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PlayedStatus items={[item]} size='large' />
|
||||||
|
<AddToFavorites item={item} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -121,7 +131,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex-1 relative"
|
className='flex-1 relative'
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
@@ -145,41 +155,38 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
logo={
|
logo={
|
||||||
<>
|
logoUrl ? (
|
||||||
{logoUrl ? (
|
<Image
|
||||||
<Image
|
source={{
|
||||||
source={{
|
uri: logoUrl,
|
||||||
uri: logoUrl,
|
}}
|
||||||
}}
|
style={{
|
||||||
style={{
|
height: 130,
|
||||||
height: 130,
|
width: "100%",
|
||||||
width: "100%",
|
resizeMode: "contain",
|
||||||
resizeMode: "contain",
|
}}
|
||||||
}}
|
onLoad={() => setLoadingLogo(false)}
|
||||||
onLoad={() => setLoadingLogo(false)}
|
onError={() => setLoadingLogo(false)}
|
||||||
onError={() => setLoadingLogo(false)}
|
/>
|
||||||
/>
|
) : null
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col bg-transparent shrink">
|
<View className='flex flex-col bg-transparent shrink'>
|
||||||
{/* {!Platform.isTV && ( */}
|
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
||||||
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
<ItemHeader item={item} className='mb-4' />
|
||||||
<ItemHeader item={item} className="mb-4" />
|
|
||||||
{item.Type !== "Program" && !Platform.isTV && (
|
{item.Type !== "Program" && !Platform.isTV && (
|
||||||
<View className="flex flex-row items-center justify-start w-full h-16">
|
<View className='flex flex-row items-center justify-start w-full h-16'>
|
||||||
<BitrateSelector
|
<BitrateSelector
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
onChange={(val) =>
|
onChange={(val) =>
|
||||||
setSelectedOptions(
|
setSelectedOptions(
|
||||||
(prev) => prev && { ...prev, bitrate: val }
|
(prev) => prev && { ...prev, bitrate: val },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
selected={selectedOptions.bitrate}
|
selected={selectedOptions.bitrate}
|
||||||
/>
|
/>
|
||||||
<MediaSourceSelector
|
<MediaSourceSelector
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
item={item}
|
item={item}
|
||||||
onChange={(val) =>
|
onChange={(val) =>
|
||||||
setSelectedOptions(
|
setSelectedOptions(
|
||||||
@@ -187,13 +194,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
prev && {
|
prev && {
|
||||||
...prev,
|
...prev,
|
||||||
mediaSource: val,
|
mediaSource: val,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
selected={selectedOptions.mediaSource}
|
selected={selectedOptions.mediaSource}
|
||||||
/>
|
/>
|
||||||
<AudioTrackSelector
|
<AudioTrackSelector
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
source={selectedOptions.mediaSource}
|
source={selectedOptions.mediaSource}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
setSelectedOptions(
|
setSelectedOptions(
|
||||||
@@ -201,7 +208,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
prev && {
|
prev && {
|
||||||
...prev,
|
...prev,
|
||||||
audioIndex: val,
|
audioIndex: val,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
selected={selectedOptions.audioIndex}
|
selected={selectedOptions.audioIndex}
|
||||||
@@ -214,7 +221,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
prev && {
|
prev && {
|
||||||
...prev,
|
...prev,
|
||||||
subtitleIndex: val,
|
subtitleIndex: val,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
selected={selectedOptions.subtitleIndex}
|
selected={selectedOptions.subtitleIndex}
|
||||||
@@ -222,13 +229,11 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* {!Platform.isTV && ( */}
|
|
||||||
<PlayButton
|
<PlayButton
|
||||||
className="grow"
|
className='grow'
|
||||||
selectedOptions={selectedOptions}
|
selectedOptions={selectedOptions}
|
||||||
item={item}
|
item={item}
|
||||||
/>
|
/>
|
||||||
{/* )} */}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
@@ -236,24 +241,24 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||||
<OverviewText text={item.Overview} className="px-4 mb-4" />
|
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||||
|
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<>
|
<>
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
<CurrentSeries item={item} className="mb-4" />
|
<CurrentSeries item={item} className='mb-4' />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
||||||
|
|
||||||
{item.People && item.People.length > 0 && (
|
{item.People && item.People.length > 0 && (
|
||||||
<View className="mb-4">
|
<View className='mb-4'>
|
||||||
{item.People.slice(0, 3).map((person, idx) => (
|
{item.People.slice(0, 3).map((person, idx) => (
|
||||||
<MoreMoviesWithActor
|
<MoreMoviesWithActor
|
||||||
currentItem={item}
|
currentItem={item}
|
||||||
key={idx}
|
key={idx}
|
||||||
actorId={person.Id!}
|
actorId={person.Id!}
|
||||||
className="mb-4"
|
className='mb-4'
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -266,5 +271,5 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
</ParallaxScrollView>
|
</ParallaxScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import React from "react";
|
import type React from "react";
|
||||||
import { View, ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { GenreTags } from "./GenreTags";
|
import { GenreTags } from "./GenreTags";
|
||||||
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
|
||||||
import { Ratings } from "./Ratings";
|
import { Ratings } from "./Ratings";
|
||||||
|
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||||
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
||||||
import { ItemActions } from "./series/SeriesActions";
|
import { ItemActions } from "./series/SeriesActions";
|
||||||
|
|
||||||
@@ -15,21 +15,21 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
if (!item)
|
if (!item)
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex flex-col space-y-1.5 w-full items-start h-32"
|
className='flex flex-col space-y-1.5 w-full items-start h-32'
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<View className="w-1/3 h-6 bg-neutral-900 rounded" />
|
<View className='w-1/3 h-6 bg-neutral-900 rounded' />
|
||||||
<View className="w-2/3 h-8 bg-neutral-900 rounded" />
|
<View className='w-2/3 h-8 bg-neutral-900 rounded' />
|
||||||
<View className="w-2/3 h-4 bg-neutral-900 rounded" />
|
<View className='w-2/3 h-4 bg-neutral-900 rounded' />
|
||||||
<View className="w-1/4 h-4 bg-neutral-900 rounded" />
|
<View className='w-1/4 h-4 bg-neutral-900 rounded' />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col" {...props}>
|
<View className='flex flex-col' {...props}>
|
||||||
<View className="flex flex-col" {...props}>
|
<View className='flex flex-col' {...props}>
|
||||||
<View className="flex flex-row items-center justify-between">
|
<View className='flex flex-row items-center justify-between'>
|
||||||
<Ratings item={item} className="mb-2" />
|
<Ratings item={item} className='mb-2' />
|
||||||
<ItemActions item={item} />
|
<ItemActions item={item} />
|
||||||
</View>
|
</View>
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetScrollView,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import type {
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
type MediaStream,
|
MediaStream,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import React, { useMemo, useRef } from "react";
|
import type React from "react";
|
||||||
|
import { useMemo, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { Badge } from "./Badge";
|
import { Badge } from "./Badge";
|
||||||
import { Text } from "./common/Text";
|
|
||||||
import {
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetBackdropProps,
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
BottomSheetView,
|
|
||||||
BottomSheetScrollView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
@@ -26,13 +28,13 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="px-4 mt-2 mb-4">
|
<View className='px-4 mt-2 mb-4'>
|
||||||
<Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
|
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
|
||||||
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
|
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
|
||||||
<View className="flex flex-row space-x-2">
|
<View className='flex flex-row space-x-2'>
|
||||||
<VideoStreamInfo source={source} />
|
<VideoStreamInfo source={source} />
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-purple-600">{t("item_card.more_details")}</Text>
|
<Text className='text-purple-600'>{t("item_card.more_details")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={bottomSheetModalRef}
|
ref={bottomSheetModalRef}
|
||||||
@@ -52,31 +54,37 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<BottomSheetScrollView>
|
<BottomSheetScrollView>
|
||||||
<View className="flex flex-col space-y-2 p-4 mb-4">
|
<View className='flex flex-col space-y-2 p-4 mb-4'>
|
||||||
<View className="">
|
<View className=''>
|
||||||
<Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
|
<Text className='text-lg font-bold mb-4'>
|
||||||
<View className="flex flex-row space-x-2">
|
{t("item_card.video")}
|
||||||
|
</Text>
|
||||||
|
<View className='flex flex-row space-x-2'>
|
||||||
<VideoStreamInfo source={source} />
|
<VideoStreamInfo source={source} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="">
|
<View className=''>
|
||||||
<Text className="text-lg font-bold mb-2">{t("item_card.audio")}</Text>
|
<Text className='text-lg font-bold mb-2'>
|
||||||
|
{t("item_card.audio")}
|
||||||
|
</Text>
|
||||||
<AudioStreamInfo
|
<AudioStreamInfo
|
||||||
audioStreams={
|
audioStreams={
|
||||||
source?.MediaStreams?.filter(
|
source?.MediaStreams?.filter(
|
||||||
(stream) => stream.Type === "Audio"
|
(stream) => stream.Type === "Audio",
|
||||||
) || []
|
) || []
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="">
|
<View className=''>
|
||||||
<Text className="text-lg font-bold mb-2">{t("item_card.subtitles")}</Text>
|
<Text className='text-lg font-bold mb-2'>
|
||||||
|
{t("item_card.subtitles")}
|
||||||
|
</Text>
|
||||||
<SubtitleStreamInfo
|
<SubtitleStreamInfo
|
||||||
subtitleStreams={
|
subtitleStreams={
|
||||||
source?.MediaStreams?.filter(
|
source?.MediaStreams?.filter(
|
||||||
(stream) => stream.Type === "Subtitle"
|
(stream) => stream.Type === "Subtitle",
|
||||||
) || []
|
) || []
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -94,25 +102,25 @@ const SubtitleStreamInfo = ({
|
|||||||
subtitleStreams: MediaStream[];
|
subtitleStreams: MediaStream[];
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col">
|
<View className='flex flex-col'>
|
||||||
{subtitleStreams.map((stream, index) => (
|
{subtitleStreams.map((stream, index) => (
|
||||||
<View key={stream.Index} className="flex flex-col">
|
<View key={stream.Index} className='flex flex-col'>
|
||||||
<Text className="text-xs mb-3 text-neutral-400">
|
<Text className='text-xs mb-3 text-neutral-400'>
|
||||||
{stream.DisplayTitle}
|
{stream.DisplayTitle}
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex flex-row flex-wrap gap-2">
|
<View className='flex flex-row flex-wrap gap-2'>
|
||||||
<Badge
|
<Badge
|
||||||
variant="gray"
|
variant='gray'
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<Ionicons name="language-outline" size={16} color="white" />
|
<Ionicons name='language-outline' size={16} color='white' />
|
||||||
}
|
}
|
||||||
text={stream.Language}
|
text={stream.Language}
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
variant="gray"
|
variant='gray'
|
||||||
text={stream.Codec}
|
text={stream.Codec}
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<Ionicons name="layers-outline" size={16} color="white" />
|
<Ionicons name='layers-outline' size={16} color='white' />
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -124,40 +132,40 @@ const SubtitleStreamInfo = ({
|
|||||||
|
|
||||||
const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
|
const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col">
|
<View className='flex flex-col'>
|
||||||
{audioStreams.map((audioStreams, index) => (
|
{audioStreams.map((audioStreams, index) => (
|
||||||
<View key={index} className="flex flex-col">
|
<View key={index} className='flex flex-col'>
|
||||||
<Text className="mb-3 text-neutral-400 text-xs">
|
<Text className='mb-3 text-neutral-400 text-xs'>
|
||||||
{audioStreams.DisplayTitle}
|
{audioStreams.DisplayTitle}
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row flex-wrap gap-2">
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
<Badge
|
<Badge
|
||||||
variant="gray"
|
variant='gray'
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<Ionicons name="language-outline" size={16} color="white" />
|
<Ionicons name='language-outline' size={16} color='white' />
|
||||||
}
|
}
|
||||||
text={audioStreams.Language}
|
text={audioStreams.Language}
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
variant="gray"
|
variant='gray'
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="musical-notes-outline"
|
name='musical-notes-outline'
|
||||||
size={16}
|
size={16}
|
||||||
color="white"
|
color='white'
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
text={audioStreams.Codec}
|
text={audioStreams.Codec}
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
variant="gray"
|
variant='gray'
|
||||||
iconLeft={<Ionicons name="mic-outline" size={16} color="white" />}
|
iconLeft={<Ionicons name='mic-outline' size={16} color='white' />}
|
||||||
text={audioStreams.ChannelLayout}
|
text={audioStreams.ChannelLayout}
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
variant="gray"
|
variant='gray'
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<Ionicons name="speedometer-outline" size={16} color="white" />
|
<Ionicons name='speedometer-outline' size={16} color='white' />
|
||||||
}
|
}
|
||||||
text={formatBitrate(audioStreams.BitRate)}
|
text={formatBitrate(audioStreams.BitRate)}
|
||||||
/>
|
/>
|
||||||
@@ -173,48 +181,48 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
|||||||
|
|
||||||
const videoStream = useMemo(() => {
|
const videoStream = useMemo(() => {
|
||||||
return source.MediaStreams?.find(
|
return source.MediaStreams?.find(
|
||||||
(stream) => stream.Type === "Video"
|
(stream) => stream.Type === "Video",
|
||||||
) as MediaStream;
|
) as MediaStream;
|
||||||
}, [source.MediaStreams]);
|
}, [source.MediaStreams]);
|
||||||
|
|
||||||
if (!videoStream) return null;
|
if (!videoStream) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-row flex-wrap gap-2">
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
<Badge
|
<Badge
|
||||||
variant="gray"
|
variant='gray'
|
||||||
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
|
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
|
||||||
text={formatFileSize(source.Size)}
|
text={formatFileSize(source.Size)}
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
variant="gray"
|
variant='gray'
|
||||||
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
|
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
|
||||||
text={`${videoStream.Width}x${videoStream.Height}`}
|
text={`${videoStream.Width}x${videoStream.Height}`}
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
variant="gray"
|
variant='gray'
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<Ionicons name="color-palette-outline" size={16} color="white" />
|
<Ionicons name='color-palette-outline' size={16} color='white' />
|
||||||
}
|
}
|
||||||
text={videoStream.VideoRange}
|
text={videoStream.VideoRange}
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
variant="gray"
|
variant='gray'
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<Ionicons name="code-working-outline" size={16} color="white" />
|
<Ionicons name='code-working-outline' size={16} color='white' />
|
||||||
}
|
}
|
||||||
text={videoStream.Codec}
|
text={videoStream.Codec}
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
variant="gray"
|
variant='gray'
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<Ionicons name="speedometer-outline" size={16} color="white" />
|
<Ionicons name='speedometer-outline' size={16} color='white' />
|
||||||
}
|
}
|
||||||
text={formatBitrate(videoStream.BitRate)}
|
text={formatBitrate(videoStream.BitRate)}
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
variant="gray"
|
variant='gray'
|
||||||
iconLeft={<Ionicons name="play-outline" size={16} color="white" />}
|
iconLeft={<Ionicons name='play-outline' size={16} color='white' />}
|
||||||
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
|
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -226,15 +234,8 @@ const formatFileSize = (bytes?: number | null) => {
|
|||||||
|
|
||||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||||
if (bytes === 0) return "0 Byte";
|
if (bytes === 0) return "0 Byte";
|
||||||
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
|
const i = Number.parseInt(
|
||||||
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
|
Math.floor(Math.log(bytes) / Math.log(1024)).toString(),
|
||||||
};
|
);
|
||||||
|
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
|
||||||
const formatBitrate = (bitrate?: number | null) => {
|
|
||||||
if (!bitrate) return "N/A";
|
|
||||||
|
|
||||||
const sizes = ["bps", "Kbps", "Mbps", "Gbps", "Tbps"];
|
|
||||||
if (bitrate === 0) return "0 bps";
|
|
||||||
const i = parseInt(Math.floor(Math.log(bitrate) / Math.log(1000)).toString());
|
|
||||||
return Math.round((bitrate / Math.pow(1000, i)) * 100) / 100 + " " + sizes[i];
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React from "react";
|
|
||||||
import { View, Text, TouchableOpacity } from "react-native";
|
|
||||||
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
|
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
|
||||||
|
import type React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Text, TouchableOpacity, View } from "react-native";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { ListGroup } from "./list/ListGroup";
|
import { ListGroup } from "./list/ListGroup";
|
||||||
import { ListItem } from "./list/ListItem";
|
import { ListItem } from "./list/ListItem";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onServerSelect?: (server: { address: string; serverName?: string }) => void;
|
onServerSelect?: (server: { address: string; serverName?: string }) => void;
|
||||||
@@ -15,15 +15,17 @@ const JellyfinServerDiscovery: React.FC<Props> = ({ onServerSelect }) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="mt-2">
|
<View className='mt-2'>
|
||||||
<Button onPress={startDiscovery} color="black">
|
<Button onPress={startDiscovery} color='black'>
|
||||||
<Text className="text-white text-center">
|
<Text className='text-white text-center'>
|
||||||
{isSearching ? t("server.searching") : t("server.search_for_local_servers")}
|
{isSearching
|
||||||
|
? t("server.searching")
|
||||||
|
: t("server.search_for_local_servers")}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{servers.length ? (
|
{servers.length ? (
|
||||||
<ListGroup title={t("server.servers")} className="mt-4">
|
<ListGroup title={t("server.servers")} className='mt-4'>
|
||||||
{servers.map((server) => (
|
{servers.map((server) => (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={server.address}
|
key={server.address}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
ActivityIndicatorProps,
|
type ActivityIndicatorProps,
|
||||||
Platform,
|
Platform,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "./common/Text";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -24,9 +24,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
const selectedName = useMemo(
|
const selectedName = useMemo(
|
||||||
() =>
|
() =>
|
||||||
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
||||||
(x) => x.Type === "Video"
|
(x) => x.Type === "Video",
|
||||||
)?.DisplayTitle || "",
|
)?.DisplayTitle || "",
|
||||||
[item, selected]
|
[item, selected],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -54,26 +54,26 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex shrink"
|
className='flex shrink'
|
||||||
style={{
|
style={{
|
||||||
minWidth: 50,
|
minWidth: 50,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col" {...props}>
|
<View className='flex flex-col' {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
{t("item_card.video")}
|
{t("item_card.video")}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
|
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'>
|
||||||
<Text numberOfLines={1}>{selectedName}</Text>
|
<Text numberOfLines={1}>{selectedName}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
loop={true}
|
loop={true}
|
||||||
side="bottom"
|
side='bottom'
|
||||||
align="start"
|
align='start'
|
||||||
alignOffset={0}
|
alignOffset={0}
|
||||||
avoidCollisions={true}
|
avoidCollisions={true}
|
||||||
collisionPadding={8}
|
collisionPadding={8}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import React from "react";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { View, ViewProps } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import type React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
actorId: string;
|
actorId: string;
|
||||||
@@ -63,9 +63,8 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
|||||||
const x = acc.find((item) => item.Id === current.Id);
|
const x = acc.find((item) => item.Id === current.Id);
|
||||||
if (!x) {
|
if (!x) {
|
||||||
return acc.concat([current]);
|
return acc.concat([current]);
|
||||||
} else {
|
|
||||||
return acc;
|
|
||||||
}
|
}
|
||||||
|
return acc;
|
||||||
}, [] as BaseItemDto[]) || [];
|
}, [] as BaseItemDto[]) || [];
|
||||||
|
|
||||||
return uniqueItems;
|
return uniqueItems;
|
||||||
@@ -77,8 +76,8 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className="text-lg font-bold mb-2 px-4">
|
<Text className='text-lg font-bold mb-2 px-4'>
|
||||||
{t("item_card.more_with", {name: actor?.Name})}
|
{t("item_card.more_with", { name: actor?.Name })}
|
||||||
</Text>
|
</Text>
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={items}
|
data={items}
|
||||||
@@ -88,7 +87,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
|||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
key={idx}
|
key={idx}
|
||||||
item={item}
|
item={item}
|
||||||
className="flex flex-col w-28"
|
className='flex flex-col w-28'
|
||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
<MoviePoster item={item} />
|
<MoviePoster item={item} />
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { tc } from "@/utils/textTools";
|
import { tc } from "@/utils/textTools";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
text?: string | null;
|
text?: string | null;
|
||||||
@@ -20,20 +20,22 @@ export const OverviewText: React.FC<Props> = ({
|
|||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col" {...props}>
|
<View className='flex flex-col' {...props}>
|
||||||
<Text className="text-lg font-bold mb-2">{t("item_card.overview")}</Text>
|
<Text className='text-lg font-bold mb-2'>{t("item_card.overview")}</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
setLimit((prev) =>
|
setLimit((prev) =>
|
||||||
prev === characterLimit ? text.length : characterLimit
|
prev === characterLimit ? text.length : characterLimit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
<Text>{tc(text, limit)}</Text>
|
<Text>{tc(text, limit)}</Text>
|
||||||
{text.length > characterLimit && (
|
{text.length > characterLimit && (
|
||||||
<Text className="text-purple-600 mt-1">
|
<Text className='text-purple-600 mt-1'>
|
||||||
{limit === characterLimit ? t("item_card.show_more") : t("item_card.show_less")}
|
{limit === characterLimit
|
||||||
|
? t("item_card.show_more")
|
||||||
|
: t("item_card.show_less")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { type PropsWithChildren, type ReactElement } from "react";
|
import type { PropsWithChildren, ReactElement } from "react";
|
||||||
import {NativeScrollEvent, NativeSyntheticEvent, View, ViewProps} from "react-native";
|
import {
|
||||||
|
type NativeScrollEvent,
|
||||||
|
NativeSyntheticEvent,
|
||||||
|
View,
|
||||||
|
type ViewProps,
|
||||||
|
} from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
interpolate,
|
interpolate,
|
||||||
useAnimatedRef,
|
useAnimatedRef,
|
||||||
@@ -35,36 +40,40 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
translateY: interpolate(
|
translateY: interpolate(
|
||||||
scrollOffset.value,
|
scrollOffset.value,
|
||||||
[-headerHeight, 0, headerHeight],
|
[-headerHeight, 0, headerHeight],
|
||||||
[-headerHeight / 2, 0, headerHeight * 0.75]
|
[-headerHeight / 2, 0, headerHeight * 0.75],
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scale: interpolate(
|
scale: interpolate(
|
||||||
scrollOffset.value,
|
scrollOffset.value,
|
||||||
[-headerHeight, 0, headerHeight],
|
[-headerHeight, 0, headerHeight],
|
||||||
[2, 1, 1]
|
[2, 1, 1],
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function isCloseToBottom({
|
||||||
function isCloseToBottom({layoutMeasurement, contentOffset, contentSize}: NativeScrollEvent) {
|
layoutMeasurement,
|
||||||
return layoutMeasurement.height + contentOffset.y >= contentSize.height - 20;
|
contentOffset,
|
||||||
|
contentSize,
|
||||||
|
}: NativeScrollEvent) {
|
||||||
|
return (
|
||||||
|
layoutMeasurement.height + contentOffset.y >= contentSize.height - 20
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1" {...props}>
|
<View className='flex-1' {...props}>
|
||||||
<Animated.ScrollView
|
<Animated.ScrollView
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
onScroll={e => {
|
onScroll={(e) => {
|
||||||
if (isCloseToBottom(e.nativeEvent))
|
if (isCloseToBottom(e.nativeEvent)) onEndReached?.();
|
||||||
onEndReached?.()
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{logo && (
|
{logo && (
|
||||||
@@ -73,7 +82,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
top: headerHeight - 200,
|
top: headerHeight - 200,
|
||||||
height: 130,
|
height: 130,
|
||||||
}}
|
}}
|
||||||
className="absolute left-0 w-full z-40 px-4 flex justify-center items-center"
|
className='absolute left-0 w-full z-40 px-4 flex justify-center items-center'
|
||||||
>
|
>
|
||||||
{logo}
|
{logo}
|
||||||
</View>
|
</View>
|
||||||
@@ -95,7 +104,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
style={{
|
style={{
|
||||||
top: -50,
|
top: -50,
|
||||||
}}
|
}}
|
||||||
className="relative flex-1 bg-transparent pb-24"
|
className='relative flex-1 bg-transparent pb-24'
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
// Background Linear Gradient
|
// Background Linear Gradient
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import React from "react";
|
import type React from "react";
|
||||||
import { Platform, View, ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
blurAmount?: number;
|
blurAmount?: number;
|
||||||
blurType?: "light" | "dark" | "xlight";
|
blurType?: "light" | "dark" | "xlight";
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { Platform } from "react-native";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import ios from "@/utils/profiles/ios";
|
import { chromecast } from "@/utils/profiles/chromecast";
|
||||||
|
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, Pressable } from "react-native";
|
||||||
import { Alert, TouchableOpacity, View } from "react-native";
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
CastButton,
|
||||||
@@ -30,13 +33,8 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Button } from "./Button";
|
import type { Button } from "./Button";
|
||||||
import { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
const chromecastProfile = !Platform.isTV
|
|
||||||
? require("@/utils/profiles/chromecast")
|
|
||||||
: null;
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -68,17 +66,21 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const startColor = useSharedValue(colorAtom);
|
const startColor = useSharedValue(colorAtom);
|
||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const [settings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
(q: string, bitrateValue: number | undefined) => {
|
(q: string) => {
|
||||||
|
if (settings.maxAutoPlayEpisodeCount.value !== -1) {
|
||||||
|
updateSettings({ autoPlayEpisodeCount: 0 });
|
||||||
|
}
|
||||||
router.push(`/player/direct-player?${q}`);
|
router.push(`/player/direct-player?${q}`);
|
||||||
},
|
},
|
||||||
[router]
|
[router],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
|
console.log("onPress");
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
@@ -94,7 +96,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const queryString = queryParams.toString();
|
const queryString = queryParams.toString();
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
goToPlayer(queryString);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,16 +115,19 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
switch (selectedIndex) {
|
switch (selectedIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
if (!Platform.isTV) {
|
await CastContext.getPlayServicesState().then(async (state) => {
|
||||||
await CastContext.getPlayServicesState().then(async (state) => {
|
if (state && state !== PlayServicesState.SUCCESS) {
|
||||||
if (state && state !== PlayServicesState.SUCCESS)
|
CastContext.showPlayServicesErrorDialog(state);
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
} else {
|
||||||
else {
|
// Check if user wants H265 for Chromecast
|
||||||
// Get a new URL with the Chromecast device profile:
|
const enableH265 = settings.enableH265ForChromecast;
|
||||||
|
|
||||||
|
// Get a new URL with the Chromecast device profile
|
||||||
|
try {
|
||||||
const data = await getStreamUrl({
|
const data = await getStreamUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
deviceProfile: chromecastProfile,
|
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
audioStreamIndex: selectedOptions.audioIndex,
|
audioStreamIndex: selectedOptions.audioIndex,
|
||||||
@@ -131,11 +136,13 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("URL: ", data?.url, enableH265);
|
||||||
|
|
||||||
if (!data?.url) {
|
if (!data?.url) {
|
||||||
console.warn("No URL returned from getStreamUrl", data);
|
console.warn("No URL returned from getStreamUrl", data);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.client_error"),
|
t("player.client_error"),
|
||||||
t("player.could_not_create_stream_for_chromecast")
|
t("player.could_not_create_stream_for_chromecast"),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -165,36 +172,36 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
: item.Type === "Movie"
|
: item.Type === "Movie"
|
||||||
? {
|
? {
|
||||||
type: "movie",
|
type: "movie",
|
||||||
title: item.Name || "",
|
title: item.Name || "",
|
||||||
subtitle: item.Overview || "",
|
subtitle: item.Overview || "",
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: getPrimaryImageUrl({
|
url: getPrimaryImageUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
quality: 90,
|
quality: 90,
|
||||||
width: 2000,
|
width: 2000,
|
||||||
})!,
|
})!,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
type: "generic",
|
type: "generic",
|
||||||
title: item.Name || "",
|
title: item.Name || "",
|
||||||
subtitle: item.Overview || "",
|
subtitle: item.Overview || "",
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: getPrimaryImageUrl({
|
url: getPrimaryImageUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
quality: 90,
|
quality: 90,
|
||||||
width: 2000,
|
width: 2000,
|
||||||
})!,
|
})!,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
startTime: 0,
|
startTime: 0,
|
||||||
})
|
})
|
||||||
@@ -205,17 +212,19 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
CastContext.showExpandedControls();
|
CastContext.showExpandedControls();
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
});
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
goToPlayer(queryString);
|
||||||
break;
|
break;
|
||||||
case cancelButtonIndex:
|
case cancelButtonIndex:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
item,
|
item,
|
||||||
@@ -232,11 +241,11 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
if (!item || !item.RunTimeTicks) return 0;
|
if (!item || !item.RunTimeTicks) return 0;
|
||||||
const userData = item.UserData;
|
const userData = item.UserData;
|
||||||
if (userData && userData.PlaybackPositionTicks) {
|
if (userData?.PlaybackPositionTicks) {
|
||||||
return userData.PlaybackPositionTicks > 0
|
return userData.PlaybackPositionTicks > 0
|
||||||
? Math.max(
|
? Math.max(
|
||||||
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
||||||
MIN_PLAYBACK_WIDTH
|
MIN_PLAYBACK_WIDTH,
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
}
|
}
|
||||||
@@ -253,7 +262,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[item]
|
[item],
|
||||||
);
|
);
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
@@ -266,7 +275,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[colorAtom]
|
[colorAtom],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -287,7 +296,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
backgroundColor: interpolateColor(
|
backgroundColor: interpolateColor(
|
||||||
colorChangeProgress.value,
|
colorChangeProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startColor.value.primary, endColor.value.primary]
|
[startColor.value.primary, endColor.value.primary],
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -295,7 +304,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
backgroundColor: interpolateColor(
|
backgroundColor: interpolateColor(
|
||||||
colorChangeProgress.value,
|
colorChangeProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startColor.value.primary, endColor.value.primary]
|
[startColor.value.primary, endColor.value.primary],
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -303,7 +312,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
width: `${interpolate(
|
width: `${interpolate(
|
||||||
widthProgress.value,
|
widthProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startWidth.value, targetWidth.value]
|
[startWidth.value, targetWidth.value],
|
||||||
)}%`,
|
)}%`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -311,7 +320,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
color: interpolateColor(
|
color: interpolateColor(
|
||||||
colorChangeProgress.value,
|
colorChangeProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startColor.value.text, endColor.value.text]
|
[startColor.value.text, endColor.value.text],
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
/**
|
/**
|
||||||
@@ -319,75 +328,62 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<TouchableOpacity
|
||||||
<TouchableOpacity
|
disabled={!item}
|
||||||
disabled={!item}
|
accessibilityLabel='Play button'
|
||||||
accessibilityLabel="Play button"
|
accessibilityHint='Tap to play the media'
|
||||||
accessibilityHint="Tap to play the media"
|
onPress={onPress}
|
||||||
onPress={onPress}
|
className={"relative"}
|
||||||
className={`relative`}
|
{...props}
|
||||||
{...props}
|
>
|
||||||
>
|
<View className='absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden'>
|
||||||
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
animatedPrimaryStyle,
|
|
||||||
animatedWidthStyle,
|
|
||||||
{
|
|
||||||
height: "100%",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
style={[
|
||||||
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
animatedPrimaryStyle,
|
||||||
|
animatedWidthStyle,
|
||||||
|
{
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<View
|
</View>
|
||||||
style={{
|
|
||||||
borderWidth: 1,
|
<Animated.View
|
||||||
borderColor: colorAtom.primary,
|
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||||
borderStyle: "solid",
|
className='absolute w-full h-full top-0 left-0 rounded-xl'
|
||||||
}}
|
/>
|
||||||
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
<View
|
||||||
>
|
style={{
|
||||||
<View className="flex flex-row items-center space-x-2">
|
borderWidth: 1,
|
||||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
borderColor: colorAtom.primary,
|
||||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
borderStyle: "solid",
|
||||||
</Animated.Text>
|
}}
|
||||||
|
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
|
||||||
|
>
|
||||||
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
|
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||||
|
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||||
|
</Animated.Text>
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<Ionicons name='play-circle' size={24} />
|
||||||
|
</Animated.Text>
|
||||||
|
{client && (
|
||||||
<Animated.Text style={animatedTextStyle}>
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<Ionicons name="play-circle" size={24} />
|
<Feather name='cast' size={22} />
|
||||||
|
<CastButton tintColor='transparent' />
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
{client && (
|
)}
|
||||||
<Animated.Text style={animatedTextStyle}>
|
{!client && settings?.openInVLC && (
|
||||||
<Feather name="cast" size={22} />
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<CastButton tintColor="transparent" />
|
<MaterialCommunityIcons
|
||||||
</Animated.Text>
|
name='vlc'
|
||||||
)}
|
size={18}
|
||||||
{!client && settings?.openInVLC && (
|
color={animatedTextStyle.color}
|
||||||
<Animated.Text style={animatedTextStyle}>
|
/>
|
||||||
<MaterialCommunityIcons
|
</Animated.Text>
|
||||||
name="vlc"
|
)}
|
||||||
size={18}
|
|
||||||
color={animatedTextStyle.color}
|
|
||||||
/>
|
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
{/* <View className="mt-2 flex flex-row items-center">
|
</TouchableOpacity>
|
||||||
<Ionicons
|
|
||||||
name="information-circle"
|
|
||||||
size={12}
|
|
||||||
className=""
|
|
||||||
color={"#9BA1A6"}
|
|
||||||
/>
|
|
||||||
<Text className="text-neutral-500 ml-1">
|
|
||||||
{directStream ? "Direct stream" : "Transcoded stream"}
|
|
||||||
</Text>
|
|
||||||
</View> */}
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { Platform } from "react-native";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import { Alert, TouchableOpacity, View } from "react-native";
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -20,10 +22,8 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Button } from "./Button";
|
import type { Button } from "./Button";
|
||||||
import { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -57,13 +57,14 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
(q: string, bitrateValue: number | undefined) => {
|
(q: string) => {
|
||||||
router.push(`/player/direct-player?${q}`);
|
router.push(`/player/direct-player?${q}`);
|
||||||
},
|
},
|
||||||
[router]
|
[router],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const onPress = () => {
|
||||||
|
console.log("onpress");
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
@@ -77,26 +78,18 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const queryString = queryParams.toString();
|
const queryString = queryParams.toString();
|
||||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
goToPlayer(queryString);
|
||||||
return;
|
return;
|
||||||
}, [
|
};
|
||||||
item,
|
|
||||||
settings,
|
|
||||||
api,
|
|
||||||
user,
|
|
||||||
router,
|
|
||||||
showActionSheetWithOptions,
|
|
||||||
selectedOptions,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
if (!item || !item.RunTimeTicks) return 0;
|
if (!item || !item.RunTimeTicks) return 0;
|
||||||
const userData = item.UserData;
|
const userData = item.UserData;
|
||||||
if (userData && userData.PlaybackPositionTicks) {
|
if (userData?.PlaybackPositionTicks) {
|
||||||
return userData.PlaybackPositionTicks > 0
|
return userData.PlaybackPositionTicks > 0
|
||||||
? Math.max(
|
? Math.max(
|
||||||
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
||||||
MIN_PLAYBACK_WIDTH
|
MIN_PLAYBACK_WIDTH,
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
}
|
}
|
||||||
@@ -113,7 +106,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[item]
|
[item],
|
||||||
);
|
);
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
@@ -126,7 +119,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[colorAtom]
|
[colorAtom],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -147,7 +140,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
backgroundColor: interpolateColor(
|
backgroundColor: interpolateColor(
|
||||||
colorChangeProgress.value,
|
colorChangeProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startColor.value.primary, endColor.value.primary]
|
[startColor.value.primary, endColor.value.primary],
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -155,7 +148,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
backgroundColor: interpolateColor(
|
backgroundColor: interpolateColor(
|
||||||
colorChangeProgress.value,
|
colorChangeProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startColor.value.primary, endColor.value.primary]
|
[startColor.value.primary, endColor.value.primary],
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -163,7 +156,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
width: `${interpolate(
|
width: `${interpolate(
|
||||||
widthProgress.value,
|
widthProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startWidth.value, targetWidth.value]
|
[startWidth.value, targetWidth.value],
|
||||||
)}%`,
|
)}%`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -171,7 +164,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
color: interpolateColor(
|
color: interpolateColor(
|
||||||
colorChangeProgress.value,
|
colorChangeProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startColor.value.text, endColor.value.text]
|
[startColor.value.text, endColor.value.text],
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
/**
|
/**
|
||||||
@@ -179,69 +172,55 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<TouchableOpacity
|
||||||
<TouchableOpacity
|
accessibilityLabel='Play button'
|
||||||
disabled={!item}
|
accessibilityHint='Tap to play the media'
|
||||||
accessibilityLabel="Play button"
|
onPress={onPress}
|
||||||
accessibilityHint="Tap to play the media"
|
className={"relative"}
|
||||||
onPress={onPress}
|
{...props}
|
||||||
className={`relative`}
|
>
|
||||||
{...props}
|
<View className='absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden'>
|
||||||
>
|
|
||||||
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
animatedPrimaryStyle,
|
|
||||||
animatedWidthStyle,
|
|
||||||
{
|
|
||||||
height: "100%",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
style={[
|
||||||
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
animatedPrimaryStyle,
|
||||||
|
animatedWidthStyle,
|
||||||
|
{
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<View
|
</View>
|
||||||
style={{
|
|
||||||
borderWidth: 1,
|
<Animated.View
|
||||||
borderColor: colorAtom.primary,
|
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||||
borderStyle: "solid",
|
className='absolute w-full h-full top-0 left-0 rounded-xl'
|
||||||
}}
|
/>
|
||||||
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
<View
|
||||||
>
|
style={{
|
||||||
<View className="flex flex-row items-center space-x-2">
|
borderWidth: 1,
|
||||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
borderColor: colorAtom.primary,
|
||||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
borderStyle: "solid",
|
||||||
</Animated.Text>
|
}}
|
||||||
|
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
|
||||||
|
>
|
||||||
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
|
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||||
|
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||||
|
</Animated.Text>
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<Ionicons name='play-circle' size={24} />
|
||||||
|
</Animated.Text>
|
||||||
|
{settings?.openInVLC && (
|
||||||
<Animated.Text style={animatedTextStyle}>
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<Ionicons name="play-circle" size={24} />
|
<MaterialCommunityIcons
|
||||||
|
name='vlc'
|
||||||
|
size={18}
|
||||||
|
color={animatedTextStyle.color}
|
||||||
|
/>
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
{settings?.openInVLC && (
|
)}
|
||||||
<Animated.Text style={animatedTextStyle}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="vlc"
|
|
||||||
size={18}
|
|
||||||
color={animatedTextStyle.color}
|
|
||||||
/>
|
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
{/* <View className="mt-2 flex flex-row items-center">
|
</TouchableOpacity>
|
||||||
<Ionicons
|
|
||||||
name="information-circle"
|
|
||||||
size={12}
|
|
||||||
className=""
|
|
||||||
color={"#9BA1A6"}
|
|
||||||
/>
|
|
||||||
<Text className="text-neutral-500 ml-1">
|
|
||||||
{directStream ? "Direct stream" : "Transcoded stream"}
|
|
||||||
</Text>
|
|
||||||
</View> */}
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
194
components/PlayInRemoteSession.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { useAllSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
type BaseItemDto,
|
||||||
|
PlayCommand,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
FlatList,
|
||||||
|
Modal,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Loader } from "./Loader";
|
||||||
|
import { RoundButton } from "./RoundButton";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
|
item: BaseItemDto;
|
||||||
|
size?: "default" | "large";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
||||||
|
const handlePlayInSession = async (sessionId: string) => {
|
||||||
|
if (!api || !item.Id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Playing ${item.Name} in session ${sessionId}`);
|
||||||
|
getSessionApi(api).play({
|
||||||
|
sessionId,
|
||||||
|
itemIds: [item.Id],
|
||||||
|
playCommand: PlayCommand.PlayNow,
|
||||||
|
});
|
||||||
|
|
||||||
|
setModalVisible(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error playing in remote session:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<RoundButton
|
||||||
|
icon='play-circle-outline'
|
||||||
|
onPress={() => setModalVisible(true)}
|
||||||
|
size={props.size}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
animationType='slide'
|
||||||
|
transparent={true}
|
||||||
|
visible={modalVisible}
|
||||||
|
onRequestClose={() => setModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.centeredView}>
|
||||||
|
<View style={styles.modalView}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<Text style={styles.modalTitle}>Select Session</Text>
|
||||||
|
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
||||||
|
<Ionicons name='close' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : !sessions || sessions.length === 0 ? (
|
||||||
|
<Text style={styles.noSessionsText}>
|
||||||
|
No active sessions found
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={sessions}
|
||||||
|
keyExtractor={(session) => session.Id || "unknown"}
|
||||||
|
renderItem={({ item: session }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.sessionItem}
|
||||||
|
onPress={() => handlePlayInSession(session.Id || "")}
|
||||||
|
>
|
||||||
|
<View style={styles.sessionInfo}>
|
||||||
|
<Text style={styles.sessionName}>
|
||||||
|
{session.DeviceName}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.sessionDetails}>
|
||||||
|
{session.UserName} • {session.Client}
|
||||||
|
</Text>
|
||||||
|
{session.NowPlayingItem && (
|
||||||
|
<Text style={styles.nowPlaying} numberOfLines={1}>
|
||||||
|
Now playing:{" "}
|
||||||
|
{session.NowPlayingItem.SeriesName
|
||||||
|
? `${session.NowPlayingItem.SeriesName} :`
|
||||||
|
: ""}
|
||||||
|
{session.NowPlayingItem.Name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Ionicons name='play-sharp' size={20} color='#888' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
centeredView: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||||
|
},
|
||||||
|
modalView: {
|
||||||
|
width: "90%",
|
||||||
|
maxHeight: "80%",
|
||||||
|
backgroundColor: "#1c1c1c",
|
||||||
|
borderRadius: 20,
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#333",
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
padding: 40,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
noSessionsText: {
|
||||||
|
padding: 40,
|
||||||
|
textAlign: "center",
|
||||||
|
color: "#888",
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
sessionItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#333",
|
||||||
|
},
|
||||||
|
sessionInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
sessionName: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "500",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
sessionDetails: {
|
||||||
|
fontSize: 13,
|
||||||
|
opacity: 0.7,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
nowPlaying: {
|
||||||
|
fontSize: 12,
|
||||||
|
opacity: 0.5,
|
||||||
|
fontStyle: "italic",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import React from "react";
|
import type React from "react";
|
||||||
import { View, ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -18,7 +18,7 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["item", item.Id],
|
queryKey: ["item", item.Id],
|
||||||
});
|
});
|
||||||
})
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["resumeItems"],
|
queryKey: ["resumeItems"],
|
||||||
});
|
});
|
||||||
@@ -51,9 +51,9 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
|||||||
<RoundButton
|
<RoundButton
|
||||||
fillColor={allPlayed ? "primary" : undefined}
|
fillColor={allPlayed ? "primary" : undefined}
|
||||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
console.log(allPlayed);
|
console.log(allPlayed);
|
||||||
await markAsPlayedStatus(!allPlayed)
|
await markAsPlayedStatus(!allPlayed);
|
||||||
}}
|
}}
|
||||||
size={props.size}
|
size={props.size}
|
||||||
/>
|
/>
|
||||||
|
|||||||