Compare commits
80 Commits
feature/of
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 68ee4405bc | |||
| a7b06b8773 | |||
| 405503c3e8 | |||
| b774b3ceab | |||
| 3f292d2d91 | |||
| 91ea6301cf | |||
|
|
7cab50750f | ||
|
|
d795e82581 | ||
|
|
e7161bc9ab | ||
|
|
8e74363f32 | ||
|
|
1cb28788d6 | ||
|
|
ff9f855d4c | ||
|
|
13df2d1077 | ||
|
|
8389404975 | ||
|
|
cd920e2d84 | ||
|
|
92a11c18e0 | ||
|
|
e05f10fe42 | ||
|
|
2540ae22ce | ||
|
|
f490957091 | ||
|
|
a146fc8810 | ||
|
|
100d7e0830 | ||
|
|
ebcdd5bbf7 | ||
|
|
18b33884e6 | ||
|
|
9410239c48 | ||
|
|
4fed25a3ab | ||
|
|
a8810cae8a | ||
|
|
aff009de92 | ||
|
|
1924efbef2 | ||
|
|
3b53d76a18 | ||
|
|
b7221e5599 | ||
|
|
5384c34b27 | ||
|
|
ca92f61900 | ||
|
|
4fba558c33 | ||
|
|
d82767f5df | ||
|
|
e56fc93b14 | ||
|
|
1e399297bd | ||
|
|
feaf82fa3f | ||
|
|
781d199546 | ||
|
|
3013251285 | ||
|
|
0e1ed71dc1 | ||
|
|
5a781ba62c | ||
|
|
0cea614423 | ||
|
|
24d006742b | ||
|
|
c7f0c2ec83 | ||
|
|
c34c7fbe83 | ||
|
|
57bbb59874 | ||
|
|
e90d2e2244 | ||
|
|
917dabc4be | ||
|
|
bc2defc8ef | ||
|
|
3ce1480e10 | ||
|
|
9597b40726 | ||
|
|
1e6408d5be | ||
|
|
c2f6897f47 | ||
|
|
eaf3682384 | ||
|
|
f3c7b636a8 | ||
|
|
64d34a9354 | ||
|
|
2a2ecf0526 | ||
|
|
a77c7e8e3c | ||
|
|
88791eccf9 | ||
|
|
515f7ea26d | ||
|
|
e83bbf3121 | ||
|
|
89b34eddc1 | ||
|
|
89fd7f0e34 | ||
|
|
ab9ae5b620 | ||
|
|
a9c519971e | ||
|
|
e51b7351f8 | ||
|
|
e0f9d6ea1c | ||
|
|
1817c5dbd2 | ||
|
|
0619c8c9c4 | ||
|
|
d6ed318eb8 | ||
|
|
5f39622ad6 | ||
|
|
3b2a6bd40a | ||
|
|
8d3e165edf | ||
|
|
f3a9fc9d1c | ||
|
|
820af06419 | ||
|
|
80192e65c4 | ||
|
|
ff930e2ad2 | ||
|
|
fafc2e65ac | ||
|
|
61c783fb55 | ||
|
|
59df18621b |
14
.claude/settings.local.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(bun install:*)",
|
||||||
|
"Bash(bunx expo prebuild:*)",
|
||||||
|
"Bash(bunx expo run:*)",
|
||||||
|
"Bash(npx expo prebuild:*)",
|
||||||
|
"Bash(npx expo run:*)",
|
||||||
|
"Bash(xcodebuild:*)"
|
||||||
|
],
|
||||||
|
"deny": []
|
||||||
|
}
|
||||||
|
}
|
||||||
7
.cursor/rules/no-custom-ios-folder-logic.mdc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
description: Don't write code directly in the ios folder.
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
We never write code directly in the ios folder. This code is generated by expo plugins.
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["next/core-web-vitals"]
|
|
||||||
}
|
|
||||||
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -43,7 +43,7 @@ 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.1
|
- 0.29.0
|
||||||
- 0.28.0
|
- 0.28.0
|
||||||
- 0.27.0
|
- 0.27.0
|
||||||
- 0.26.1
|
- 0.26.1
|
||||||
|
|||||||
77
.github/workflows/build-android.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: 🤖 Android APK Build
|
name: 🤖 Android APK Build (Phone + TV)
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -12,68 +12,91 @@ on:
|
|||||||
branches: [develop, master]
|
branches: [develop, master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build-android:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
name: 🏗️ Build Android APK
|
name: 🏗️ Build Android APK
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
target: [phone, tv]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@v4.3.0 # v5.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
show-progress: false
|
|
||||||
submodules: recursive
|
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
with:
|
with:
|
||||||
bun-version: '1.2.17'
|
bun-version: latest
|
||||||
|
|
||||||
- name: ☕ Setup JDK
|
|
||||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
|
||||||
with:
|
|
||||||
distribution: 'zulu'
|
|
||||||
java-version: '17'
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
uses: actions/cache@v4.2.4 # v4.2.4
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-bun-cache-
|
${{ runner.os }}-${{ runner.arch }}-bun-develop
|
||||||
|
${{ runner.os }}-bun-develop
|
||||||
|
|
||||||
- name: 📦 Install dependencies
|
- name: 💾 Cache node_modules
|
||||||
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-${{ runner.arch }}-modules-latest-develop-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-modules-latest-develop
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-modules-develop
|
||||||
|
${{ runner.os }}-modules-develop
|
||||||
|
|
||||||
|
- name: 📦 Install dependencies and reload submodules
|
||||||
run: |
|
run: |
|
||||||
bun install --frozen-lockfile
|
bun install --frozen-lockfile
|
||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: 💾 Cache Android dependencies
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
android/.gradle
|
~/.gradle/caches
|
||||||
key: ${{ runner.os }}-android-deps-${{ hashFiles('android/**/build.gradle') }}
|
~/.gradle/wrapper
|
||||||
restore-keys: |
|
key: ${{ runner.os }}-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
${{ runner.os }}-android-deps-
|
restore-keys: ${{ runner.os }}-gradle-develop
|
||||||
|
|
||||||
- name: 🛠️ Generate project files
|
- name: 🛠️ Generate project files
|
||||||
run: bun run prebuild
|
run: |
|
||||||
|
if [ "${{ matrix.target }}" = "tv" ]; then
|
||||||
|
bun run prebuild:tv
|
||||||
|
else
|
||||||
|
bun run prebuild
|
||||||
|
fi
|
||||||
|
|
||||||
- name: 🚀 Build APK via Bun
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
|
with:
|
||||||
|
path: android/.gradle
|
||||||
|
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: ${{ runner.os }}-android-gradle-develop
|
||||||
|
|
||||||
|
- name: 🚀 Build APK
|
||||||
|
env:
|
||||||
|
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
|
||||||
run: bun run build:android:local
|
run: bun run build:android:local
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
- name: 📅 Set date tag
|
||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: 📤 Upload APK artifact
|
- name: 📤 Upload APK artifact
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@v3 # v4.6.2
|
||||||
with:
|
with:
|
||||||
name: streamyfin-apk-${{ env.DATE_TAG }}
|
name: streamyfin-android-${{ matrix.target }}-apk-${{ env.DATE_TAG }}
|
||||||
path: |
|
path: |
|
||||||
android/app/build/outputs/apk/release/*.apk
|
android/app/build/outputs/apk/release/*.apk
|
||||||
android/app/build/outputs/bundle/release/*.aab
|
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|||||||
55
.github/workflows/build-ios.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: 🤖 iOS IPA Build
|
name: 🤖 iOS IPA Build (Phone + TV)
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -12,51 +12,71 @@ on:
|
|||||||
branches: [develop, master]
|
branches: [develop, master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build-ios:
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
name: 🏗️ Build iOS IPA
|
name: 🏗️ Build iOS IPA
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
target: [phone, tv]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Check out repository
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
show-progress: false
|
|
||||||
submodules: recursive
|
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
with:
|
with:
|
||||||
bun-version: '1.2.17'
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-bun-cache-
|
${{ runner.os }}-bun-cache
|
||||||
|
|
||||||
- name: 📦 Install & Prepare
|
- name: 📦 Install dependencies and reload submodules
|
||||||
run: |
|
run: |
|
||||||
bun install --frozen-lockfile
|
bun install --frozen-lockfile
|
||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: 🛠️ Generate project files
|
- name: 🛠️ Generate project files
|
||||||
run: bun run prebuild
|
run: |
|
||||||
|
if [ "${{ matrix.target }}" = "tv" ]; then
|
||||||
|
bun run prebuild:tv
|
||||||
|
else
|
||||||
|
bun run prebuild
|
||||||
|
fi
|
||||||
|
|
||||||
- name: 🏗 Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@main
|
uses: expo/expo-github-action@main
|
||||||
with:
|
with:
|
||||||
eas-version: 16.7.1
|
eas-version: latest
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
- name: 🏗️ Build iOS app
|
- name: ⚙️ Ensure iOS/tvOS SDKs installed
|
||||||
run: |
|
run: |
|
||||||
eas build -p ios --local --non-interactive
|
if [ "${{ matrix.target }}" = "tv" ]; then
|
||||||
|
xcodebuild -downloadPlatform tvOS
|
||||||
|
else
|
||||||
|
xcodebuild -downloadPlatform iOS
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 🚀 Build iOS app
|
||||||
|
env:
|
||||||
|
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
|
||||||
|
run: eas build -p ios --local --non-interactive
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
- name: 📅 Set date tag
|
||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
@@ -64,7 +84,6 @@ jobs:
|
|||||||
- name: 📤 Upload IPA artifact
|
- name: 📤 Upload IPA artifact
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
with:
|
with:
|
||||||
name: streamyfin-ipa-${{ env.DATE_TAG }}
|
name: streamyfin-ios-${{ matrix.target }}-ipa-${{ env.DATE_TAG }}
|
||||||
path: |
|
path: build-*.ipa
|
||||||
build-*.ipa
|
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|||||||
6
.github/workflows/check-lockfile.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
show-progress: false
|
show-progress: false
|
||||||
@@ -29,10 +29,10 @@ jobs:
|
|||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
with:
|
with:
|
||||||
bun-version: '1.2.17'
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.bun/install/cache
|
~/.bun/install/cache
|
||||||
|
|||||||
10
.github/workflows/ci-codeql.yml
vendored
@@ -20,24 +20,24 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
language: [ 'javascript-typescript' ]
|
language: [ 'javascript-typescript', 'actions' ]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
show-progress: false
|
show-progress: false
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
uses: github/codeql-action/init@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended,security-and-quality
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
- name: 🛠️ Autobuild
|
||||||
uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
uses: github/codeql-action/autobuild@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
uses: github/codeql-action/analyze@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10
|
||||||
|
|||||||
44
.github/workflows/linting.yml
vendored
@@ -1,10 +1,12 @@
|
|||||||
name: 🚦 Security & Quality Gate
|
name: 🚦 Security & Quality Gate
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request:
|
||||||
types: [opened, edited, synchronize, reopened]
|
types: [opened, edited, synchronize, reopened]
|
||||||
branches: [develop, master]
|
branches: [develop, master]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [develop]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -12,17 +14,18 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
validate_pr_title:
|
validate_pr_title:
|
||||||
name: "📝 Validate PR Title"
|
name: "📝 Validate PR Title"
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3
|
- uses: amannn/action-semantic-pull-request@7f33ba792281b034f64e96f4c0b5496782dd3b37 # v6.1.0
|
||||||
id: lint_pr_title
|
id: lint_pr_title
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: marocchino/sticky-pull-request-comment@d2ad0de260ae8b0235ce059e63f2949ba9e05943 # v2.9.3
|
- uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||||
with:
|
with:
|
||||||
header: pr-title-lint-error
|
header: pr-title-lint-error
|
||||||
@@ -36,7 +39,7 @@ jobs:
|
|||||||
```
|
```
|
||||||
|
|
||||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||||
uses: marocchino/sticky-pull-request-comment@d2ad0de260ae8b0235ce059e63f2949ba9e05943 # v2.9.3
|
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||||
with:
|
with:
|
||||||
header: pr-title-lint-error
|
header: pr-title-lint-error
|
||||||
delete: true
|
delete: true
|
||||||
@@ -48,19 +51,41 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Dependency Review
|
- name: Dependency Review
|
||||||
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
|
uses: actions/dependency-review-action@bc41886e18ea39df68b1b1245f4184881938e050 # v4.7.2
|
||||||
with:
|
with:
|
||||||
fail-on-severity: high
|
fail-on-severity: high
|
||||||
deny-licenses: GPL-3.0, AGPL-3.0
|
deny-licenses: GPL-3.0, AGPL-3.0
|
||||||
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
||||||
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||||
|
|
||||||
|
expo-doctor:
|
||||||
|
name: 🚑 Expo Doctor Check
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: 🛒 Checkout repository
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
submodules: recursive
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: 🍞 Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: 📦 Install dependencies (bun)
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: 🚑 Run Expo Doctor
|
||||||
|
run: bun expo-doctor
|
||||||
|
|
||||||
code_quality:
|
code_quality:
|
||||||
name: "🔍 Lint & Test (${{ matrix.command }})"
|
name: "🔍 Lint & Test (${{ matrix.command }})"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -70,9 +95,10 @@ jobs:
|
|||||||
command:
|
command:
|
||||||
- "lint"
|
- "lint"
|
||||||
- "check"
|
- "check"
|
||||||
|
- "format"
|
||||||
steps:
|
steps:
|
||||||
- name: "📥 Checkout PR code"
|
- name: "📥 Checkout PR code"
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
@@ -81,12 +107,12 @@ jobs:
|
|||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: '22.x'
|
node-version: '24.x'
|
||||||
|
|
||||||
- name: "🍞 Setup Bun"
|
- name: "🍞 Setup Bun"
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
with:
|
with:
|
||||||
bun-version: '1.2.17'
|
bun-version: latest
|
||||||
|
|
||||||
- name: "📦 Install dependencies"
|
- name: "📦 Install dependencies"
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|||||||
2
.github/workflows/notification.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: 🛎️ Notify Discord
|
- name: 🛎️ Notify Discord
|
||||||
uses: Ilshidur/action-discord@0c4b27844ba47cb1c7bee539c8eead5284ce9fa9 # 0.3.2
|
uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
|
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
|
||||||
|
|||||||
2
.gitignore
vendored
@@ -10,7 +10,6 @@ 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
|
||||||
@@ -46,3 +45,4 @@ streamyfin-4fec1-firebase-adminsdk.json
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
*.aab
|
*.aab
|
||||||
|
/version-backup-*
|
||||||
|
|||||||
82
README.md
@@ -1,15 +1,24 @@
|
|||||||
# 📺 Streamyfin
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
A simple and user-friendly Jellyfin video streaming client built with Expo. If you are looking for an alternative to other Jellyfin clients, we hope you find Streamyfin a useful addition to your media streaming toolbox.
|
|
||||||
|
|
||||||
<div style="display: flex; flex-direction: row; gap: 8px">
|
<p align="center">
|
||||||
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
|
||||||
<img width=150 src="./assets/images/screenshots/screenshot3.png" />
|
</p>
|
||||||
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
|
|
||||||
<img width=159 src="./assets/images/jellyseerr.PNG"/>
|
**Streamyfin is a simple, user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
|
||||||
</div>
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="./assets/images/screenshots/screenshot1.png" width="22%">
|
||||||
|
|
||||||
|
<img src="./assets/images/screenshots/screenshot3.png" width="22%">
|
||||||
|
|
||||||
|
<img src="./assets/images/screenshots/screenshot2.png" width="22%">
|
||||||
|
|
||||||
|
<img src="./assets/images/jellyseerr.PNG" width="23%">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
|
|
||||||
@@ -47,7 +56,7 @@ The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that ho
|
|||||||
|
|
||||||
### 🔍 Jellysearch
|
### 🔍 Jellysearch
|
||||||
|
|
||||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀
|
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin!
|
||||||
|
|
||||||
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
||||||
|
|
||||||
@@ -107,13 +116,13 @@ Key points of the MPL-2.0:
|
|||||||
- You must disclose your source code for any modifications to the covered files
|
- You must disclose your source code for any modifications to the covered files
|
||||||
- Larger works may combine MPL code with code under other licenses
|
- Larger works may combine MPL code with code under other licenses
|
||||||
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
|
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
|
||||||
- For the full text of the license, please see the LICENSE file in this repository.
|
- For the full text of the license, please see the LICENSE file in this repository
|
||||||
|
|
||||||
## 🌐 Connect with Us
|
## 🌐 Connect with Us
|
||||||
|
|
||||||
Join our Discord: [https://discord.gg/aJvAYeycyY](https://discord.gg/aJvAYeycyY)
|
Join our Discord: [](https://discord.gg/BuGG9ZNhaE)
|
||||||
|
|
||||||
If you have questions or need support, feel free to reach out:
|
Need support or have questions:
|
||||||
|
|
||||||
- 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)
|
||||||
@@ -139,77 +148,86 @@ Special shoutout to the JF official clients for being an inspiration to ours.
|
|||||||
|
|
||||||
Thanks to the following contributors for their significant contributions:
|
Thanks to the following contributors for their significant contributions:
|
||||||
|
|
||||||
|
<div align="left">
|
||||||
<table>
|
<table>
|
||||||
<tr
|
<tr>
|
||||||
style="
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-around;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/Alexk2309">
|
<a href="https://github.com/Alexk2309">
|
||||||
<img src="https://github.com/Alexk2309.png?size=80" width="80" style="border-radius: 50%;" />
|
<img src="https://github.com/Alexk2309.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
<br /><sub><b>@Alexk2309</b></sub>
|
<br /><sub><b>@Alexk2309</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/herrrta">
|
<a href="https://github.com/herrrta">
|
||||||
<img src="https://github.com/herrrta.png?size=80" width="80" style="border-radius: 50%;" />
|
<img src="https://github.com/herrrta.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
<br /><sub><b>@herrrta</b></sub>
|
<br /><sub><b>@herrrta</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/lostb1t">
|
<a href="https://github.com/lostb1t">
|
||||||
<img src="https://github.com/lostb1t.png?size=80" width="80" style="border-radius: 50%;" />
|
<img src="https://github.com/lostb1t.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
<br /><sub><b>@lostb1t</b></sub>
|
<br /><sub><b>@lostb1t</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/Simon-Eklundh">
|
<a href="https://github.com/Simon-Eklundh">
|
||||||
<img src="https://github.com/Simon-Eklundh.png?size=80" width="80" style="border-radius: 50%;" />
|
<img src="https://github.com/Simon-Eklundh.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
<br /><sub><b>@Simon-Eklundh</b></sub>
|
<br /><sub><b>@Simon-Eklundh</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/topiga">
|
<a href="https://github.com/topiga">
|
||||||
<img src="https://github.com/topiga.png?size=80" width="80" style="border-radius: 50%;" />
|
<img src="https://github.com/topiga.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
<br /><sub><b>@topiga</b></sub>
|
<br /><sub><b>@topiga</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/lancechant">
|
||||||
|
<img src="https://github.com/lancechant.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@lancechant</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/simoncaron">
|
<a href="https://github.com/simoncaron">
|
||||||
<img src="https://github.com/simoncaron.png?size=80" width="80" style="border-radius: 50%;" />
|
<img src="https://github.com/simoncaron.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
<br /><sub><b>@simoncaron</b></sub>
|
<br /><sub><b>@simoncaron</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/jakequade">
|
<a href="https://github.com/jakequade">
|
||||||
<img src="https://github.com/jakequade.png?size=80" width="80" style="border-radius: 50%;" />
|
<img src="https://github.com/jakequade.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
<br /><sub><b>@jakequade</b></sub>
|
<br /><sub><b>@jakequade</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/Ryan0204">
|
<a href="https://github.com/Ryan0204">
|
||||||
<img src="https://github.com/Ryan0204.png?size=80" width="80" style="border-radius: 50%;" />
|
<img src="https://github.com/Ryan0204.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
<br /><sub><b>@Ryan0204</b></sub>
|
<br /><sub><b>@Ryan0204</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/retardgerman">
|
<a href="https://github.com/retardgerman">
|
||||||
<img src="https://github.com/retardgerman.png?size=80" width="80" style="border-radius: 50%;" />
|
<img src="https://github.com/retardgerman.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
<br /><sub><b>@retardgerman</b></sub>
|
<br /><sub><b>@retardgerman</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/whoopsi-daisy">
|
<a href="https://github.com/whoopsi-daisy">
|
||||||
<img src="https://github.com/whoopsi-daisy.png?size=80" width="80" style="border-radius: 50%;" />
|
<img src="https://github.com/whoopsi-daisy.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
<br /><sub><b>@whoopsi-daisy</b></sub>
|
<br /><sub><b>@whoopsi-daisy</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Gauvino">
|
||||||
|
<img src="https://github.com/Gauvino.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@Gauvino</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
And all other developers who have contributed to Streamyfin, thank you for your contributions.
|
And all other developers who have contributed to Streamyfin, thank you for your contributions.
|
||||||
|
|
||||||
@@ -228,4 +246,4 @@ I'd also like to thank the following people and projects for their contributions
|
|||||||
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions or support requests related to piracy are strictly prohibited across all our channels.
|
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions or support requests related to piracy are strictly prohibited across all our channels.
|
||||||
|
|
||||||
## 🤝 Sponsorship
|
## 🤝 Sponsorship
|
||||||
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur)
|
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ module.exports = ({ config }) => {
|
|||||||
"react-native-google-cast",
|
"react-native-google-cast",
|
||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Add the background downloader plugin only for non-TV builds
|
||||||
|
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
android: {
|
android: {
|
||||||
|
|||||||
27
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.28.1",
|
"version": "0.32.1",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -29,18 +29,19 @@
|
|||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||||
"icon": {
|
"icon": {
|
||||||
"dark": "./assets/images/icon-plain.png",
|
"dark": "./assets/images/icon-ios-plain.png",
|
||||||
"light": "./assets/images/icon-ios-light.png",
|
"light": "./assets/images/icon-ios-light.png",
|
||||||
"tinted": "./assets/images/icon-ios-tinted.png"
|
"tinted": "./assets/images/icon-ios-tinted.png"
|
||||||
}
|
},
|
||||||
|
"appleTeamId": "MWD5K362T8"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 56,
|
"versionCode": 62,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-mono.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
"backgroundColor": "#464646"
|
"backgroundColor": "#2E2E2E"
|
||||||
},
|
},
|
||||||
"package": "com.fredrikburmester.streamyfin",
|
"package": "com.fredrikburmester.streamyfin",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
@@ -113,17 +114,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
["react-native-bottom-tabs"],
|
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||||
["./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",
|
||||||
{
|
{
|
||||||
"backgroundColor": "#2e2e2e",
|
"backgroundColor": "#2e2e2e",
|
||||||
"image": "./assets/images/StreamyFinFinal.png",
|
"image": "./assets/images/icon-ios-plain.png",
|
||||||
"imageWidth": 100
|
"imageWidth": 100
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -134,12 +133,8 @@
|
|||||||
"color": "#9333EA"
|
"color": "#9333EA"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
"./plugins/with-runtime-framework-headers.js",
|
||||||
"react-native-google-cast",
|
"react-native-bottom-tabs"
|
||||||
{
|
|
||||||
"useDefaultExpandedMediaControls": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
|
import { useAtom } from "jotai/index";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FlatList, Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
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 { FlatList, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
|
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
|
||||||
export default function SearchLayout() {
|
export default function SearchLayout() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -10,7 +10,7 @@ export default function SearchLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.favorites"),
|
headerTitle: t("tabs.favorites"),
|
||||||
headerLargeStyle: {
|
headerLargeStyle: {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Favorites } from "@/components/home/Favorites";
|
import { useCallback, useState } from "react";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
|
||||||
import React, { useCallback, useState } from "react";
|
|
||||||
import { RefreshControl, ScrollView, View } from "react-native";
|
import { RefreshControl, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Favorites } from "@/components/home/Favorites";
|
||||||
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
|
||||||
export default function favorites() {
|
export default function favorites() {
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
|
||||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||||
|
|
||||||
|
import { useAtom } from "jotai";
|
||||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
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 [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@ export default function IndexLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.home"),
|
headerTitle: t("tabs.home"),
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
@@ -64,12 +66,6 @@ export default function IndexLayout() {
|
|||||||
title: t("home.settings.settings_title"),
|
title: t("home.settings.settings_title"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name='settings/optimized-server/page'
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/marlin-search/page'
|
name='settings/marlin-search/page'
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
||||||
import {
|
import {
|
||||||
@@ -6,11 +11,6 @@ import {
|
|||||||
} from "@/components/series/SeasonDropdown";
|
} from "@/components/series/SeasonDropdown";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
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();
|
||||||
@@ -23,12 +23,12 @@ export default function page() {
|
|||||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
const { downloadedFiles, deleteItems } = useDownload();
|
const { getDownloadedItems, deleteItems } = useDownload();
|
||||||
|
|
||||||
const series = useMemo(() => {
|
const series = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
downloadedFiles
|
getDownloadedItems()
|
||||||
?.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!,
|
||||||
@@ -37,7 +37,37 @@ export default function page() {
|
|||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [downloadedFiles]);
|
}, [getDownloadedItems]);
|
||||||
|
|
||||||
|
// Group episodes by season in a single pass
|
||||||
|
const seasonGroups = useMemo(() => {
|
||||||
|
const groups: Record<number, BaseItemDto[]> = {};
|
||||||
|
|
||||||
|
series.forEach((episode) => {
|
||||||
|
const seasonNumber = episode.item.ParentIndexNumber;
|
||||||
|
if (seasonNumber !== undefined && seasonNumber !== null) {
|
||||||
|
if (!groups[seasonNumber]) {
|
||||||
|
groups[seasonNumber] = [];
|
||||||
|
}
|
||||||
|
groups[seasonNumber].push(episode.item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort episodes within each season
|
||||||
|
Object.values(groups).forEach((episodes) => {
|
||||||
|
episodes.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [series]);
|
||||||
|
|
||||||
|
// Get unique seasons (just the season numbers, sorted)
|
||||||
|
const uniqueSeasons = useMemo(() => {
|
||||||
|
const seasonNumbers = Object.keys(seasonGroups)
|
||||||
|
.map(Number)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
return seasonNumbers.map((seasonNum) => seasonGroups[seasonNum][0]); // First episode of each season
|
||||||
|
}, [seasonGroups]);
|
||||||
|
|
||||||
const seasonIndex =
|
const seasonIndex =
|
||||||
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
|
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
|
||||||
@@ -45,20 +75,8 @@ export default function page() {
|
|||||||
"";
|
"";
|
||||||
|
|
||||||
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
||||||
const seasons: Record<string, BaseItemDto[]> = {};
|
return seasonGroups[Number(seasonIndex)] ?? [];
|
||||||
|
}, [seasonGroups, seasonIndex]);
|
||||||
series?.forEach((episode) => {
|
|
||||||
if (!seasons[episode.item.ParentIndexNumber!]) {
|
|
||||||
seasons[episode.item.ParentIndexNumber!] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
seasons[episode.item.ParentIndexNumber!].push(episode.item);
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ??
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
}, [series, seasonIndex]);
|
|
||||||
|
|
||||||
const initialSeasonIndex = useMemo(
|
const initialSeasonIndex = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -102,7 +120,7 @@ export default function page() {
|
|||||||
<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={uniqueSeasons}
|
||||||
state={seasonIndexState}
|
state={seasonIndexState}
|
||||||
initialSeasonIndex={initialSeasonIndex!}
|
initialSeasonIndex={initialSeasonIndex!}
|
||||||
onSelect={(season) => {
|
onSelect={(season) => {
|
||||||
|
|||||||
@@ -1,13 +1,3 @@
|
|||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
|
||||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
|
||||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
|
||||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
|
||||||
import { type DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
|
||||||
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 {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
@@ -16,28 +6,69 @@ import {
|
|||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
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 { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||||
|
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||||
|
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||||
|
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { type DownloadedItem } from "@/providers/Downloads/types";
|
||||||
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
|
const {
|
||||||
|
removeProcess,
|
||||||
|
getDownloadedItems,
|
||||||
|
deleteFileByType,
|
||||||
|
deleteAllFiles,
|
||||||
|
} = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [settings] = useSettings();
|
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
|
const [showMigration, setShowMigration] = useState(false);
|
||||||
|
|
||||||
|
const migration_20241124 = () => {
|
||||||
|
Alert.alert(
|
||||||
|
t("home.downloads.new_app_version_requires_re_download"),
|
||||||
|
t("home.downloads.new_app_version_requires_re_download_description"),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: t("home.downloads.back"),
|
||||||
|
onPress: () => {
|
||||||
|
setShowMigration(false);
|
||||||
|
router.back();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("home.downloads.delete"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
await deleteAllFiles();
|
||||||
|
setShowMigration(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadedFiles = getDownloadedItems();
|
||||||
|
|
||||||
const movies = useMemo(() => {
|
const movies = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
||||||
} catch {
|
} catch {
|
||||||
migration_20241124();
|
setShowMigration(true);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [downloadedFiles]);
|
}, [downloadedFiles]);
|
||||||
@@ -54,13 +85,11 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
return Object.values(series);
|
return Object.values(series);
|
||||||
} catch {
|
} catch {
|
||||||
migration_20241124();
|
setShowMigration(true);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [downloadedFiles]);
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
@@ -71,6 +100,12 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
}, [downloadedFiles]);
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showMigration) {
|
||||||
|
migration_20241124();
|
||||||
|
}
|
||||||
|
}, [showMigration]);
|
||||||
|
|
||||||
const deleteMovies = () =>
|
const deleteMovies = () =>
|
||||||
deleteFileByType("Movie")
|
deleteFileByType("Movie")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
@@ -98,16 +133,10 @@ export default function page() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ScrollView
|
<View style={{ flex: 1 }}>
|
||||||
contentContainerStyle={{
|
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
|
||||||
paddingLeft: insets.left,
|
<View className='py-4'>
|
||||||
paddingRight: insets.right,
|
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||||
paddingBottom: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className='py-4'>
|
|
||||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
|
||||||
{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'>
|
<Text className='text-lg font-bold'>
|
||||||
{t("home.downloads.queue")}
|
{t("home.downloads.queue")}
|
||||||
@@ -151,70 +180,74 @@ export default function page() {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
|
|
||||||
<ActiveDownloads />
|
<ActiveDownloads />
|
||||||
</View>
|
|
||||||
|
|
||||||
{movies.length > 0 && (
|
|
||||||
<View className='mb-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>
|
|
||||||
<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>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className='px-4 flex flex-row'>
|
|
||||||
{movies?.map((item) => (
|
|
||||||
<View className='mb-2 last:mb-0' key={item.item.Id}>
|
|
||||||
<MovieCard item={item.item} />
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
{groupedBySeries.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'>
|
<Text className='text-lg font-bold'>
|
||||||
{t("home.downloads.tvseries")}
|
{t("home.downloads.movies")}
|
||||||
</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}
|
|
||||||
</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>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View className='px-4 flex flex-row'>
|
||||||
|
{movies?.map((item) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item.item}
|
||||||
|
isOffline
|
||||||
|
key={item.item.Id}
|
||||||
|
>
|
||||||
|
<MovieCard item={item.item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
)}
|
||||||
<View className='px-4 flex flex-row'>
|
{groupedBySeries.length > 0 && (
|
||||||
{groupedBySeries?.map((items) => (
|
<View className='mb-4'>
|
||||||
<View
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
className='mb-2 last:mb-0'
|
<Text className='text-lg font-bold'>
|
||||||
key={items[0].item.SeriesId}
|
{t("home.downloads.tvseries")}
|
||||||
>
|
</Text>
|
||||||
<SeriesCard
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
items={items.map((i) => i.item)}
|
<Text className='text-xs font-bold'>
|
||||||
key={items[0].item.SeriesId}
|
{groupedBySeries?.length}
|
||||||
/>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
</View>
|
<View className='px-4 flex flex-row'>
|
||||||
)}
|
{groupedBySeries?.map((items) => (
|
||||||
{downloadedFiles?.length === 0 && (
|
<View
|
||||||
<View className='flex px-4'>
|
className='mb-2 last:mb-0'
|
||||||
<Text className='opacity-50'>
|
key={items[0].item.SeriesId}
|
||||||
{t("home.downloads.no_downloaded_items")}
|
>
|
||||||
</Text>
|
<SeriesCard
|
||||||
</View>
|
items={items.map((i) => i.item)}
|
||||||
)}
|
key={items[0].item.SeriesId}
|
||||||
</View>
|
/>
|
||||||
</ScrollView>
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{downloadedFiles?.length === 0 && (
|
||||||
|
<View className='flex px-4'>
|
||||||
|
<Text className='opacity-50'>
|
||||||
|
{t("home.downloads.no_downloaded_items")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={bottomSheetModalRef}
|
ref={bottomSheetModalRef}
|
||||||
enableDynamicSizing
|
enableDynamicSizing
|
||||||
@@ -249,23 +282,3 @@ export default function page() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function migration_20241124() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { deleteAllFiles } = useDownload();
|
|
||||||
Alert.alert(
|
|
||||||
t("home.downloads.new_app_version_requires_re_download"),
|
|
||||||
t("home.downloads.new_app_version_requires_re_download_description"),
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: t("home.downloads.back"),
|
|
||||||
onPress: () => router.back(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("home.downloads.delete"),
|
|
||||||
style: "destructive",
|
|
||||||
onPress: async () => await deleteAllFiles(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useFocusEffect, useRouter } from "expo-router";
|
import { useFocusEffect, useRouter } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Linking, TouchableOpacity, View } from "react-native";
|
import { Linking, Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -19,7 +19,9 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='bg-neutral-900 h-full py-16 px-4 space-y-8'>
|
<View
|
||||||
|
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 space-y-4" : "py-16 space-y-8"} px-4`}
|
||||||
|
>
|
||||||
<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")}
|
||||||
@@ -49,42 +51,50 @@ export default function page() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row items-center mt-4'>
|
{!Platform.isTV && (
|
||||||
<View
|
<>
|
||||||
style={{
|
<View className='flex flex-row items-center mt-4'>
|
||||||
width: 50,
|
<View
|
||||||
height: 50,
|
style={{
|
||||||
}}
|
width: 50,
|
||||||
className='flex items-center justify-center'
|
height: 50,
|
||||||
>
|
}}
|
||||||
<Ionicons name='cloud-download-outline' size={32} color='white' />
|
className='flex items-center justify-center'
|
||||||
</View>
|
>
|
||||||
<View className='shrink ml-2'>
|
<Ionicons
|
||||||
<Text className='font-bold mb-1'>
|
name='cloud-download-outline'
|
||||||
{t("home.intro.downloads_feature_title")}
|
size={32}
|
||||||
</Text>
|
color='white'
|
||||||
<Text className='shrink text-xs'>
|
/>
|
||||||
{t("home.intro.downloads_feature_description")}
|
</View>
|
||||||
</Text>
|
<View className='shrink ml-2'>
|
||||||
</View>
|
<Text className='font-bold mb-1'>
|
||||||
</View>
|
{t("home.intro.downloads_feature_title")}
|
||||||
<View className='flex flex-row items-center mt-4'>
|
</Text>
|
||||||
<View
|
<Text className='shrink text-xs'>
|
||||||
style={{
|
{t("home.intro.downloads_feature_description")}
|
||||||
width: 50,
|
</Text>
|
||||||
height: 50,
|
</View>
|
||||||
}}
|
</View>
|
||||||
className='flex items-center justify-center'
|
<View className='flex flex-row items-center mt-4'>
|
||||||
>
|
<View
|
||||||
<Feather name='cast' size={28} color={"white"} />
|
style={{
|
||||||
</View>
|
width: 50,
|
||||||
<View className='shrink ml-2'>
|
height: 50,
|
||||||
<Text className='font-bold mb-1'>Chromecast</Text>
|
}}
|
||||||
<Text className='shrink text-xs'>
|
className='flex items-center justify-center'
|
||||||
{t("home.intro.chromecast_feature_description")}
|
>
|
||||||
</Text>
|
<Feather name='cast' size={28} color={"white"} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
<View className='shrink ml-2'>
|
||||||
|
<Text className='font-bold mb-1'>Chromecast</Text>
|
||||||
|
<Text className='shrink text-xs'>
|
||||||
|
{t("home.intro.chromecast_feature_description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<View className='flex flex-row items-center mt-4'>
|
<View className='flex flex-row items-center mt-4'>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -99,19 +109,22 @@ export default function page() {
|
|||||||
<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'>
|
<View className='flex-row flex-wrap items-baseline'>
|
||||||
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
<Text className='shrink text-xs'>
|
||||||
<Text
|
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
||||||
className='text-purple-600'
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
Linking.openURL(
|
Linking.openURL(
|
||||||
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
|
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("home.intro.read_more")}
|
<Text className='text-xs text-purple-600 underline'>
|
||||||
</Text>
|
{t("home.intro.read_more")}
|
||||||
</Text>
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,19 +1,4 @@
|
|||||||
import { Badge } from "@/components/Badge";
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
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 {
|
import {
|
||||||
HardwareAccelerationType,
|
HardwareAccelerationType,
|
||||||
type SessionInfoDto,
|
type SessionInfoDto,
|
||||||
@@ -26,10 +11,19 @@ import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
|||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { get } from "lodash";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { Badge } from "@/components/Badge";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
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";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
||||||
@@ -440,8 +434,6 @@ const TranscodingStreamView = ({
|
|||||||
isTranscoding,
|
isTranscoding,
|
||||||
properties,
|
properties,
|
||||||
transcodeProperties,
|
transcodeProperties,
|
||||||
value,
|
|
||||||
transcodeValue,
|
|
||||||
}: TranscodingStreamViewProps) => {
|
}: TranscodingStreamViewProps) => {
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col pt-2 first:pt-0'>
|
<View className='flex flex-col pt-2 first:pt-0'>
|
||||||
@@ -454,20 +446,18 @@ const TranscodingStreamView = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{isTranscoding && transcodeProperties ? (
|
{isTranscoding && transcodeProperties ? (
|
||||||
<>
|
<View className='flex flex-row'>
|
||||||
<View className='flex flex-row'>
|
<Text className='-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4'>
|
||||||
<Text className='-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4'>
|
<MaterialCommunityIcons
|
||||||
<MaterialCommunityIcons
|
name='arrow-right-bottom'
|
||||||
name='arrow-right-bottom'
|
size={14}
|
||||||
size={14}
|
color='white'
|
||||||
color='white'
|
/>
|
||||||
/>
|
</Text>
|
||||||
</Text>
|
<Text className='flex-1 text-sm mt-1'>
|
||||||
<Text className='flex-1 text-sm mt-1'>
|
<TranscodingBadges properties={transcodeProperties} />
|
||||||
<TranscodingBadges properties={transcodeProperties} />
|
</Text>
|
||||||
</Text>
|
</View>
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
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";
|
||||||
@@ -14,21 +20,14 @@ import { StorageSettings } from "@/components/settings/StorageSettings";
|
|||||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||||
import { UserInfo } from "@/components/settings/UserInfo";
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useJellyfin } from "@/providers/JellyfinProvider";
|
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { clearLogs } from "@/utils/log";
|
import { clearLogs } from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import { ScrollView, Switch, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
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 [_user] = useAtom(userAtom);
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
|
|
||||||
@@ -74,13 +73,13 @@ export default function settings() {
|
|||||||
|
|
||||||
<OtherSettings />
|
<OtherSettings />
|
||||||
|
|
||||||
<DownloadSettings />
|
{!Platform.isTV && <DownloadSettings />}
|
||||||
|
|
||||||
<PluginSettings />
|
<PluginSettings />
|
||||||
|
|
||||||
<AppLanguageSelector />
|
<AppLanguageSelector />
|
||||||
|
|
||||||
<ChromecastSettings />
|
{!Platform.isTV && <ChromecastSettings />}
|
||||||
|
|
||||||
<ListGroup title={"Intro"}>
|
<ListGroup title={"Intro"}>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -113,7 +112,7 @@ export default function settings() {
|
|||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<StorageSettings />
|
{!Platform.isTV && <StorageSettings />}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
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 { useTranslation } from "react-i18next";
|
||||||
import { Switch, View } from "react-native";
|
import { Switch, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
const [_settings, _updateSettings, pluginSettings] = useSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
|
||||||
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import * as Sharing from "expo-sharing";
|
import * as Sharing from "expo-sharing";
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import Collapsible from "react-native-collapsible";
|
import Collapsible from "react-native-collapsible";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
||||||
|
|
||||||
export default function page() {
|
export default function Page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { logs } = useLog();
|
const { logs } = useLog();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const orderFilterId = useId();
|
||||||
|
const levelsFilterId = useId();
|
||||||
|
|
||||||
const defaultLevels: LogLevel[] = ["INFO", "ERROR", "DEBUG", "WARN"];
|
const defaultLevels: LogLevel[] = ["INFO", "ERROR", "DEBUG", "WARN"];
|
||||||
const codeBlockStyle = {
|
const codeBlockStyle = {
|
||||||
backgroundColor: "#000",
|
backgroundColor: "#000",
|
||||||
@@ -25,10 +28,12 @@ export default function page() {
|
|||||||
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [state, setState] = useState<Record<string, boolean>>({});
|
const [state, setState] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const [order, setOrder] = useState<"asc" | "desc">("desc");
|
const [order, setOrder] = useState<"asc" | "desc">("desc");
|
||||||
const [levels, setLevels] = useState<LogLevel[]>(defaultLevels);
|
const [levels, setLevels] = useState<LogLevel[]>(defaultLevels);
|
||||||
|
|
||||||
|
const _orderId = useId();
|
||||||
|
const _levelsId = useId();
|
||||||
|
|
||||||
const filteredLogs = useMemo(
|
const filteredLogs = useMemo(
|
||||||
() =>
|
() =>
|
||||||
logs
|
logs
|
||||||
@@ -73,7 +78,7 @@ export default function page() {
|
|||||||
<>
|
<>
|
||||||
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
id='order'
|
id={orderFilterId}
|
||||||
queryKey='log'
|
queryKey='log'
|
||||||
queryFn={async () => ["asc", "desc"]}
|
queryFn={async () => ["asc", "desc"]}
|
||||||
set={(values) => setOrder(values[0])}
|
set={(values) => setOrder(values[0])}
|
||||||
@@ -83,7 +88,7 @@ export default function page() {
|
|||||||
showSearch={false}
|
showSearch={false}
|
||||||
/>
|
/>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
id='levels'
|
id={levelsFilterId}
|
||||||
queryKey='log'
|
queryKey='log'
|
||||||
queryFn={async () => defaultLevels}
|
queryFn={async () => defaultLevels}
|
||||||
set={setLevels}
|
set={setLevels}
|
||||||
@@ -122,7 +127,7 @@ export default function page() {
|
|||||||
{new Date(log.timestamp).toLocaleString()}
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text uiTextView selectable className='text-xs'>
|
<Text selectable className='text-xs'>
|
||||||
{log.message}
|
{log.message}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -16,6 +10,11 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getOrSetDeviceId } from "@/utils/device";
|
|
||||||
import { getStatistics } from "@/utils/optimize-server";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
|
||||||
|
|
||||||
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
|
||||||
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
|
||||||
mutationFn: async (newVal: string) => {
|
|
||||||
if (newVal.length === 0 || !newVal.startsWith("http")) {
|
|
||||||
toast.error(t("home.settings.toasts.invalid_url"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedUrl = newVal.endsWith("/") ? newVal : `${newVal}/`;
|
|
||||||
|
|
||||||
updateSettings({
|
|
||||||
optimizedVersionsServerUrl: updatedUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
return await getStatistics({
|
|
||||||
url: updatedUrl,
|
|
||||||
authHeader: api?.accessToken,
|
|
||||||
deviceId: getOrSetDeviceId(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data) {
|
|
||||||
toast.success(t("home.settings.toasts.connected"));
|
|
||||||
} else {
|
|
||||||
toast.error(t("home.settings.toasts.could_not_connect"));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error(t("home.settings.toasts.could_not_connect"));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSave = (newVal: string) => {
|
|
||||||
saveMutation.mutate(newVal);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pluginSettings?.optimizedVersionsServerUrl?.locked) {
|
|
||||||
navigation.setOptions({
|
|
||||||
title: t("home.settings.downloads.optimized_server"),
|
|
||||||
headerRight: () =>
|
|
||||||
saveMutation.isPending ? (
|
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
|
||||||
) : (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => onSave(optimizedVersionsServerUrl)}
|
|
||||||
>
|
|
||||||
<Text className='text-blue-500'>
|
|
||||||
{t("home.settings.downloads.save_button")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DisabledSetting
|
|
||||||
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
|
|
||||||
className='p-4'
|
|
||||||
>
|
|
||||||
<OptimizedServerForm
|
|
||||||
value={optimizedVersionsServerUrl}
|
|
||||||
onChangeValue={setOptimizedVersionsServerUrl}
|
|
||||||
/>
|
|
||||||
</DisabledSetting>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,3 @@
|
|||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
import type { 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";
|
||||||
@@ -19,6 +7,18 @@ import { useAtom } from "jotai";
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||||
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
|
|||||||
@@ -1,22 +1,3 @@
|
|||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
SortByOption,
|
|
||||||
SortOrderOption,
|
|
||||||
genreFilterAtom,
|
|
||||||
sortByAtom,
|
|
||||||
sortOptions,
|
|
||||||
sortOrderAtom,
|
|
||||||
sortOrderOptions,
|
|
||||||
tagsFilterAtom,
|
|
||||||
yearFilterAtom,
|
|
||||||
} from "@/utils/atoms/filters";
|
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -35,6 +16,25 @@ import type React from "react";
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FlatList, View } from "react-native";
|
import { FlatList, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
genreFilterAtom,
|
||||||
|
SortByOption,
|
||||||
|
SortOrderOption,
|
||||||
|
sortByAtom,
|
||||||
|
sortOptions,
|
||||||
|
sortOrderAtom,
|
||||||
|
sortOrderOptions,
|
||||||
|
tagsFilterAtom,
|
||||||
|
yearFilterAtom,
|
||||||
|
} from "@/utils/atoms/filters";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -43,7 +43,7 @@ const page: React.FC = () => {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
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,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ const page: React.FC = () => {
|
|||||||
recursive: true,
|
recursive: true,
|
||||||
genres: selectedGenres,
|
genres: selectedGenres,
|
||||||
tags: selectedTags,
|
tags: selectedTags,
|
||||||
years: selectedYears.map((year) => Number.parseInt(year)),
|
years: selectedYears.map((year) => Number.parseInt(year, 10)),
|
||||||
includeItemTypes: ["Movie", "Series"],
|
includeItemTypes: ["Movie", "Series"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import { ItemContent } from "@/components/ItemContent";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -15,29 +9,18 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ItemContent } from "@/components/ItemContent";
|
||||||
|
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const { id } = useLocalSearchParams() as { id: string };
|
const { id } = useLocalSearchParams() as { id: string };
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data: item, isError } = useQuery({
|
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||||
queryKey: ["item", id],
|
const isOffline = offline === "true";
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user || !id) return;
|
|
||||||
const res = await getUserLibraryApi(api).getItem({
|
|
||||||
itemId: id,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data;
|
const { data: item, isError } = useItemQuery(id, isOffline);
|
||||||
},
|
|
||||||
staleTime: 0,
|
|
||||||
refetchOnMount: true,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
const opacity = useSharedValue(1);
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
@@ -107,7 +90,7 @@ const Page: React.FC = () => {
|
|||||||
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
<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 className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
{item && <ItemContent item={item} />}
|
{item && <ItemContent item={item} isOffline={isOffline} />}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { uniqBy } from "lodash";
|
||||||
|
import { useMemo } from "react";
|
||||||
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 { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
import {
|
import {
|
||||||
type MovieResult,
|
type MovieResult,
|
||||||
Results,
|
|
||||||
type TvResult,
|
type TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
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, image, type } = local as unknown as {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
name: string;
|
name: string;
|
||||||
image: string;
|
image: string;
|
||||||
@@ -99,7 +98,7 @@ export default function page() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
renderItem={(item, index) => (
|
renderItem={(item, _index) => (
|
||||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { uniqBy } from "lodash";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
|
||||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
|
||||||
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||||
|
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||||
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 { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
import {
|
import {
|
||||||
type MovieResult,
|
type MovieResult,
|
||||||
Results,
|
|
||||||
type TvResult,
|
type TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} 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();
|
||||||
@@ -96,7 +92,7 @@ export default function page() {
|
|||||||
{name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
renderItem={(item, index) => (
|
renderItem={(item, _index) => (
|
||||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,25 +1,3 @@
|
|||||||
import { Button } from "@/components/Button";
|
|
||||||
import { GenreTags } from "@/components/GenreTags";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
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 { ItemActions } from "@/components/series/SeriesActions";
|
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
|
||||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
|
||||||
import {
|
|
||||||
type IssueType,
|
|
||||||
IssueTypeName,
|
|
||||||
} from "@/utils/jellyseerr/server/constants/issue";
|
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
|
||||||
import type {
|
|
||||||
MovieResult,
|
|
||||||
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,
|
||||||
@@ -36,7 +14,31 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
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";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
|
import Cast from "@/components/jellyseerr/Cast";
|
||||||
|
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||||
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { JellyserrRatings } from "@/components/Ratings";
|
||||||
|
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||||
|
import { ItemActions } from "@/components/series/SeriesActions";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||||
|
import {
|
||||||
|
type IssueType,
|
||||||
|
IssueTypeName,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/issue";
|
||||||
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type {
|
||||||
|
MovieResult,
|
||||||
|
TvResult,
|
||||||
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
|
||||||
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 type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
@@ -219,11 +221,7 @@ const Page: React.FC = () => {
|
|||||||
| TvDetails
|
| TvDetails
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text selectable className='font-bold text-2xl mb-1'>
|
||||||
uiTextView
|
|
||||||
selectable
|
|
||||||
className='font-bold text-2xl mb-1'
|
|
||||||
>
|
|
||||||
{mediaTitle}
|
{mediaTitle}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='opacity-50'>{releaseYear}</Text>
|
<Text className='opacity-50'>{releaseYear}</Text>
|
||||||
@@ -254,26 +252,28 @@ const Page: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
details?.mediaInfo?.jellyfinMediaId && (
|
details?.mediaInfo?.jellyfinMediaId && (
|
||||||
<View className='flex flex-row space-x-2 mt-4'>
|
<View className='flex flex-row space-x-2 mt-4'>
|
||||||
<Button
|
{!Platform.isTV && (
|
||||||
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
<Button
|
||||||
color='transparent'
|
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
color='transparent'
|
||||||
iconLeft={
|
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||||
<Ionicons
|
iconLeft={
|
||||||
name='warning-outline'
|
<Ionicons
|
||||||
size={20}
|
name='warning-outline'
|
||||||
color='white'
|
size={20}
|
||||||
/>
|
color='white'
|
||||||
}
|
/>
|
||||||
style={{
|
}
|
||||||
borderWidth: 1,
|
style={{
|
||||||
borderStyle: "solid",
|
borderWidth: 1,
|
||||||
}}
|
borderStyle: "solid",
|
||||||
>
|
}}
|
||||||
<Text className='text-sm'>
|
>
|
||||||
{t("jellyseerr.report_issue_button")}
|
<Text className='text-sm'>
|
||||||
</Text>
|
{t("jellyseerr.report_issue_button")}
|
||||||
</Button>
|
</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
|
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -331,92 +331,95 @@ const Page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
onDismiss={() => _setRequestBody(undefined)}
|
onDismiss={() => _setRequestBody(undefined)}
|
||||||
/>
|
/>
|
||||||
<BottomSheetModal
|
{!Platform.isTV && (
|
||||||
ref={bottomSheetModalRef}
|
// This is till it's fixed because the menu isn't selectable on TV
|
||||||
enableDynamicSizing
|
<BottomSheetModal
|
||||||
handleIndicatorStyle={{
|
ref={bottomSheetModalRef}
|
||||||
backgroundColor: "white",
|
enableDynamicSizing
|
||||||
}}
|
handleIndicatorStyle={{
|
||||||
backgroundStyle={{
|
backgroundColor: "white",
|
||||||
backgroundColor: "#171717",
|
}}
|
||||||
}}
|
backgroundStyle={{
|
||||||
backdropComponent={renderBackdrop}
|
backgroundColor: "#171717",
|
||||||
>
|
}}
|
||||||
<BottomSheetView>
|
backdropComponent={renderBackdrop}
|
||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
>
|
||||||
<View>
|
<BottomSheetView>
|
||||||
<Text className='font-bold text-2xl text-neutral-100'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
{t("jellyseerr.whats_wrong")}
|
<View>
|
||||||
</Text>
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
</View>
|
{t("jellyseerr.whats_wrong")}
|
||||||
<View className='flex flex-col space-y-2 items-start'>
|
</Text>
|
||||||
<View className='flex flex-col'>
|
</View>
|
||||||
<DropdownMenu.Root>
|
<View className='flex flex-col space-y-2 items-start'>
|
||||||
<DropdownMenu.Trigger>
|
<View className='flex flex-col'>
|
||||||
<View className='flex flex-col'>
|
<DropdownMenu.Root>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
<DropdownMenu.Trigger>
|
||||||
{t("jellyseerr.issue_type")}
|
<View className='flex flex-col'>
|
||||||
</Text>
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
{t("jellyseerr.issue_type")}
|
||||||
<Text style={{}} className='' numberOfLines={1}>
|
|
||||||
{issueType
|
|
||||||
? IssueTypeName[issueType]
|
|
||||||
: t("jellyseerr.select_an_issue")}
|
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
</View>
|
<Text style={{}} className='' numberOfLines={1}>
|
||||||
</DropdownMenu.Trigger>
|
{issueType
|
||||||
<DropdownMenu.Content
|
? IssueTypeName[issueType]
|
||||||
loop={false}
|
: t("jellyseerr.select_an_issue")}
|
||||||
side='bottom'
|
</Text>
|
||||||
align='center'
|
</TouchableOpacity>
|
||||||
alignOffset={0}
|
</View>
|
||||||
avoidCollisions={true}
|
</DropdownMenu.Trigger>
|
||||||
collisionPadding={0}
|
<DropdownMenu.Content
|
||||||
sideOffset={0}
|
loop={false}
|
||||||
>
|
side='bottom'
|
||||||
<DropdownMenu.Label>
|
align='center'
|
||||||
{t("jellyseerr.types")}
|
alignOffset={0}
|
||||||
</DropdownMenu.Label>
|
avoidCollisions={true}
|
||||||
{Object.entries(IssueTypeName)
|
collisionPadding={0}
|
||||||
.reverse()
|
sideOffset={0}
|
||||||
.map(([key, value], idx) => (
|
>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Label>
|
||||||
key={value}
|
{t("jellyseerr.types")}
|
||||||
onSelect={() =>
|
</DropdownMenu.Label>
|
||||||
setIssueType(key as unknown as IssueType)
|
{Object.entries(IssueTypeName)
|
||||||
}
|
.reverse()
|
||||||
>
|
.map(([key, value], _idx) => (
|
||||||
<DropdownMenu.ItemTitle>
|
<DropdownMenu.Item
|
||||||
{value}
|
key={value}
|
||||||
</DropdownMenu.ItemTitle>
|
onSelect={() =>
|
||||||
</DropdownMenu.Item>
|
setIssueType(key as unknown as IssueType)
|
||||||
))}
|
}
|
||||||
</DropdownMenu.Content>
|
>
|
||||||
</DropdownMenu.Root>
|
<DropdownMenu.ItemTitle>
|
||||||
</View>
|
{value}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</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}
|
||||||
onChangeText={setIssueMessage}
|
onChangeText={setIssueMessage}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
||||||
|
{t("jellyseerr.submit_button")}
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
</BottomSheetView>
|
||||||
{t("jellyseerr.submit_button")}
|
</BottomSheetModal>
|
||||||
</Button>
|
)}
|
||||||
</View>
|
|
||||||
</BottomSheetView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { OverviewText } from "@/components/OverviewText";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { orderBy, uniqBy } from "lodash";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||||
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||||
@@ -8,12 +14,6 @@ import type {
|
|||||||
MovieResult,
|
MovieResult,
|
||||||
TvResult,
|
TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} 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";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
@@ -21,14 +21,13 @@ export default function page() {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
jellyseerrApi,
|
jellyseerrApi,
|
||||||
jellyseerrUser,
|
|
||||||
jellyseerrRegion: region,
|
jellyseerrRegion: region,
|
||||||
jellyseerrLocale: locale,
|
jellyseerrLocale: locale,
|
||||||
} = useJellyseerr();
|
} = useJellyseerr();
|
||||||
|
|
||||||
const { personId } = local as { personId: string };
|
const { personId } = local as { personId: string };
|
||||||
|
|
||||||
const { data, isLoading, isFetching } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ["jellyseerr", "person", personId],
|
queryKey: ["jellyseerr", "person", personId],
|
||||||
queryFn: async () => ({
|
queryFn: async () => ({
|
||||||
details: await jellyseerrApi?.personDetails(personId),
|
details: await jellyseerrApi?.personDetails(personId),
|
||||||
@@ -107,7 +106,7 @@ export default function page() {
|
|||||||
MainContent={() => (
|
MainContent={() => (
|
||||||
<OverviewText text={data?.details?.biography} className='mt-4' />
|
<OverviewText text={data?.details?.biography} className='mt-4' />
|
||||||
)}
|
)}
|
||||||
renderItem={(item, index) => (
|
renderItem={(item, _index) => (
|
||||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import type {
|
import {
|
||||||
|
createMaterialTopTabNavigator,
|
||||||
MaterialTopTabNavigationEventMap,
|
MaterialTopTabNavigationEventMap,
|
||||||
MaterialTopTabNavigationOptions,
|
MaterialTopTabNavigationOptions,
|
||||||
} from "@react-navigation/material-top-tabs";
|
} from "@react-navigation/material-top-tabs";
|
||||||
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
|
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
import { Stack, withLayoutContext } from "expo-router";
|
import { Stack, withLayoutContext } from "expo-router";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
const { Navigator } = createMaterialTopTabNavigator();
|
const { Navigator } = createMaterialTopTabNavigator();
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import { ItemImage } from "@/components/common/ItemImage";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React from "react";
|
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const _insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { data: channels } = useQuery({
|
const { data: channels } = useQuery({
|
||||||
queryKey: ["livetv", "channels"],
|
queryKey: ["livetv", "channels"],
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
import { ItemImage } from "@/components/common/ItemImage";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { HourHeader } from "@/components/livetv/HourHeader";
|
|
||||||
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
|
||||||
import { TAB_HEIGHT } from "@/constants/Values";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
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, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { Dimensions, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
Button,
|
|
||||||
Dimensions,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { HourHeader } from "@/components/livetv/HourHeader";
|
||||||
|
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
const HOUR_HEIGHT = 30;
|
const HOUR_HEIGHT = 30;
|
||||||
const ITEMS_PER_PAGE = 20;
|
const ITEMS_PER_PAGE = 20;
|
||||||
@@ -28,17 +21,9 @@ export default function page() {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [date, setDate] = useState<Date>(new Date());
|
const [date, _setDate] = useState<Date>(new Date());
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
const { data: guideInfo } = useQuery({
|
|
||||||
queryKey: ["livetv", "guideInfo"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await getLiveTvApi(api!).getGuideInfo();
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: channels } = useQuery({
|
const { data: channels } = useQuery({
|
||||||
queryKey: ["livetv", "channels", currentPage],
|
queryKey: ["livetv", "channels", currentPage],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -150,7 +135,7 @@ export default function page() {
|
|||||||
>
|
>
|
||||||
<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
|
||||||
channel={c}
|
channel={c}
|
||||||
programs={programs?.Items}
|
programs={programs?.Items}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
|
||||||
import { TAB_HEIGHT } from "@/constants/Values";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import type { 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 { useTranslation } from "react-i18next";
|
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 { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -1,13 +1,3 @@
|
|||||||
import { AddToFavorites } from "@/components/AddToFavorites";
|
|
||||||
import { DownloadItems } from "@/components/DownloadItem";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import { NextUp } from "@/components/series/NextUp";
|
|
||||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
|
||||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -18,6 +8,16 @@ import type React from "react";
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
|
import { AddToFavorites } from "@/components/AddToFavorites";
|
||||||
|
import { DownloadItems } from "@/components/DownloadItem";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { NextUp } from "@/components/series/NextUp";
|
||||||
|
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||||
|
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -69,10 +69,18 @@ const page: React.FC = () => {
|
|||||||
seriesId: item?.Id!,
|
seriesId: item?.Id!,
|
||||||
userId: user?.Id!,
|
userId: user?.Id!,
|
||||||
enableUserData: true,
|
enableUserData: true,
|
||||||
fields: ["MediaSources", "MediaStreams", "Overview"],
|
// Note: Including trick play is necessary to enable trick play downloads
|
||||||
|
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||||
});
|
});
|
||||||
return res?.data.Items || [];
|
return res?.data.Items || [];
|
||||||
},
|
},
|
||||||
|
select: (data) =>
|
||||||
|
// This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order.
|
||||||
|
[...(data || [])].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
||||||
|
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
||||||
|
),
|
||||||
staleTime: 60,
|
staleTime: 60,
|
||||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
enabled: !!api && !!user?.Id && !!item?.Id,
|
||||||
});
|
});
|
||||||
@@ -136,7 +144,7 @@ const page: React.FC = () => {
|
|||||||
resizeMode: "contain",
|
resizeMode: "contain",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col pt-4'>
|
<View className='flex flex-col pt-4'>
|
||||||
|
|||||||
@@ -1,34 +1,3 @@
|
|||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, { useCallback, useEffect, useMemo } from "react";
|
|
||||||
import { FlatList, View, useWindowDimensions } from "react-native";
|
|
||||||
|
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
SortByOption,
|
|
||||||
SortOrderOption,
|
|
||||||
genreFilterAtom,
|
|
||||||
getSortByPreference,
|
|
||||||
getSortOrderPreference,
|
|
||||||
sortByAtom,
|
|
||||||
sortByPreferenceAtom,
|
|
||||||
sortOptions,
|
|
||||||
sortOrderAtom,
|
|
||||||
sortOrderOptions,
|
|
||||||
sortOrderPreferenceAtom,
|
|
||||||
tagsFilterAtom,
|
|
||||||
yearFilterAtom,
|
|
||||||
} from "@/utils/atoms/filters";
|
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -40,8 +9,38 @@ 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 { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
genreFilterAtom,
|
||||||
|
getSortByPreference,
|
||||||
|
getSortOrderPreference,
|
||||||
|
SortByOption,
|
||||||
|
SortOrderOption,
|
||||||
|
sortByAtom,
|
||||||
|
sortByPreferenceAtom,
|
||||||
|
sortOptions,
|
||||||
|
sortOrderAtom,
|
||||||
|
sortOrderOptions,
|
||||||
|
sortOrderPreferenceAtom,
|
||||||
|
tagsFilterAtom,
|
||||||
|
yearFilterAtom,
|
||||||
|
} from "@/utils/atoms/filters";
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -169,7 +168,7 @@ const Page = () => {
|
|||||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||||
genres: selectedGenres,
|
genres: selectedGenres,
|
||||||
tags: selectedTags,
|
tags: selectedTags,
|
||||||
years: selectedYears.map((year) => Number.parseInt(year)),
|
years: selectedYears.map((year) => Number.parseInt(year, 10)),
|
||||||
includeItemTypes: itemType ? [itemType] : undefined,
|
includeItemTypes: itemType ? [itemType] : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
@@ -18,7 +20,7 @@ export default function IndexLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.library"),
|
headerTitle: t("tabs.library"),
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
@@ -198,7 +200,7 @@ export default function IndexLayout() {
|
|||||||
name='[libraryId]'
|
name='[libraryId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -211,7 +213,7 @@ export default function IndexLayout() {
|
|||||||
name='collections/[collectionId]'
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import {
|
import {
|
||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
getUserViewsApi,
|
getUserViewsApi,
|
||||||
@@ -14,6 +9,11 @@ import { useEffect, useMemo } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
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 { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function index() {
|
export default function index() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import {
|
import {
|
||||||
commonScreenOptions,
|
commonScreenOptions,
|
||||||
nestedTabPageScreenOptions,
|
nestedTabPageScreenOptions,
|
||||||
} from "@/components/stacks/NestedTabPageStack";
|
} from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Stack } from "expo-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
|
|
||||||
export default function SearchLayout() {
|
export default function SearchLayout() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -13,7 +13,7 @@ export default function SearchLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.search"),
|
headerTitle: t("tabs.search"),
|
||||||
headerLargeStyle: {
|
headerLargeStyle: {
|
||||||
@@ -31,7 +31,7 @@ export default function SearchLayout() {
|
|||||||
name='collections/[collectionId]'
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
|
|||||||
@@ -1,9 +1,32 @@
|
|||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemKind,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import axios from "axios";
|
||||||
|
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import { Tag } from "@/components/GenreTags";
|
import { Input } from "@/components/common/Input";
|
||||||
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 { Tag } from "@/components/GenreTags";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import {
|
import {
|
||||||
JellyseerrSearchSort,
|
JellyseerrSearchSort,
|
||||||
JellyserrIndexPage,
|
JellyserrIndexPage,
|
||||||
@@ -16,27 +39,6 @@ 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 { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
import type {
|
|
||||||
BaseItemDto,
|
|
||||||
BaseItemKind,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import axios from "axios";
|
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { useDebounce } from "use-debounce";
|
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
@@ -57,6 +59,9 @@ export default function search() {
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const searchFilterId = useId();
|
||||||
|
const orderFilterId = useId();
|
||||||
|
|
||||||
const { q } = params as { q: string };
|
const { q } = params as { q: string };
|
||||||
|
|
||||||
const [searchType, setSearchType] = useState<SearchType>("Library");
|
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||||
@@ -249,205 +254,223 @@ export default function search() {
|
|||||||
}, [l1, l2, l3, l7, l8]);
|
}, [l1, l2, l3, l7, l8]);
|
||||||
|
|
||||||
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'
|
||||||
|
style={{
|
||||||
|
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||||
|
}}
|
||||||
|
> */}
|
||||||
|
{Platform.isTV && (
|
||||||
|
<Input
|
||||||
|
placeholder={t("search.search")}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
router.setParams({ q: "" });
|
||||||
|
setSearch(text);
|
||||||
|
}}
|
||||||
|
keyboardType='default'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
clearButtonMode='while-editing'
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
className='flex flex-col'
|
||||||
|
style={{
|
||||||
|
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
{jellyseerrApi && (
|
||||||
className='flex flex-col'
|
<ScrollView
|
||||||
style={{
|
horizontal
|
||||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
||||||
}}
|
>
|
||||||
>
|
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||||
{jellyseerrApi && (
|
<Tag
|
||||||
<ScrollView
|
text={t("search.library")}
|
||||||
horizontal
|
textClass='p-1'
|
||||||
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
className={
|
||||||
>
|
searchType === "Library" ? "bg-purple-600" : undefined
|
||||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
}
|
||||||
<Tag
|
/>
|
||||||
text={t("search.library")}
|
</TouchableOpacity>
|
||||||
textClass='p-1'
|
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||||
className={
|
<Tag
|
||||||
searchType === "Library" ? "bg-purple-600" : undefined
|
text={t("search.discover")}
|
||||||
}
|
textClass='p-1'
|
||||||
/>
|
className={
|
||||||
</TouchableOpacity>
|
searchType === "Discover" ? "bg-purple-600" : undefined
|
||||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
}
|
||||||
<Tag
|
/>
|
||||||
text={t("search.discover")}
|
</TouchableOpacity>
|
||||||
textClass='p-1'
|
{searchType === "Discover" &&
|
||||||
className={
|
!loading &&
|
||||||
searchType === "Discover" ? "bg-purple-600" : undefined
|
noResults &&
|
||||||
}
|
debouncedSearch.length > 0 && (
|
||||||
/>
|
<View className='flex flex-row justify-end items-center space-x-1'>
|
||||||
</TouchableOpacity>
|
<FilterButton
|
||||||
{searchType === "Discover" &&
|
id={searchFilterId}
|
||||||
!loading &&
|
queryKey='jellyseerr_search'
|
||||||
noResults &&
|
queryFn={async () =>
|
||||||
debouncedSearch.length > 0 && (
|
Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||||
<View className='flex flex-row justify-end items-center space-x-1'>
|
Number.isNaN(Number(v)),
|
||||||
<FilterButton
|
)
|
||||||
id='search'
|
}
|
||||||
queryKey='jellyseerr_search'
|
set={(value) => setJellyseerrOrderBy(value[0])}
|
||||||
queryFn={async () =>
|
values={[jellyseerrOrderBy]}
|
||||||
Object.keys(JellyseerrSearchSort).filter((v) =>
|
title={t("library.filters.sort_by")}
|
||||||
Number.isNaN(Number(v)),
|
renderItemLabel={(item) =>
|
||||||
)
|
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
||||||
}
|
}
|
||||||
set={(value) => setJellyseerrOrderBy(value[0])}
|
showSearch={false}
|
||||||
values={[jellyseerrOrderBy]}
|
/>
|
||||||
title={t("library.filters.sort_by")}
|
<FilterButton
|
||||||
renderItemLabel={(item) =>
|
id={orderFilterId}
|
||||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
queryKey='jellysearr_search'
|
||||||
}
|
queryFn={async () => ["asc", "desc"]}
|
||||||
showSearch={false}
|
set={(value) => setJellyseerrSortOrder(value[0])}
|
||||||
/>
|
values={[jellyseerrSortOrder]}
|
||||||
<FilterButton
|
title={t("library.filters.sort_order")}
|
||||||
id='order'
|
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
||||||
queryKey='jellysearr_search'
|
showSearch={false}
|
||||||
queryFn={async () => ["asc", "desc"]}
|
/>
|
||||||
set={(value) => setJellyseerrSortOrder(value[0])}
|
</View>
|
||||||
values={[jellyseerrSortOrder]}
|
)}
|
||||||
title={t("library.filters.sort_order")}
|
</ScrollView>
|
||||||
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>
|
|
||||||
|
|
||||||
{searchType === "Library" ? (
|
|
||||||
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
|
||||||
<SearchItemWrapper
|
|
||||||
header={t("search.movies")}
|
|
||||||
items={movies}
|
|
||||||
renderItem={(item: BaseItemDto) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
key={item.Id}
|
|
||||||
className='flex flex-col w-28 mr-2'
|
|
||||||
item={item}
|
|
||||||
>
|
|
||||||
<MoviePoster item={item} key={item.Id} />
|
|
||||||
<Text numberOfLines={2} className='mt-2'>
|
|
||||||
{item.Name}
|
|
||||||
</Text>
|
|
||||||
<Text className='opacity-50 text-xs'>
|
|
||||||
{item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SearchItemWrapper
|
|
||||||
items={series}
|
|
||||||
header={t("search.series")}
|
|
||||||
renderItem={(item: BaseItemDto) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
key={item.Id}
|
|
||||||
item={item}
|
|
||||||
className='flex flex-col w-28 mr-2'
|
|
||||||
>
|
|
||||||
<SeriesPoster item={item} key={item.Id} />
|
|
||||||
<Text numberOfLines={2} className='mt-2'>
|
|
||||||
{item.Name}
|
|
||||||
</Text>
|
|
||||||
<Text className='opacity-50 text-xs'>
|
|
||||||
{item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SearchItemWrapper
|
|
||||||
items={episodes}
|
|
||||||
header={t("search.episodes")}
|
|
||||||
renderItem={(item: BaseItemDto) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item}
|
|
||||||
key={item.Id}
|
|
||||||
className='flex flex-col w-44 mr-2'
|
|
||||||
>
|
|
||||||
<ContinueWatchingPoster item={item} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SearchItemWrapper
|
|
||||||
items={collections}
|
|
||||||
header={t("search.collections")}
|
|
||||||
renderItem={(item: BaseItemDto) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
key={item.Id}
|
|
||||||
item={item}
|
|
||||||
className='flex flex-col w-28 mr-2'
|
|
||||||
>
|
|
||||||
<MoviePoster item={item} key={item.Id} />
|
|
||||||
<Text numberOfLines={2} className='mt-2'>
|
|
||||||
{item.Name}
|
|
||||||
</Text>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SearchItemWrapper
|
|
||||||
items={actors}
|
|
||||||
header={t("search.actors")}
|
|
||||||
renderItem={(item: BaseItemDto) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item}
|
|
||||||
key={item.Id}
|
|
||||||
className='flex flex-col w-28 mr-2'
|
|
||||||
>
|
|
||||||
<MoviePoster item={item} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<JellyserrIndexPage
|
|
||||||
searchQuery={debouncedSearch}
|
|
||||||
sortType={jellyseerrOrderBy}
|
|
||||||
order={jellyseerrSortOrder}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{searchType === "Library" &&
|
|
||||||
(!loading && noResults && debouncedSearch.length > 0 ? (
|
|
||||||
<View>
|
|
||||||
<Text className='text-center text-lg font-bold mt-4'>
|
|
||||||
{t("search.no_results_found_for")}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-xs text-purple-600 text-center'>
|
|
||||||
"{debouncedSearch}"
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : debouncedSearch.length === 0 ? (
|
|
||||||
<View className='mt-4 flex flex-col items-center space-y-2'>
|
|
||||||
{exampleSearches.map((e) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
setSearch(e);
|
|
||||||
searchBarRef.current?.setText(e);
|
|
||||||
}}
|
|
||||||
key={e}
|
|
||||||
className='mb-2'
|
|
||||||
>
|
|
||||||
<Text className='text-purple-600'>{e}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : null)}
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
|
||||||
</>
|
{searchType === "Library" ? (
|
||||||
|
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||||
|
<SearchItemWrapper
|
||||||
|
header={t("search.movies")}
|
||||||
|
items={movies}
|
||||||
|
renderItem={(item: BaseItemDto) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={item.Id}
|
||||||
|
className='flex flex-col w-28 mr-2'
|
||||||
|
item={item}
|
||||||
|
>
|
||||||
|
<MoviePoster item={item} key={item.Id} />
|
||||||
|
<Text numberOfLines={2} className='mt-2'>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='opacity-50 text-xs'>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
items={series}
|
||||||
|
header={t("search.series")}
|
||||||
|
renderItem={(item: BaseItemDto) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={item.Id}
|
||||||
|
item={item}
|
||||||
|
className='flex flex-col w-28 mr-2'
|
||||||
|
>
|
||||||
|
<SeriesPoster item={item} key={item.Id} />
|
||||||
|
<Text numberOfLines={2} className='mt-2'>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='opacity-50 text-xs'>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
items={episodes}
|
||||||
|
header={t("search.episodes")}
|
||||||
|
renderItem={(item: BaseItemDto) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
key={item.Id}
|
||||||
|
className='flex flex-col w-44 mr-2'
|
||||||
|
>
|
||||||
|
<ContinueWatchingPoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
items={collections}
|
||||||
|
header={t("search.collections")}
|
||||||
|
renderItem={(item: BaseItemDto) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={item.Id}
|
||||||
|
item={item}
|
||||||
|
className='flex flex-col w-28 mr-2'
|
||||||
|
>
|
||||||
|
<MoviePoster item={item} key={item.Id} />
|
||||||
|
<Text numberOfLines={2} className='mt-2'>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
items={actors}
|
||||||
|
header={t("search.actors")}
|
||||||
|
renderItem={(item: BaseItemDto) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
key={item.Id}
|
||||||
|
className='flex flex-col w-28 mr-2'
|
||||||
|
>
|
||||||
|
<MoviePoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<JellyserrIndexPage
|
||||||
|
searchQuery={debouncedSearch}
|
||||||
|
sortType={jellyseerrOrderBy}
|
||||||
|
order={jellyseerrSortOrder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchType === "Library" &&
|
||||||
|
(!loading && noResults && debouncedSearch.length > 0 ? (
|
||||||
|
<View>
|
||||||
|
<Text className='text-center text-lg font-bold mt-4'>
|
||||||
|
{t("search.no_results_found_for")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-xs text-purple-600 text-center'>
|
||||||
|
"{debouncedSearch}"
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : debouncedSearch.length === 0 ? (
|
||||||
|
<View className='mt-4 flex flex-col items-center space-y-2'>
|
||||||
|
{exampleSearches.map((e) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
setSearch(e);
|
||||||
|
searchBarRef.current?.setText(e);
|
||||||
|
}}
|
||||||
|
key={e}
|
||||||
|
className='mb-2'
|
||||||
|
>
|
||||||
|
<Text className='text-purple-600'>{e}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,26 @@
|
|||||||
import React, { useCallback, useRef } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
|
|
||||||
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type NativeBottomTabNavigationEventMap,
|
|
||||||
createNativeBottomTabNavigator,
|
createNativeBottomTabNavigator,
|
||||||
|
type NativeBottomTabNavigationEventMap,
|
||||||
|
type NativeBottomTabNavigationOptions,
|
||||||
} from "@bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
|
|
||||||
const { Navigator } = createNativeBottomTabNavigator();
|
|
||||||
import type { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
|
||||||
|
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { eventBus } from "@/utils/eventBus";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
|
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
|
const { Navigator } = createNativeBottomTabNavigator();
|
||||||
|
|
||||||
export const NativeTabs = withLayoutContext<
|
export const NativeTabs = withLayoutContext<
|
||||||
BottomTabNavigationOptions,
|
NativeBottomTabNavigationOptions,
|
||||||
typeof Navigator,
|
typeof Navigator,
|
||||||
TabNavigationState<ParamListBase>,
|
TabNavigationState<ParamListBase>,
|
||||||
NativeBottomTabNavigationEventMap
|
NativeBottomTabNavigationEventMap
|
||||||
@@ -54,7 +51,6 @@ export default function TabLayout() {
|
|||||||
<SystemBars hidden={false} style='light' />
|
<SystemBars hidden={false} style='light' />
|
||||||
<NativeTabs
|
<NativeTabs
|
||||||
sidebarAdaptable={false}
|
sidebarAdaptable={false}
|
||||||
ignoresTopSafeArea
|
|
||||||
tabBarStyle={{
|
tabBarStyle={{
|
||||||
backgroundColor: "#121212",
|
backgroundColor: "#121212",
|
||||||
}}
|
}}
|
||||||
@@ -63,8 +59,8 @@ export default function TabLayout() {
|
|||||||
>
|
>
|
||||||
<NativeTabs.Screen redirect name='index' />
|
<NativeTabs.Screen redirect name='index' />
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
listeners={({ navigation }) => ({
|
listeners={(_e) => ({
|
||||||
tabPress: (e) => {
|
tabPress: (_e) => {
|
||||||
eventBus.emit("scrollToTop");
|
eventBus.emit("scrollToTop");
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
@@ -73,8 +69,7 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.home"),
|
title: t("tabs.home"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ color, focused, size }) =>
|
? (_e) => require("@/assets/icons/house.fill.png")
|
||||||
require("@/assets/icons/house.fill.png")
|
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
focused
|
focused
|
||||||
? { sfSymbol: "house.fill" }
|
? { sfSymbol: "house.fill" }
|
||||||
@@ -82,8 +77,8 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
listeners={({ navigation }) => ({
|
listeners={(_e) => ({
|
||||||
tabPress: (e) => {
|
tabPress: (_e) => {
|
||||||
eventBus.emit("searchTabPressed");
|
eventBus.emit("searchTabPressed");
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
@@ -92,8 +87,7 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.search"),
|
title: t("tabs.search"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ color, focused, size }) =>
|
? (_e) => require("@/assets/icons/magnifyingglass.png")
|
||||||
require("@/assets/icons/magnifyingglass.png")
|
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
focused
|
focused
|
||||||
? { sfSymbol: "magnifyingglass" }
|
? { sfSymbol: "magnifyingglass" }
|
||||||
@@ -106,7 +100,7 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.favorites"),
|
title: t("tabs.favorites"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ focused }) =>
|
||||||
focused
|
focused
|
||||||
? require("@/assets/icons/heart.fill.png")
|
? require("@/assets/icons/heart.fill.png")
|
||||||
: require("@/assets/icons/heart.png")
|
: require("@/assets/icons/heart.png")
|
||||||
@@ -122,8 +116,7 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.library"),
|
title: t("tabs.library"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ color, focused, size }) =>
|
? (_e) => require("@/assets/icons/server.rack.png")
|
||||||
require("@/assets/icons/server.rack.png")
|
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
focused
|
focused
|
||||||
? { sfSymbol: "rectangle.stack.fill" }
|
? { sfSymbol: "rectangle.stack.fill" }
|
||||||
@@ -134,11 +127,10 @@ export default function TabLayout() {
|
|||||||
name='(custom-links)'
|
name='(custom-links)'
|
||||||
options={{
|
options={{
|
||||||
title: t("tabs.custom_links"),
|
title: t("tabs.custom_links"),
|
||||||
// @ts-expect-error
|
|
||||||
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ focused }) => require("@/assets/icons/list.png")
|
? (_e) => require("@/assets/icons/list.png")
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
focused
|
focused
|
||||||
? { sfSymbol: "list.dash.fill" }
|
? { sfSymbol: "list.dash.fill" }
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import React from "react";
|
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
type BaseItemDto,
|
type BaseItemDto,
|
||||||
type MediaSourceInfo,
|
type MediaSourceInfo,
|
||||||
PlaybackOrder,
|
PlaybackOrder,
|
||||||
type PlaybackProgressInfo,
|
|
||||||
PlaybackStartInfo,
|
PlaybackStartInfo,
|
||||||
RepeatMode,
|
RepeatMode,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
@@ -16,14 +15,14 @@ import { useAtomValue } from "jotai";
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, View } from "react-native";
|
import { Alert, Platform, View } from "react-native";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
import { Controls } from "@/components/video-player/controls/Controls";
|
||||||
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
import { VlcPlayerView } from "@/modules";
|
import { VlcPlayerView } from "@/modules";
|
||||||
@@ -33,20 +32,15 @@ import type {
|
|||||||
ProgressUpdatePayload,
|
ProgressUpdatePayload,
|
||||||
VlcPlayerViewRef,
|
VlcPlayerViewRef,
|
||||||
} from "@/modules/VlcPlayer.types";
|
} from "@/modules/VlcPlayer.types";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
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 { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||||
import generateDeviceProfile from "@/utils/profiles/native";
|
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
|
|
||||||
const downloadProvider = !Platform.isTV
|
|
||||||
? require("@/providers/DownloadProvider")
|
|
||||||
: { useDownload: () => null };
|
|
||||||
|
|
||||||
const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
@@ -56,11 +50,12 @@ export default function page() {
|
|||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(() => {
|
const [aspectRatio, setAspectRatio] = useState<
|
||||||
// Load persisted state from storage
|
"default" | "16:9" | "4:3" | "1:1" | "21:9"
|
||||||
const saved = storage.getBoolean(IGNORE_SAFE_AREAS_KEY);
|
>("default");
|
||||||
return saved ?? false;
|
const [scaleFactor, setScaleFactor] = useState<
|
||||||
});
|
1.0 | 1.1 | 1.2 | 1.3 | 1.4 | 1.5 | 1.6 | 1.7 | 1.8 | 1.9 | 2.0
|
||||||
|
>(1.0);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
@@ -74,7 +69,7 @@ export default function page() {
|
|||||||
? null
|
? null
|
||||||
: require("react-native-volume-manager");
|
: require("react-native-volume-manager");
|
||||||
|
|
||||||
const getDownloadedItem = downloadProvider.useDownload();
|
const downloadUtils = useDownload();
|
||||||
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
@@ -85,11 +80,6 @@ export default function page() {
|
|||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Persist ignoreSafeAreas state whenever it changes
|
|
||||||
useEffect(() => {
|
|
||||||
storage.set(IGNORE_SAFE_AREAS_KEY, ignoreSafeAreas);
|
|
||||||
}, [ignoreSafeAreas]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
itemId,
|
itemId,
|
||||||
audioIndex: audioIndexStr,
|
audioIndex: audioIndexStr,
|
||||||
@@ -108,9 +98,10 @@ export default function page() {
|
|||||||
/** Playback position in ticks. */
|
/** Playback position in ticks. */
|
||||||
playbackPosition?: string;
|
playbackPosition?: string;
|
||||||
}>();
|
}>();
|
||||||
const [settings] = useSettings();
|
const [_settings] = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
|
const playbackManager = usePlaybackManager();
|
||||||
|
|
||||||
const audioIndex = audioIndexStr
|
const audioIndex = audioIndexStr
|
||||||
? Number.parseInt(audioIndexStr, 10)
|
? Number.parseInt(audioIndexStr, 10)
|
||||||
@@ -123,18 +114,21 @@ export default function page() {
|
|||||||
: BITRATES[0].value;
|
: BITRATES[0].value;
|
||||||
|
|
||||||
const [item, setItem] = useState<BaseItemDto | null>(null);
|
const [item, setItem] = useState<BaseItemDto | null>(null);
|
||||||
|
const [downloadedItem, setDownloadedItem] = useState<DownloadedItem | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [itemStatus, setItemStatus] = useState({
|
const [itemStatus, setItemStatus] = useState({
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isError: false,
|
isError: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Gets the initial playback position from the URL or the item's user data. */
|
/** Gets the initial playback position from the URL. */
|
||||||
const getInitialPlaybackTicks = useCallback((): number => {
|
const getInitialPlaybackTicks = useCallback((): number => {
|
||||||
if (playbackPositionFromUrl) {
|
if (playbackPositionFromUrl) {
|
||||||
return Number.parseInt(playbackPositionFromUrl, 10);
|
return Number.parseInt(playbackPositionFromUrl, 10);
|
||||||
}
|
}
|
||||||
return item?.UserData?.PlaybackPositionTicks ?? 0;
|
return item?.UserData?.PlaybackPositionTicks ?? 0;
|
||||||
}, [playbackPositionFromUrl, item]);
|
}, [playbackPositionFromUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchItemData = async () => {
|
const fetchItemData = async () => {
|
||||||
@@ -142,8 +136,11 @@ export default function page() {
|
|||||||
try {
|
try {
|
||||||
let fetchedItem: BaseItemDto | null = null;
|
let fetchedItem: BaseItemDto | null = null;
|
||||||
if (offline && !Platform.isTV) {
|
if (offline && !Platform.isTV) {
|
||||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
const data = downloadUtils.getDownloadedItemById(itemId);
|
||||||
if (data) fetchedItem = data.item as BaseItemDto;
|
if (data) {
|
||||||
|
fetchedItem = data.item as BaseItemDto;
|
||||||
|
setDownloadedItem(data);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const res = await getUserLibraryApi(api!).getItem({
|
const res = await getUserLibraryApi(api!).getItem({
|
||||||
itemId,
|
itemId,
|
||||||
@@ -179,17 +176,20 @@ export default function page() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStreamData = async () => {
|
const fetchStreamData = async () => {
|
||||||
setStreamStatus({ isLoading: true, isError: false });
|
setStreamStatus({ isLoading: true, isError: false });
|
||||||
const native = await generateDeviceProfile();
|
|
||||||
try {
|
try {
|
||||||
let result: Stream | null = null;
|
let result: Stream | null = null;
|
||||||
if (offline && !Platform.isTV) {
|
if (offline && downloadedItem && downloadedItem.mediaSource) {
|
||||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
const url = downloadedItem.videoFilePath;
|
||||||
if (!data?.mediaSource) return;
|
|
||||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
|
||||||
if (item) {
|
if (item) {
|
||||||
result = { mediaSource: data.mediaSource, sessionId: "", url };
|
result = {
|
||||||
|
mediaSource: downloadedItem.mediaSource,
|
||||||
|
sessionId: "",
|
||||||
|
url: url,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
const native = generateDeviceProfile();
|
||||||
|
const transcoding = generateDeviceProfile({ transcode: true });
|
||||||
const res = await getStreamUrl({
|
const res = await getStreamUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
@@ -199,7 +199,7 @@ export default function page() {
|
|||||||
maxStreamingBitrate: bitrateValue,
|
maxStreamingBitrate: bitrateValue,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
subtitleStreamIndex: subtitleIndex,
|
subtitleStreamIndex: subtitleIndex,
|
||||||
deviceProfile: native,
|
deviceProfile: bitrateValue ? transcoding : native,
|
||||||
});
|
});
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
const { mediaSource, sessionId, url } = res;
|
const { mediaSource, sessionId, url } = res;
|
||||||
@@ -220,26 +220,39 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchStreamData();
|
fetchStreamData();
|
||||||
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
|
}, [
|
||||||
|
itemId,
|
||||||
|
mediaSourceId,
|
||||||
|
bitrateValue,
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
user?.Id,
|
||||||
|
downloadedItem,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!stream) return;
|
if (!stream || !api) return;
|
||||||
|
|
||||||
const reportPlaybackStart = async () => {
|
const reportPlaybackStart = async () => {
|
||||||
await getPlaystateApi(api!).reportPlaybackStart({
|
await getPlaystateApi(api).reportPlaybackStart({
|
||||||
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
reportPlaybackStart();
|
reportPlaybackStart();
|
||||||
}, [stream]);
|
}, [stream, api]);
|
||||||
|
|
||||||
const togglePlay = async () => {
|
const togglePlay = async () => {
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
setIsPlaying(!isPlaying);
|
setIsPlaying(!isPlaying);
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
reportPlaybackProgress();
|
playbackManager.reportPlaybackProgress(
|
||||||
|
item?.Id!,
|
||||||
|
msToTicks(progress.get()),
|
||||||
|
{
|
||||||
|
AudioStreamIndex: audioIndex ?? -1,
|
||||||
|
SubtitleStreamIndex: subtitleIndex ?? -1,
|
||||||
|
},
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
videoRef.current?.play();
|
videoRef.current?.play();
|
||||||
await getPlaystateApi(api!).reportPlaybackStart({
|
await getPlaystateApi(api!).reportPlaybackStart({
|
||||||
@@ -249,7 +262,6 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const reportPlaybackStopped = useCallback(async () => {
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
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!,
|
||||||
@@ -257,8 +269,6 @@ export default function page() {
|
|||||||
positionTicks: currentTimeInTicks,
|
positionTicks: currentTimeInTicks,
|
||||||
playSessionId: stream?.sessionId!,
|
playSessionId: stream?.sessionId!,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidateProgressCache();
|
|
||||||
}, [
|
}, [
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
@@ -270,10 +280,15 @@ export default function page() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
|
// Update URL with final playback position before stopping
|
||||||
|
router.setParams({
|
||||||
|
playbackPosition: msToTicks(progress.get()).toString(),
|
||||||
|
});
|
||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
setIsPlaybackStopped(true);
|
setIsPlaybackStopped(true);
|
||||||
videoRef.current?.stop();
|
videoRef.current?.stop();
|
||||||
}, [videoRef, reportPlaybackStopped]);
|
revalidateProgressCache();
|
||||||
|
}, [videoRef, reportPlaybackStopped, progress]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||||
@@ -282,7 +297,7 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
}, [navigation, stop]);
|
}, [navigation, stop]);
|
||||||
|
|
||||||
const currentPlayStateInfo = () => {
|
const currentPlayStateInfo = useCallback(() => {
|
||||||
if (!stream) return;
|
if (!stream) return;
|
||||||
return {
|
return {
|
||||||
itemId: item?.Id!,
|
itemId: item?.Id!,
|
||||||
@@ -298,7 +313,32 @@ export default function page() {
|
|||||||
repeatMode: RepeatMode.RepeatNone,
|
repeatMode: RepeatMode.RepeatNone,
|
||||||
playbackOrder: PlaybackOrder.Default,
|
playbackOrder: PlaybackOrder.Default,
|
||||||
};
|
};
|
||||||
};
|
}, [
|
||||||
|
stream,
|
||||||
|
item?.Id,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
|
mediaSourceId,
|
||||||
|
progress,
|
||||||
|
isPlaying,
|
||||||
|
isMuted,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const lastUrlUpdateTime = useSharedValue(0);
|
||||||
|
const wasJustSeeking = useSharedValue(false);
|
||||||
|
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
|
||||||
|
|
||||||
|
// Track when seeking ends to update URL immediately
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => isSeeking.get(),
|
||||||
|
(currentSeeking, previousSeeking) => {
|
||||||
|
if (previousSeeking && !currentSeeking) {
|
||||||
|
// Seeking just ended
|
||||||
|
wasJustSeeking.value = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const onProgress = useCallback(
|
const onProgress = useCallback(
|
||||||
async (data: ProgressUpdatePayload) => {
|
async (data: ProgressUpdatePayload) => {
|
||||||
@@ -311,15 +351,31 @@ export default function page() {
|
|||||||
|
|
||||||
progress.set(currentTime);
|
progress.set(currentTime);
|
||||||
|
|
||||||
// Update the playback position in the URL.
|
// Update URL immediately after seeking, or every 30 seconds during normal playback
|
||||||
router.setParams({
|
const now = Date.now();
|
||||||
playbackPosition: msToTicks(currentTime).toString(),
|
const shouldUpdateUrl = wasJustSeeking.get();
|
||||||
});
|
wasJustSeeking.value = false;
|
||||||
|
|
||||||
if (offline) return;
|
if (
|
||||||
if (!item?.Id || !stream) return;
|
shouldUpdateUrl ||
|
||||||
|
now - lastUrlUpdateTime.get() > URL_UPDATE_INTERVAL
|
||||||
|
) {
|
||||||
|
router.setParams({
|
||||||
|
playbackPosition: msToTicks(currentTime).toString(),
|
||||||
|
});
|
||||||
|
lastUrlUpdateTime.value = now;
|
||||||
|
}
|
||||||
|
|
||||||
reportPlaybackProgress();
|
if (!item?.Id) return;
|
||||||
|
|
||||||
|
playbackManager.reportPlaybackProgress(
|
||||||
|
item.Id,
|
||||||
|
msToTicks(progress.get()),
|
||||||
|
{
|
||||||
|
AudioStreamIndex: audioIndex ?? -1,
|
||||||
|
SubtitleStreamIndex: subtitleIndex ?? -1,
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
item?.Id,
|
item?.Id,
|
||||||
@@ -339,28 +395,10 @@ export default function page() {
|
|||||||
setIsPipStarted(pipStarted);
|
setIsPipStarted(pipStarted);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const reportPlaybackProgress = useCallback(async () => {
|
|
||||||
if (!api || offline || !stream) return;
|
|
||||||
await getPlaystateApi(api).reportPlaybackProgress({
|
|
||||||
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
api,
|
|
||||||
isPlaying,
|
|
||||||
offline,
|
|
||||||
stream,
|
|
||||||
item?.Id,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
progress,
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** Gets the initial playback position in seconds. */
|
/** Gets the initial playback position in seconds. */
|
||||||
const startPosition = useMemo(() => {
|
const startPosition = useMemo(() => {
|
||||||
if (offline) return 0;
|
|
||||||
return ticksToSeconds(getInitialPlaybackTicks());
|
return ticksToSeconds(getInitialPlaybackTicks());
|
||||||
}, [offline, getInitialPlaybackTicks]);
|
}, [getInitialPlaybackTicks]);
|
||||||
|
|
||||||
const volumeUpCb = useCallback(async () => {
|
const volumeUpCb = useCallback(async () => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
@@ -399,6 +437,7 @@ export default function page() {
|
|||||||
console.error("Error toggling mute:", error);
|
console.error("Error toggling mute:", error);
|
||||||
}
|
}
|
||||||
}, [previousVolume]);
|
}, [previousVolume]);
|
||||||
|
|
||||||
const volumeDownCb = useCallback(async () => {
|
const volumeDownCb = useCallback(async () => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
@@ -445,14 +484,32 @@ export default function page() {
|
|||||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||||
if (state === "Playing") {
|
if (state === "Playing") {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
reportPlaybackProgress();
|
if (item?.Id) {
|
||||||
|
playbackManager.reportPlaybackProgress(
|
||||||
|
item.Id,
|
||||||
|
msToTicks(progress.get()),
|
||||||
|
{
|
||||||
|
AudioStreamIndex: audioIndex ?? -1,
|
||||||
|
SubtitleStreamIndex: subtitleIndex ?? -1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!Platform.isTV) await activateKeepAwakeAsync();
|
if (!Platform.isTV) await activateKeepAwakeAsync();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === "Paused") {
|
if (state === "Paused") {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
reportPlaybackProgress();
|
if (item?.Id) {
|
||||||
|
playbackManager.reportPlaybackProgress(
|
||||||
|
item.Id,
|
||||||
|
msToTicks(progress.get()),
|
||||||
|
{
|
||||||
|
AudioStreamIndex: audioIndex ?? -1,
|
||||||
|
SubtitleStreamIndex: subtitleIndex ?? -1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!Platform.isTV) await deactivateKeepAwake();
|
if (!Platform.isTV) await deactivateKeepAwake();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -464,7 +521,7 @@ export default function page() {
|
|||||||
setIsBuffering(true);
|
setIsBuffering(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[reportPlaybackProgress],
|
[playbackManager, item?.Id, progress],
|
||||||
);
|
);
|
||||||
|
|
||||||
const allAudio =
|
const allAudio =
|
||||||
@@ -482,25 +539,29 @@ export default function page() {
|
|||||||
.filter((sub: any) => sub.DeliveryMethod === "External")
|
.filter((sub: any) => sub.DeliveryMethod === "External")
|
||||||
.map((sub: any) => ({
|
.map((sub: any) => ({
|
||||||
name: sub.DisplayTitle,
|
name: sub.DisplayTitle,
|
||||||
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
|
DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
|
||||||
}));
|
}));
|
||||||
|
/** The text based subtitle tracks */
|
||||||
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
||||||
|
/** The user chosen subtitle track from the server */
|
||||||
const chosenSubtitleTrack = allSubs.find(
|
const chosenSubtitleTrack = allSubs.find(
|
||||||
(sub) => sub.Index === subtitleIndex,
|
(sub) => sub.Index === subtitleIndex,
|
||||||
);
|
);
|
||||||
|
/** The user chosen audio track from the server */
|
||||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||||
|
/** Whether the stream we're playing is not transcoding*/
|
||||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||||
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
/** The initial options to pass to the VLC Player */
|
||||||
|
const initOptions = [``];
|
||||||
if (
|
if (
|
||||||
chosenSubtitleTrack &&
|
chosenSubtitleTrack &&
|
||||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||||
) {
|
) {
|
||||||
|
// If not transcoding, we can the index as normal.
|
||||||
|
// If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
|
||||||
const finalIndex = notTranscoding
|
const finalIndex = notTranscoding
|
||||||
? allSubs.indexOf(chosenSubtitleTrack)
|
? allSubs.indexOf(chosenSubtitleTrack)
|
||||||
: textSubs.indexOf(chosenSubtitleTrack);
|
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
|
||||||
initOptions.push(`--sub-track=${finalIndex}`);
|
initOptions.push(`--sub-track=${finalIndex}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,7 +577,66 @@ export default function page() {
|
|||||||
return () => setIsMounted(false);
|
return () => setIsMounted(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (itemStatus.isLoading || streamStatus.isLoading) {
|
// Memoize video ref functions to prevent unnecessary re-renders
|
||||||
|
const startPictureInPicture = useMemo(
|
||||||
|
() => videoRef.current?.startPictureInPicture,
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
const play = useMemo(
|
||||||
|
() => videoRef.current?.play || (() => {}),
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
const pause = useMemo(
|
||||||
|
() => videoRef.current?.pause || (() => {}),
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
const seek = useMemo(
|
||||||
|
() => videoRef.current?.seekTo || (() => {}),
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
const getAudioTracks = useMemo(
|
||||||
|
() => videoRef.current?.getAudioTracks,
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
const getSubtitleTracks = useMemo(
|
||||||
|
() => videoRef.current?.getSubtitleTracks,
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
const setSubtitleTrack = useMemo(
|
||||||
|
() => videoRef.current?.setSubtitleTrack,
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
const setSubtitleURL = useMemo(
|
||||||
|
() => videoRef.current?.setSubtitleURL,
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
const setAudioTrack = useMemo(
|
||||||
|
() => videoRef.current?.setAudioTrack,
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
const setVideoAspectRatio = useMemo(
|
||||||
|
() => videoRef.current?.setVideoAspectRatio,
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
const setVideoScaleFactor = useMemo(
|
||||||
|
() => videoRef.current?.setVideoScaleFactor,
|
||||||
|
[isVideoLoaded],
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Debug: component render"); // Uncomment to debug re-renders
|
||||||
|
|
||||||
|
// Show error UI first, before checking loading/missing‐data
|
||||||
|
if (itemStatus.isError || streamStatus.isError) {
|
||||||
|
return (
|
||||||
|
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
||||||
|
<Text className='text-white'>{t("player.error")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then show loader while either side is still fetching or data isn’t present
|
||||||
|
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
|
||||||
|
// …loader UI…
|
||||||
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 />
|
||||||
@@ -532,7 +652,14 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "black",
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -541,8 +668,6 @@ export default function page() {
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
|
|
||||||
paddingRight: ignoreSafeAreas ? 0 : insets.right,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VlcPlayerView
|
<VlcPlayerView
|
||||||
@@ -550,7 +675,7 @@ export default function page() {
|
|||||||
source={{
|
source={{
|
||||||
uri: stream?.url || "",
|
uri: stream?.url || "",
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
isNetwork: true,
|
isNetwork: !offline,
|
||||||
startPosition,
|
startPosition,
|
||||||
externalSubtitles,
|
externalSubtitles,
|
||||||
initOptions,
|
initOptions,
|
||||||
@@ -573,7 +698,7 @@ export default function page() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{videoRef.current && !isPipStarted && isMounted === true && item ? (
|
{!isPipStarted && isMounted === true && item && (
|
||||||
<Controls
|
<Controls
|
||||||
mediaSource={stream?.mediaSource}
|
mediaSource={stream?.mediaSource}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -586,23 +711,27 @@ export default function page() {
|
|||||||
isBuffering={isBuffering}
|
isBuffering={isBuffering}
|
||||||
showControls={showControls}
|
showControls={showControls}
|
||||||
setShowControls={setShowControls}
|
setShowControls={setShowControls}
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
|
||||||
isVideoLoaded={isVideoLoaded}
|
isVideoLoaded={isVideoLoaded}
|
||||||
startPictureInPicture={videoRef?.current?.startPictureInPicture}
|
startPictureInPicture={startPictureInPicture}
|
||||||
play={videoRef.current?.play}
|
play={play}
|
||||||
pause={videoRef.current?.pause}
|
pause={pause}
|
||||||
seek={videoRef.current?.seekTo}
|
seek={seek}
|
||||||
enableTrickplay={true}
|
enableTrickplay={true}
|
||||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
getAudioTracks={getAudioTracks}
|
||||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
getSubtitleTracks={getSubtitleTracks}
|
||||||
offline={offline}
|
offline={offline}
|
||||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
setSubtitleTrack={setSubtitleTrack}
|
||||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
setSubtitleURL={setSubtitleURL}
|
||||||
setAudioTrack={videoRef.current.setAudioTrack}
|
setAudioTrack={setAudioTrack}
|
||||||
|
setVideoAspectRatio={setVideoAspectRatio}
|
||||||
|
setVideoScaleFactor={setVideoScaleFactor}
|
||||||
|
aspectRatio={aspectRatio}
|
||||||
|
scaleFactor={scaleFactor}
|
||||||
|
setAspectRatio={setAspectRatio}
|
||||||
|
setScaleFactor={setScaleFactor}
|
||||||
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.
|
||||||
|
|||||||
414
app/_layout.tsx
@@ -1,13 +1,15 @@
|
|||||||
import "@/augmentations";
|
import "@/augmentations";
|
||||||
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
import {
|
import {
|
||||||
JellyfinProvider,
|
|
||||||
apiAtom,
|
apiAtom,
|
||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
getTokenFromStorage,
|
getTokenFromStorage,
|
||||||
|
JellyfinProvider,
|
||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -23,36 +25,37 @@ import {
|
|||||||
writeToLog,
|
writeToLog,
|
||||||
} from "@/utils/log";
|
} from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
|
||||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
|
||||||
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;
|
||||||
|
|
||||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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 Device from "expo-device";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
|
|
||||||
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import { Stack, router, useSegments } from "expo-router";
|
import { router, Stack, useSegments } from "expo-router";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
|
||||||
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, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
import { AppState, Appearance } from "react-native";
|
import { Appearance, AppState } 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 { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
import type { EventSubscription } from "expo-modules-core";
|
import type { EventSubscription } from "expo-modules-core";
|
||||||
import type {
|
import type {
|
||||||
@@ -62,6 +65,8 @@ import type {
|
|||||||
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Toaster } from "sonner-native";
|
import { Toaster } from "sonner-native";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
@@ -83,9 +88,9 @@ SplashScreen.setOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function useNotificationObserver() {
|
function useNotificationObserver() {
|
||||||
if (Platform.isTV) return;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
function redirect(notification: typeof Notifications.Notification) {
|
function redirect(notification: typeof Notifications.Notification) {
|
||||||
@@ -137,16 +142,13 @@ if (!Platform.isTV) {
|
|||||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||||
console.log("TaskManager ~ trigger");
|
console.log("TaskManager ~ trigger");
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
const settingsData = storage.getString("settings");
|
const settingsData = storage.getString("settings");
|
||||||
|
|
||||||
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||||
const url = settings?.optimizedVersionsServerUrl;
|
|
||||||
|
|
||||||
if (!settings?.autoDownload || !url)
|
if (!settings?.autoDownload)
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const token = getTokenFromStorage();
|
const token = getTokenFromStorage();
|
||||||
@@ -156,74 +158,6 @@ if (!Platform.isTV) {
|
|||||||
if (!token || !deviceId || !baseDirectory)
|
if (!token || !deviceId || !baseDirectory)
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const jobs = await getAllJobsByDeviceId({
|
|
||||||
deviceId,
|
|
||||||
authHeader: token,
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
|
||||||
|
|
||||||
for (const job of jobs) {
|
|
||||||
if (job.status === "completed") {
|
|
||||||
const downloadUrl = `${url}download/${job.id}`;
|
|
||||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
|
||||||
|
|
||||||
if (tasks.find((task: { id: string }) => task.id === job.id)) {
|
|
||||||
console.log("TaskManager ~ Download already in progress: ", job.id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
BackGroundDownloader.download({
|
|
||||||
id: job.id,
|
|
||||||
url: downloadUrl,
|
|
||||||
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
|
||||||
headers: {
|
|
||||||
Authorization: token,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.begin(() => {
|
|
||||||
console.log("TaskManager ~ Download started: ", job.id);
|
|
||||||
})
|
|
||||||
.done(() => {
|
|
||||||
console.log("TaskManager ~ Download completed: ", job.id);
|
|
||||||
saveDownloadedItemInfo(job.item);
|
|
||||||
BackGroundDownloader.completeHandler(job.id);
|
|
||||||
cancelJobById({
|
|
||||||
authHeader: token,
|
|
||||||
id: job.id,
|
|
||||||
url: url,
|
|
||||||
});
|
|
||||||
Notifications.scheduleNotificationAsync({
|
|
||||||
content: {
|
|
||||||
title: job.item.Name,
|
|
||||||
body: "Download completed",
|
|
||||||
data: {
|
|
||||||
url: "/downloads",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
trigger: null,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.error((error: any) => {
|
|
||||||
console.log("TaskManager ~ Download error: ", job.id, error);
|
|
||||||
BackGroundDownloader.completeHandler(job.id);
|
|
||||||
Notifications.scheduleNotificationAsync({
|
|
||||||
content: {
|
|
||||||
title: job.item.Name,
|
|
||||||
body: "Download failed",
|
|
||||||
data: {
|
|
||||||
url: "/downloads",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
trigger: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
|
||||||
|
|
||||||
// Be sure to return the successful result type!
|
// Be sure to return the successful result type!
|
||||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||||
});
|
});
|
||||||
@@ -301,51 +235,51 @@ function Layout() {
|
|||||||
);
|
);
|
||||||
}, [settings?.preferedLanguage, i18n]);
|
}, [settings?.preferedLanguage, i18n]);
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
useNotificationObserver();
|
||||||
useNotificationObserver();
|
|
||||||
|
|
||||||
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>();
|
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>();
|
||||||
const notificationListener = useRef<EventSubscription>();
|
const notificationListener = useRef<EventSubscription>();
|
||||||
const responseListener = useRef<EventSubscription>();
|
const responseListener = useRef<EventSubscription>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (expoPushToken && api && user) {
|
if (!Platform.isTV && expoPushToken && api && user) {
|
||||||
api
|
api
|
||||||
?.post("/Streamyfin/device", {
|
?.post("/Streamyfin/device", {
|
||||||
token: expoPushToken.data,
|
token: expoPushToken.data,
|
||||||
deviceId: getOrSetDeviceId(),
|
deviceId: getOrSetDeviceId(),
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
})
|
})
|
||||||
.then((_) => console.log("Posted expo push token"))
|
.then((_) => console.log("Posted expo push token"))
|
||||||
.catch((_) =>
|
.catch((_) =>
|
||||||
writeErrorLog("Failed to push expo push token to plugin"),
|
writeErrorLog("Failed to push expo push token to plugin"),
|
||||||
);
|
);
|
||||||
} else console.log("No token available");
|
} else console.log("No token available");
|
||||||
}, [api, expoPushToken, user]);
|
}, [api, expoPushToken, user]);
|
||||||
|
|
||||||
async function registerNotifications() {
|
async function registerNotifications() {
|
||||||
if (Platform.OS === "android") {
|
if (Platform.OS === "android") {
|
||||||
console.log("Setting android notification channel 'default'");
|
console.log("Setting android notification channel 'default'");
|
||||||
await Notifications?.setNotificationChannelAsync("default", {
|
await Notifications?.setNotificationChannelAsync("default", {
|
||||||
name: "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(() => {
|
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(() => {
|
||||||
|
if (!Platform.isTV) {
|
||||||
registerNotifications();
|
registerNotifications();
|
||||||
|
|
||||||
notificationListener.current =
|
notificationListener.current =
|
||||||
@@ -363,12 +297,10 @@ function Layout() {
|
|||||||
(response: NotificationResponse) => {
|
(response: NotificationResponse) => {
|
||||||
// Currently the notifications supported by the plugin will send data for deep links.
|
// Currently the notifications supported by the plugin will send data for deep links.
|
||||||
const { title, data } = response.notification.request.content;
|
const { title, data } = response.notification.request.content;
|
||||||
|
|
||||||
writeDebugLog(
|
writeDebugLog(
|
||||||
`Notification ${title} opened`,
|
`Notification ${title} opened`,
|
||||||
response.notification.request.content,
|
response.notification.request.content,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (data && Object.keys(data).length > 0) {
|
if (data && Object.keys(data).length > 0) {
|
||||||
const type = data?.type?.toLower?.();
|
const type = data?.type?.toLower?.();
|
||||||
const itemId = data?.id;
|
const itemId = data?.id;
|
||||||
@@ -381,12 +313,10 @@ function Layout() {
|
|||||||
// We just clicked a notification for an individual episode.
|
// We just clicked a notification for an individual episode.
|
||||||
if (itemId) {
|
if (itemId) {
|
||||||
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
|
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
|
||||||
}
|
// summarized season notification for multiple episodes. Bring them to series season
|
||||||
// summarized season notification for multiple episodes. Bring them to series season
|
} else {
|
||||||
else {
|
|
||||||
const seriesId = data.seriesId;
|
const seriesId = data.seriesId;
|
||||||
const seasonIndex = data.seasonIndex;
|
const seasonIndex = data.seasonIndex;
|
||||||
|
|
||||||
if (seasonIndex) {
|
if (seasonIndex) {
|
||||||
router.push(
|
router.push(
|
||||||
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`,
|
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`,
|
||||||
@@ -411,138 +341,116 @@ function Layout() {
|
|||||||
responseListener.current,
|
responseListener.current,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, []);
|
}
|
||||||
|
}, [user, api]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.isTV) {
|
if (Platform.isTV) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.includes("direct-player" as never)) {
|
||||||
|
if (
|
||||||
|
!settings.followDeviceOrientation &&
|
||||||
|
settings.defaultVideoOrientation
|
||||||
|
) {
|
||||||
|
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (segments.includes("direct-player" as never)) {
|
if (settings.followDeviceOrientation === true) {
|
||||||
if (
|
ScreenOrientation.unlockAsync();
|
||||||
!settings.followDeviceOrientation &&
|
} else {
|
||||||
settings.defaultVideoOrientation
|
ScreenOrientation.lockAsync(
|
||||||
) {
|
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.followDeviceOrientation === true) {
|
|
||||||
ScreenOrientation.unlockAsync();
|
|
||||||
} else {
|
|
||||||
ScreenOrientation.lockAsync(
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
settings.followDeviceOrientation,
|
|
||||||
settings.defaultVideoOrientation,
|
|
||||||
segments,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = AppState.addEventListener(
|
|
||||||
"change",
|
|
||||||
(nextAppState) => {
|
|
||||||
if (
|
|
||||||
appState.current.match(/inactive|background/) &&
|
|
||||||
nextAppState === "active"
|
|
||||||
) {
|
|
||||||
BackGroundDownloader.checkForExistingDownloads();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
settings.followDeviceOrientation,
|
||||||
|
settings.defaultVideoOrientation,
|
||||||
|
segments,
|
||||||
|
]);
|
||||||
|
|
||||||
BackGroundDownloader.checkForExistingDownloads();
|
useEffect(() => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||||
subscription.remove();
|
if (
|
||||||
};
|
appState.current.match(/inactive|background/) &&
|
||||||
}, []);
|
nextAppState === "active"
|
||||||
}
|
) {
|
||||||
|
BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<JobQueueProvider>
|
<JellyfinProvider>
|
||||||
<JellyfinProvider>
|
<PlaySettingsProvider>
|
||||||
<PlaySettingsProvider>
|
<LogProvider>
|
||||||
<LogProvider>
|
<WebSocketProvider>
|
||||||
<WebSocketProvider>
|
<DownloadProvider>
|
||||||
<DownloadProvider>
|
<BottomSheetModalProvider>
|
||||||
<BottomSheetModalProvider>
|
<SystemBars style='light' hidden={false} />
|
||||||
<SystemBars style='light' hidden={false} />
|
<ThemeProvider value={DarkTheme}>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name='(auth)/(tabs)'
|
||||||
name='(auth)/(tabs)'
|
options={{
|
||||||
options={{
|
headerShown: false,
|
||||||
headerShown: false,
|
title: "",
|
||||||
title: "",
|
header: () => null,
|
||||||
header: () => null,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/player'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
title: "",
|
|
||||||
header: () => null,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='login'
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: "",
|
|
||||||
headerTransparent: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name='+not-found' />
|
|
||||||
</Stack>
|
|
||||||
<Toaster
|
|
||||||
duration={4000}
|
|
||||||
toastOptions={{
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#262626",
|
|
||||||
borderColor: "#363639",
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
titleStyle: {
|
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
closeButton
|
|
||||||
/>
|
/>
|
||||||
</ThemeProvider>
|
<Stack.Screen
|
||||||
</BottomSheetModalProvider>
|
name='(auth)/player'
|
||||||
</DownloadProvider>
|
options={{
|
||||||
</WebSocketProvider>
|
headerShown: false,
|
||||||
</LogProvider>
|
title: "",
|
||||||
</PlaySettingsProvider>
|
header: () => null,
|
||||||
</JellyfinProvider>
|
}}
|
||||||
</JobQueueProvider>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='login'
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
title: "",
|
||||||
|
headerTransparent: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name='+not-found' />
|
||||||
|
</Stack>
|
||||||
|
<Toaster
|
||||||
|
duration={4000}
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderColor: "#363639",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
titleStyle: {
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
closeButton
|
||||||
|
/>
|
||||||
|
</ThemeProvider>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
|
</DownloadProvider>
|
||||||
|
</WebSocketProvider>
|
||||||
|
</LogProvider>
|
||||||
|
</PlaySettingsProvider>
|
||||||
|
</JellyfinProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveDownloadedItemInfo(item: BaseItemDto) {
|
|
||||||
try {
|
|
||||||
const downloadedItems = storage.getString("downloadedItems");
|
|
||||||
const items: BaseItemDto[] = downloadedItems
|
|
||||||
? JSON.parse(downloadedItems)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
|
|
||||||
if (existingItemIndex !== -1) {
|
|
||||||
items[existingItemIndex] = item;
|
|
||||||
} else {
|
|
||||||
items.push(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
storage.set("downloadedItems", JSON.stringify(items));
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Failed to save downloaded item information:", error);
|
|
||||||
console.error("Failed to save downloaded item information:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
252
app/login.tsx
@@ -1,29 +1,29 @@
|
|||||||
import { Button } from "@/components/Button";
|
|
||||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
|
||||||
import { Input } from "@/components/common/Input";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
|
||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import type { 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 { t } from "i18next";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Keyboard,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
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 { Button } from "@/components/Button";
|
||||||
|
import { Input } from "@/components/common/Input";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||||
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
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")),
|
||||||
});
|
});
|
||||||
@@ -199,7 +199,7 @@ const Login: React.FC = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.error_title"),
|
t("login.error_title"),
|
||||||
t("login.failed_to_initiate_quick_connect"),
|
t("login.failed_to_initiate_quick_connect"),
|
||||||
@@ -213,133 +213,127 @@ const Login: React.FC = () => {
|
|||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
>
|
>
|
||||||
{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'>
|
|
||||||
{api.basePath}
|
|
||||||
</Text>
|
|
||||||
<Input
|
|
||||||
placeholder={t("login.username_placeholder")}
|
|
||||||
onChangeText={(text) =>
|
|
||||||
setCredentials({ ...credentials, username: text })
|
|
||||||
}
|
|
||||||
value={credentials.username}
|
|
||||||
keyboardType='default'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
// Changed from username to oneTimeCode because it is a known issue in RN
|
|
||||||
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
|
|
||||||
textContentType='oneTimeCode'
|
|
||||||
clearButtonMode='while-editing'
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder={t("login.password_placeholder")}
|
|
||||||
onChangeText={(text) =>
|
|
||||||
setCredentials({ ...credentials, password: text })
|
|
||||||
}
|
|
||||||
value={credentials.password}
|
|
||||||
secureTextEntry
|
|
||||||
keyboardType='default'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
textContentType='password'
|
|
||||||
clearButtonMode='while-editing'
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
<View className='flex flex-row items-center justify-between'>
|
|
||||||
<Button
|
|
||||||
onPress={handleLogin}
|
|
||||||
loading={loading}
|
|
||||||
className='flex-1 mr-2'
|
|
||||||
>
|
|
||||||
{t("login.login_button")}
|
|
||||||
</Button>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleQuickConnect}
|
|
||||||
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name='cellphone-lock'
|
|
||||||
size={24}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<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'>
|
|
||||||
<Image
|
|
||||||
style={{
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
marginLeft: -23,
|
|
||||||
marginBottom: -20,
|
|
||||||
}}
|
|
||||||
source={require("@/assets/images/StreamyFinFinal.png")}
|
|
||||||
/>
|
|
||||||
<Text className='text-3xl font-bold'>Streamyfin</Text>
|
|
||||||
<Text className='text-neutral-500'>
|
|
||||||
{t("server.enter_url_to_jellyfin_server")}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text className='text-xs text-neutral-400'>{api.basePath}</Text>
|
||||||
<Input
|
<Input
|
||||||
aria-label='Server URL'
|
placeholder={t("login.username_placeholder")}
|
||||||
placeholder={t("server.server_url_placeholder")}
|
onChangeText={(text) =>
|
||||||
onChangeText={setServerURL}
|
setCredentials({ ...credentials, username: text })
|
||||||
value={serverURL}
|
}
|
||||||
keyboardType='url'
|
value={credentials.username}
|
||||||
|
keyboardType='default'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
textContentType='URL'
|
// Changed from username to oneTimeCode because it is a known issue in RN
|
||||||
|
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
|
||||||
|
textContentType='oneTimeCode'
|
||||||
|
clearButtonMode='while-editing'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
loading={loadingServerCheck}
|
<Input
|
||||||
disabled={loadingServerCheck}
|
placeholder={t("login.password_placeholder")}
|
||||||
onPress={async () => {
|
onChangeText={(text) =>
|
||||||
await handleConnect(serverURL);
|
setCredentials({ ...credentials, password: text })
|
||||||
}}
|
}
|
||||||
className='w-full grow'
|
value={credentials.password}
|
||||||
>
|
secureTextEntry
|
||||||
{t("server.connect_button")}
|
keyboardType='default'
|
||||||
</Button>
|
returnKeyType='done'
|
||||||
<JellyfinServerDiscovery
|
autoCapitalize='none'
|
||||||
onServerSelect={async (server) => {
|
textContentType='password'
|
||||||
setServerURL(server.address);
|
clearButtonMode='while-editing'
|
||||||
if (server.serverName) {
|
maxLength={500}
|
||||||
setServerName(server.serverName);
|
|
||||||
}
|
|
||||||
await handleConnect(server.address);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<PreviousServersList
|
|
||||||
onServerSelect={async (s) => {
|
|
||||||
await handleConnect(s.address);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
<View className='flex flex-row items-center justify-between'>
|
||||||
|
<Button
|
||||||
|
onPress={handleLogin}
|
||||||
|
loading={loading}
|
||||||
|
className='flex-1 mr-2'
|
||||||
|
>
|
||||||
|
{t("login.login_button")}
|
||||||
|
</Button>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleQuickConnect}
|
||||||
|
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name='cellphone-lock'
|
||||||
|
size={24}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</>
|
|
||||||
|
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<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'>
|
||||||
|
<Image
|
||||||
|
style={{
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
marginLeft: -23,
|
||||||
|
marginBottom: -20,
|
||||||
|
}}
|
||||||
|
source={require("@/assets/images/icon-ios-plain.png")}
|
||||||
|
/>
|
||||||
|
<Text className='text-3xl font-bold'>Streamyfin</Text>
|
||||||
|
<Text className='text-neutral-500'>
|
||||||
|
{t("server.enter_url_to_jellyfin_server")}
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
aria-label='Server URL'
|
||||||
|
placeholder={t("server.server_url_placeholder")}
|
||||||
|
onChangeText={setServerURL}
|
||||||
|
value={serverURL}
|
||||||
|
keyboardType='url'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='URL'
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
loading={loadingServerCheck}
|
||||||
|
disabled={loadingServerCheck}
|
||||||
|
onPress={async () => {
|
||||||
|
await handleConnect(serverURL);
|
||||||
|
}}
|
||||||
|
className='w-full grow'
|
||||||
|
>
|
||||||
|
{t("server.connect_button")}
|
||||||
|
</Button>
|
||||||
|
<JellyfinServerDiscovery
|
||||||
|
onServerSelect={async (server) => {
|
||||||
|
setServerURL(server.address);
|
||||||
|
if (server.serverName) {
|
||||||
|
setServerName(server.serverName);
|
||||||
|
}
|
||||||
|
await handleConnect(server.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PreviousServersList
|
||||||
|
onServerSelect={async (s) => {
|
||||||
|
await handleConnect(s.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 79 KiB |
BIN
assets/images/icon-android-plain.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
assets/images/icon-android-themed.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 75 KiB |
@@ -1,6 +1,6 @@
|
|||||||
import type { StreamyfinPluginConfig } from "@/utils/atoms/settings";
|
import { Api, AUTHORIZATION_HEADER } from "@jellyfin/sdk";
|
||||||
import { AUTHORIZATION_HEADER, Api } from "@jellyfin/sdk";
|
|
||||||
import type { AxiosRequestConfig, AxiosResponse } from "axios";
|
import type { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||||
|
import type { StreamyfinPluginConfig } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
declare module "@jellyfin/sdk" {
|
declare module "@jellyfin/sdk" {
|
||||||
interface Api {
|
interface Api {
|
||||||
|
|||||||
@@ -7,15 +7,27 @@ declare module "react-native-mmkv" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add the augmentation methods directly to the MMKV prototype
|
||||||
|
// This follows the recommended pattern while adding the helper methods your app uses
|
||||||
MMKV.prototype.get = function <T>(key: string): T | undefined {
|
MMKV.prototype.get = function <T>(key: string): T | undefined {
|
||||||
const serializedItem = this.getString(key);
|
try {
|
||||||
return serializedItem ? JSON.parse(serializedItem) : undefined;
|
const serializedItem = this.getString(key);
|
||||||
|
if (!serializedItem) return undefined;
|
||||||
|
return JSON.parse(serializedItem);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to parse MMKV value for key "${key}":`, error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||||
if (value === undefined) {
|
try {
|
||||||
this.delete(key);
|
if (value === undefined) {
|
||||||
} else {
|
this.delete(key);
|
||||||
this.set(key, JSON.stringify(value));
|
} else {
|
||||||
|
this.set(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to set MMKV value for key "${key}":`, error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
18
biome.json
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.0.5/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**/*",
|
"**/*",
|
||||||
"!node_modules/**",
|
"!node_modules",
|
||||||
"!ios/**",
|
"!ios",
|
||||||
"!android/**",
|
"!android",
|
||||||
"!Streamyfin.app/**",
|
"!Streamyfin.app",
|
||||||
"!utils/jellyseerr/**",
|
"!utils/jellyseerr",
|
||||||
"!.expo/**"
|
"!.expo"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
@@ -24,7 +24,9 @@
|
|||||||
"noForEach": "off"
|
"noForEach": "off"
|
||||||
},
|
},
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"correctness": { "useExhaustiveDependencies": "off" },
|
"correctness": {
|
||||||
|
"useExhaustiveDependencies": "off"
|
||||||
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noExplicitAny": "off",
|
"noExplicitAny": "off",
|
||||||
"noArrayIndexKey": "off"
|
"noArrayIndexKey": "off"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { RoundButton } from "@/components/RoundButton";
|
|
||||||
import { useFavorite } from "@/hooks/useFavorite";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { 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 { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
@@ -17,7 +19,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
if (Platform.isTV) return null;
|
const isTv = Platform.isTV;
|
||||||
|
|
||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
[source],
|
[source],
|
||||||
@@ -30,6 +33,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className='flex shrink'
|
className='flex shrink'
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
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 { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
@@ -58,23 +60,26 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
inverted,
|
inverted,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
if (Platform.isTV) return null;
|
const isTv = Platform.isTV;
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (inverted)
|
if (inverted)
|
||||||
return BITRATES.sort(
|
return BITRATES.slice().sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(a.value || Number.POSITIVE_INFINITY) -
|
(a.value || Number.POSITIVE_INFINITY) -
|
||||||
(b.value || Number.POSITIVE_INFINITY),
|
(b.value || Number.POSITIVE_INFINITY),
|
||||||
);
|
);
|
||||||
return BITRATES.sort(
|
return BITRATES.slice().sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(b.value || Number.POSITIVE_INFINITY) -
|
(b.value || Number.POSITIVE_INFINITY) -
|
||||||
(a.value || Number.POSITIVE_INFINITY),
|
(a.value || Number.POSITIVE_INFINITY),
|
||||||
);
|
);
|
||||||
}, []);
|
}, [inverted]);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className='flex shrink'
|
className='flex shrink'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { type PropsWithChildren, type ReactNode, useMemo } from "react";
|
import { type PropsWithChildren, type ReactNode, useMemo } from "react";
|
||||||
import { Platform, Text, TouchableOpacity, View } from "react-native";
|
import { Text, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import React, { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { Platform, TouchableOpacity, type ViewProps } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
CastButton,
|
CastButton,
|
||||||
CastContext,
|
CastContext,
|
||||||
@@ -11,12 +11,6 @@ import GoogleCast, {
|
|||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
background?: "blur" | "transparent";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Chromecast({
|
export function Chromecast({
|
||||||
width = 48,
|
width = 48,
|
||||||
height = 48,
|
height = 48,
|
||||||
@@ -44,11 +38,7 @@ export function Chromecast({
|
|||||||
// Android requires the cast button to be present for startDiscovery to work
|
// Android requires the cast button to be present for startDiscovery to work
|
||||||
const AndroidCastButton = useCallback(
|
const AndroidCastButton = useCallback(
|
||||||
() =>
|
() =>
|
||||||
Platform.OS === "android" ? (
|
Platform.OS === "android" ? <CastButton tintColor='transparent' /> : null,
|
||||||
<CastButton tintColor='transparent' />
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
),
|
|
||||||
[Platform.OS],
|
[Platform.OS],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from "zeego/context-menu";
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
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 type React from "react";
|
import type React from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { ProgressBar } from "./common/ProgressBar";
|
||||||
import { WatchedIndicator } from "./WatchedIndicator";
|
import { WatchedIndicator } from "./WatchedIndicator";
|
||||||
|
|
||||||
type ContinueWatchingPosterProps = {
|
type ContinueWatchingPosterProps = {
|
||||||
@@ -62,18 +63,6 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
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(() => {
|
|
||||||
if (item.Type === "Program") {
|
|
||||||
const startDate = new Date(item.StartDate || "");
|
|
||||||
const endDate = new Date(item.EndDate || "");
|
|
||||||
const now = new Date();
|
|
||||||
const total = endDate.getTime() - startDate.getTime();
|
|
||||||
const elapsed = now.getTime() - startDate.getTime();
|
|
||||||
return (elapsed / total) * 100;
|
|
||||||
}
|
|
||||||
return item.UserData?.PlayedPercentage || 0;
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
if (!url)
|
if (!url)
|
||||||
return <View className='aspect-video border border-neutral-800 w-44' />;
|
return <View className='aspect-video border border-neutral-800 w-44' />;
|
||||||
|
|
||||||
@@ -101,22 +90,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
{!progress && <WatchedIndicator item={item} />}
|
{!item.UserData?.Played && <WatchedIndicator item={item} />}
|
||||||
{progress > 0 && (
|
<ProgressBar item={item} />
|
||||||
<>
|
|
||||||
<View
|
|
||||||
className={
|
|
||||||
"absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: `${progress}%`,
|
|
||||||
}}
|
|
||||||
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,3 @@
|
|||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
|
||||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
|
||||||
import download from "@/utils/profiles/download";
|
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
@@ -17,22 +9,36 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { type Href, router, useFocusEffect } from "expo-router";
|
import { type Href, router } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
|
import { getDownloadUrl } from "@/utils/jellyfin/media/getDownloadUrl";
|
||||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||||
import { type 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 { Text } from "./common/Text";
|
|
||||||
|
export type SelectedOptions = {
|
||||||
|
bitrate: Bitrate;
|
||||||
|
mediaSource: MediaSourceInfo | undefined;
|
||||||
|
audioIndex: number | undefined;
|
||||||
|
subtitleIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
interface DownloadProps extends ViewProps {
|
interface DownloadProps extends ViewProps {
|
||||||
items: BaseItemDto[];
|
items: BaseItemDto[];
|
||||||
@@ -54,33 +60,29 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [queue, _setQueue] = useAtom(queueAtom);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
||||||
|
|
||||||
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
const { processes, startBackgroundDownload, getDownloadedItems } =
|
||||||
//const { startRemuxing } = useRemuxHlsToMp4();
|
useDownload();
|
||||||
|
const downloadedFiles = getDownloadedItems();
|
||||||
|
|
||||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
const [selectedOptions, setSelectedOptions] = useState<
|
||||||
MediaSourceInfo | undefined | null
|
SelectedOptions | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
const {
|
||||||
useState<number>(0);
|
defaultAudioIndex,
|
||||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(
|
defaultBitrate,
|
||||||
settings?.defaultBitrate ?? {
|
defaultMediaSource,
|
||||||
key: "Max",
|
defaultSubtitleIndex,
|
||||||
value: undefined,
|
} = useDefaultPlaySettings(items[0], settings);
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const userCanDownload = useMemo(
|
const userCanDownload = useMemo(
|
||||||
() => user?.Policy?.EnableContentDownloading,
|
() => user?.Policy?.EnableContentDownloading,
|
||||||
[user],
|
[user],
|
||||||
);
|
);
|
||||||
const usingOptimizedServer = useMemo(
|
|
||||||
() => settings?.downloadMethod === DownloadMethod.Optimized,
|
|
||||||
[settings],
|
|
||||||
);
|
|
||||||
|
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
@@ -88,7 +90,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
bottomSheetModalRef.current?.present();
|
bottomSheetModalRef.current?.present();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((index: number) => {}, []);
|
const handleSheetChanges = useCallback((_index: number) => {}, []);
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
const closeModal = useCallback(() => {
|
||||||
bottomSheetModalRef.current?.dismiss();
|
bottomSheetModalRef.current?.dismiss();
|
||||||
@@ -102,6 +104,28 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
[items, downloadedFiles],
|
[items, downloadedFiles],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Initialize selectedOptions with default values
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedOptions(() => ({
|
||||||
|
bitrate: defaultBitrate,
|
||||||
|
mediaSource: defaultMediaSource,
|
||||||
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
|
audioIndex: defaultAudioIndex,
|
||||||
|
}));
|
||||||
|
}, [
|
||||||
|
defaultAudioIndex,
|
||||||
|
defaultBitrate,
|
||||||
|
defaultSubtitleIndex,
|
||||||
|
defaultMediaSource,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const itemsToDownload = useMemo(() => {
|
||||||
|
if (downloadUnwatchedOnly) {
|
||||||
|
return itemsNotDownloaded.filter((item) => !item.UserData?.Played);
|
||||||
|
}
|
||||||
|
return itemsNotDownloaded;
|
||||||
|
}, [itemsNotDownloaded, downloadUnwatchedOnly]);
|
||||||
|
|
||||||
const allItemsDownloaded = useMemo(() => {
|
const allItemsDownloaded = useMemo(() => {
|
||||||
if (items.length === 0) return false;
|
if (items.length === 0) return false;
|
||||||
return itemsNotDownloaded.length === 0;
|
return itemsNotDownloaded.length === 0;
|
||||||
@@ -144,99 +168,98 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const acceptDownloadOptions = useCallback(() => {
|
|
||||||
if (userCanDownload === true) {
|
|
||||||
if (itemsNotDownloaded.some((i) => !i.Id)) {
|
|
||||||
throw new Error("No item id");
|
|
||||||
}
|
|
||||||
closeModal();
|
|
||||||
|
|
||||||
initiateDownload(...itemsNotDownloaded);
|
|
||||||
} else {
|
|
||||||
toast.error(
|
|
||||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
queue,
|
|
||||||
setQueue,
|
|
||||||
itemsNotDownloaded,
|
|
||||||
usingOptimizedServer,
|
|
||||||
userCanDownload,
|
|
||||||
maxBitrate,
|
|
||||||
selectedMediaSource,
|
|
||||||
selectedAudioStream,
|
|
||||||
selectedSubtitleStream,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const initiateDownload = useCallback(
|
const initiateDownload = useCallback(
|
||||||
async (...items: BaseItemDto[]) => {
|
async (...items: BaseItemDto[]) => {
|
||||||
if (
|
if (
|
||||||
!api ||
|
!api ||
|
||||||
!user?.Id ||
|
!user?.Id ||
|
||||||
items.some((p) => !p.Id) ||
|
items.some((p) => !p.Id) ||
|
||||||
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
|
(itemsNotDownloaded.length === 1 && !selectedOptions?.mediaSource?.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;
|
const downloadDetailsPromises = items.map(async (item) => {
|
||||||
let audioIndex: number | undefined = selectedAudioStream;
|
const { mediaSource, audioIndex, subtitleIndex } =
|
||||||
let subtitleIndex: number | undefined = selectedSubtitleStream;
|
itemsNotDownloaded.length > 1
|
||||||
|
? getDefaultPlaySettings(item, settings!)
|
||||||
|
: {
|
||||||
|
mediaSource: selectedOptions?.mediaSource,
|
||||||
|
audioIndex: selectedOptions?.audioIndex,
|
||||||
|
subtitleIndex: selectedOptions?.subtitleIndex,
|
||||||
|
};
|
||||||
|
|
||||||
for (const item of items) {
|
const downloadDetails = await getDownloadUrl({
|
||||||
if (itemsNotDownloaded.length > 1) {
|
|
||||||
const defaults = getDefaultPlaySettings(item, settings!);
|
|
||||||
mediaSource = defaults.mediaSource;
|
|
||||||
audioIndex = defaults.audioIndex;
|
|
||||||
subtitleIndex = defaults.subtitleIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
startTimeTicks: 0,
|
userId: user.Id!,
|
||||||
userId: user?.Id,
|
mediaSource: mediaSource!,
|
||||||
audioStreamIndex: audioIndex,
|
audioStreamIndex: audioIndex ?? -1,
|
||||||
maxStreamingBitrate: maxBitrate.value,
|
subtitleStreamIndex: subtitleIndex ?? -1,
|
||||||
mediaSourceId: mediaSource?.Id,
|
maxBitrate: selectedOptions?.bitrate || defaultBitrate,
|
||||||
subtitleStreamIndex: subtitleIndex,
|
deviceId: api.deviceInfo.id,
|
||||||
deviceProfile: download,
|
|
||||||
download: true,
|
|
||||||
// deviceId: mediaSource?.Id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res) {
|
return {
|
||||||
|
url: downloadDetails?.url,
|
||||||
|
item,
|
||||||
|
mediaSource: downloadDetails?.mediaSource,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const downloadDetails = await Promise.all(downloadDetailsPromises);
|
||||||
|
for (const { url, item, mediaSource } of downloadDetails) {
|
||||||
|
if (!url) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
if (!mediaSource) {
|
||||||
const { mediaSource: source, url } = res;
|
console.error(`Could not get download URL for ${item.Name}`);
|
||||||
|
toast.error(
|
||||||
if (!url || !source) throw new Error("No url");
|
t("Could not get download URL for {{itemName}}", {
|
||||||
|
itemName: item.Name,
|
||||||
saveDownloadItemInfoToDiskTmp(item, source, url);
|
}),
|
||||||
await startBackgroundDownload(url, item, source, maxBitrate);
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await startBackgroundDownload(
|
||||||
|
url,
|
||||||
|
item,
|
||||||
|
mediaSource,
|
||||||
|
selectedOptions?.bitrate || defaultBitrate,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
api,
|
api,
|
||||||
user?.Id,
|
user?.Id,
|
||||||
itemsNotDownloaded,
|
itemsNotDownloaded,
|
||||||
selectedMediaSource,
|
selectedOptions,
|
||||||
selectedAudioStream,
|
|
||||||
selectedSubtitleStream,
|
|
||||||
settings,
|
settings,
|
||||||
maxBitrate,
|
defaultBitrate,
|
||||||
usingOptimizedServer,
|
|
||||||
startBackgroundDownload,
|
startBackgroundDownload,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const acceptDownloadOptions = useCallback(() => {
|
||||||
|
if (userCanDownload === true) {
|
||||||
|
if (itemsToDownload.some((i) => !i.Id)) {
|
||||||
|
throw new Error("No item id");
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
|
||||||
|
initiateDownload(...itemsToDownload);
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [closeModal, initiateDownload, itemsToDownload, userCanDownload]);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
<BottomSheetBackdrop
|
<BottomSheetBackdrop
|
||||||
@@ -247,19 +270,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
),
|
),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
if (!settings) return;
|
|
||||||
if (itemsNotDownloaded.length !== 1) return;
|
|
||||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
|
||||||
getDefaultPlaySettings(items[0], settings);
|
|
||||||
|
|
||||||
setSelectedMediaSource(mediaSource ?? undefined);
|
|
||||||
setSelectedAudioStream(audioIndex ?? 0);
|
|
||||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
|
||||||
setMaxBitrate(bitrate);
|
|
||||||
}, [items, itemsNotDownloaded, settings]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderButtonContent = () => {
|
const renderButtonContent = () => {
|
||||||
if (processes.length > 0 && itemsProcesses.length > 0) {
|
if (processes.length > 0 && itemsProcesses.length > 0) {
|
||||||
@@ -327,40 +337,78 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
<Text className='text-neutral-300'>
|
<Text className='text-neutral-300'>
|
||||||
{subtitle ||
|
{subtitle ||
|
||||||
t("item_card.download.download_x_item", {
|
t("item_card.download.download_x_item", {
|
||||||
item_count: itemsNotDownloaded.length,
|
item_count: itemsToDownload.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={(val) =>
|
||||||
selected={maxBitrate}
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, bitrate: val },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
selected={selectedOptions?.bitrate}
|
||||||
/>
|
/>
|
||||||
|
{itemsNotDownloaded.length > 1 && (
|
||||||
|
<View className='flex flex-row items-center justify-between w-full py-2'>
|
||||||
|
<Text>{t("item_card.download.download_unwatched_only")}</Text>
|
||||||
|
<Switch
|
||||||
|
onValueChange={setDownloadUnwatchedOnly}
|
||||||
|
value={downloadUnwatchedOnly}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
{itemsNotDownloaded.length === 1 && (
|
{itemsNotDownloaded.length === 1 && (
|
||||||
<>
|
<View>
|
||||||
<MediaSourceSelector
|
<MediaSourceSelector
|
||||||
item={items[0]}
|
item={items[0]}
|
||||||
onChange={setSelectedMediaSource}
|
onChange={(val) =>
|
||||||
selected={selectedMediaSource}
|
setSelectedOptions(
|
||||||
|
(prev) =>
|
||||||
|
prev && {
|
||||||
|
...prev,
|
||||||
|
mediaSource: val,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
selected={selectedOptions?.mediaSource}
|
||||||
/>
|
/>
|
||||||
{selectedMediaSource && (
|
{selectedOptions?.mediaSource && (
|
||||||
<View className='flex flex-col space-y-2'>
|
<View className='flex flex-col space-y-2'>
|
||||||
<AudioTrackSelector
|
<AudioTrackSelector
|
||||||
source={selectedMediaSource}
|
source={selectedOptions.mediaSource}
|
||||||
onChange={setSelectedAudioStream}
|
onChange={(val) => {
|
||||||
selected={selectedAudioStream}
|
setSelectedOptions(
|
||||||
|
(prev) =>
|
||||||
|
prev && {
|
||||||
|
...prev,
|
||||||
|
audioIndex: val,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
selected={selectedOptions.audioIndex}
|
||||||
/>
|
/>
|
||||||
<SubtitleTrackSelector
|
<SubtitleTrackSelector
|
||||||
source={selectedMediaSource}
|
source={selectedOptions.mediaSource}
|
||||||
onChange={setSelectedSubtitleStream}
|
onChange={(val) => {
|
||||||
selected={selectedSubtitleStream}
|
setSelectedOptions(
|
||||||
|
(prev) =>
|
||||||
|
prev && {
|
||||||
|
...prev,
|
||||||
|
subtitleIndex: val,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
selected={selectedOptions.subtitleIndex}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className='mt-auto'
|
className='mt-auto'
|
||||||
onPress={acceptDownloadOptions}
|
onPress={acceptDownloadOptions}
|
||||||
@@ -368,13 +416,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
>
|
>
|
||||||
{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'>
|
|
||||||
<Text className='text-xs'>
|
|
||||||
{usingOptimizedServer
|
|
||||||
? t("item_card.download.using_optimized_server")
|
|
||||||
: t("item_card.download.using_default_method")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
</BottomSheetView>
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { tc } from "@/utils/textTools";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
|||||||
@@ -1,25 +1,3 @@
|
|||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
|
||||||
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
|
||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
|
||||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
|
||||||
import { SimilarItems } from "@/components/SimilarItems";
|
|
||||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
|
||||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
|
||||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
|
||||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
|
||||||
import { useImageColors } from "@/hooks/useImageColors";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
@@ -30,12 +8,34 @@ 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";
|
||||||
|
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||||
|
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||||
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
||||||
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
|
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||||
|
import { SimilarItems } from "@/components/SimilarItems";
|
||||||
|
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||||
|
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||||
|
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||||
|
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||||
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { AddToFavorites } from "./AddToFavorites";
|
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 { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||||
|
|
||||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||||
|
|
||||||
export type SelectedOptions = {
|
export type SelectedOptions = {
|
||||||
@@ -45,8 +45,13 @@ export type SelectedOptions = {
|
|||||||
subtitleIndex: number;
|
subtitleIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
interface ItemContentProps {
|
||||||
({ item }) => {
|
item: BaseItemDto;
|
||||||
|
isOffline: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||||
|
({ item, isOffline }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
@@ -68,7 +73,16 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
defaultSubtitleIndex,
|
defaultSubtitleIndex,
|
||||||
} = useDefaultPlaySettings(item, settings);
|
} = useDefaultPlaySettings(item!, settings);
|
||||||
|
|
||||||
|
const logoUrl = useMemo(
|
||||||
|
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||||
|
[api, item],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loading = useMemo(() => {
|
||||||
|
return Boolean(logoUrl && loadingLogo);
|
||||||
|
}, [loadingLogo, logoUrl]);
|
||||||
|
|
||||||
// Needs to automatically change the selected to the default values for default indexes.
|
// Needs to automatically change the selected to the default values for default indexes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,8 +99,8 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
useEffect(() => {
|
||||||
useEffect(() => {
|
if (!Platform.isTV) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
item && (
|
item && (
|
||||||
@@ -112,22 +126,19 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}, [item]);
|
}
|
||||||
}
|
}, [item, navigation, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
if (item) {
|
||||||
setHeaderHeight(230);
|
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||||
else if (item.Type === "Movie") setHeaderHeight(500);
|
setHeaderHeight(230);
|
||||||
else setHeaderHeight(350);
|
else if (item.Type === "Movie") setHeaderHeight(500);
|
||||||
}, [item.Type, orientation]);
|
else setHeaderHeight(350);
|
||||||
|
}
|
||||||
|
}, [item, orientation]);
|
||||||
|
|
||||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
if (!item || !selectedOptions) return null;
|
||||||
|
|
||||||
const loading = useMemo(() => {
|
|
||||||
return Boolean(logoUrl && loadingLogo);
|
|
||||||
}, [loadingLogo, logoUrl]);
|
|
||||||
if (!selectedOptions) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -168,13 +179,15 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
onLoad={() => setLoadingLogo(false)}
|
onLoad={() => setLoadingLogo(false)}
|
||||||
onError={() => setLoadingLogo(false)}
|
onError={() => setLoadingLogo(false)}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : (
|
||||||
|
<View />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col bg-transparent shrink'>
|
<View className='flex flex-col bg-transparent shrink'>
|
||||||
<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-2' />
|
||||||
{item.Type !== "Program" && !Platform.isTV && (
|
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
||||||
<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'
|
||||||
@@ -233,25 +246,34 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
className='grow'
|
className='grow'
|
||||||
selectedOptions={selectedOptions}
|
selectedOptions={selectedOptions}
|
||||||
item={item}
|
item={item}
|
||||||
|
isOffline={isOffline}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
<SeasonEpisodesCarousel
|
||||||
|
item={item}
|
||||||
|
loading={loading}
|
||||||
|
isOffline={isOffline}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
{!isOffline && (
|
||||||
|
<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" && !isOffline && (
|
||||||
<CurrentSeries item={item} className='mb-4' />
|
<CurrentSeries item={item} className='mb-4' />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
{!isOffline && (
|
||||||
|
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
||||||
|
)}
|
||||||
|
|
||||||
{item.People && item.People.length > 0 && (
|
{item.People && item.People.length > 0 && !isOffline && (
|
||||||
<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
|
||||||
@@ -264,7 +286,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SimilarItems itemId={item.Id} />
|
{!isOffline && <SimilarItems itemId={item.Id} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { GenreTags } from "./GenreTags";
|
import { GenreTags } from "./GenreTags";
|
||||||
import { Ratings } from "./Ratings";
|
|
||||||
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||||
|
import { Ratings } from "./Ratings";
|
||||||
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
||||||
import { ItemActions } from "./series/SeriesActions";
|
import { ItemActions } from "./series/SeriesActions";
|
||||||
|
|
||||||
@@ -33,16 +33,16 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
<ItemActions item={item} />
|
<ItemActions item={item} />
|
||||||
</View>
|
</View>
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
<>
|
<View>
|
||||||
<EpisodeTitleHeader item={item} />
|
<EpisodeTitleHeader item={item} />
|
||||||
<GenreTags genres={item.Genres!} />
|
<GenreTags genres={item.Genres!} />
|
||||||
</>
|
</View>
|
||||||
)}
|
)}
|
||||||
{item.Type === "Movie" && (
|
{item.Type === "Movie" && (
|
||||||
<>
|
<View>
|
||||||
<MoviesTitleHeader item={item} />
|
<MoviesTitleHeader item={item} />
|
||||||
<GenreTags genres={item.Genres!} />
|
<GenreTags genres={item.Genres!} />
|
||||||
</>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { formatBitrate } from "@/utils/bitrate";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
type BottomSheetBackdropProps,
|
type BottomSheetBackdropProps,
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import type {
|
import type {
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
@@ -15,15 +13,15 @@ import type React from "react";
|
|||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
import { Badge } from "./Badge";
|
import { Badge } from "./Badge";
|
||||||
import { Button } from "./Button";
|
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -55,7 +53,7 @@ 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>
|
||||||
<Text className='text-lg font-bold mb-4'>
|
<Text className='text-lg font-bold mb-4'>
|
||||||
{t("item_card.video")}
|
{t("item_card.video")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -64,7 +62,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className=''>
|
<View>
|
||||||
<Text className='text-lg font-bold mb-2'>
|
<Text className='text-lg font-bold mb-2'>
|
||||||
{t("item_card.audio")}
|
{t("item_card.audio")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -77,7 +75,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className=''>
|
<View>
|
||||||
<Text className='text-lg font-bold mb-2'>
|
<Text className='text-lg font-bold mb-2'>
|
||||||
{t("item_card.subtitles")}
|
{t("item_card.subtitles")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -103,7 +101,7 @@ const SubtitleStreamInfo = ({
|
|||||||
}) => {
|
}) => {
|
||||||
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}
|
||||||
@@ -177,15 +175,13 @@ const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
||||||
if (!source) return null;
|
|
||||||
|
|
||||||
const videoStream = useMemo(() => {
|
const videoStream = useMemo(() => {
|
||||||
return source.MediaStreams?.find(
|
return source?.MediaStreams?.find((stream) => stream.Type === "Video") as
|
||||||
(stream) => stream.Type === "Video",
|
| MediaStream
|
||||||
) as MediaStream;
|
| undefined;
|
||||||
}, [source.MediaStreams]);
|
}, [source?.MediaStreams]);
|
||||||
|
|
||||||
if (!videoStream) return null;
|
if (!source || !videoStream) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex-row flex-wrap gap-2'>
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
@@ -223,7 +219,11 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
|||||||
<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 != null
|
||||||
|
? `${videoStream.AverageFrameRate.toFixed(0)} fps`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -236,6 +236,7 @@ const formatFileSize = (bytes?: number | null) => {
|
|||||||
if (bytes === 0) return "0 Byte";
|
if (bytes === 0) return "0 Byte";
|
||||||
const i = Number.parseInt(
|
const i = Number.parseInt(
|
||||||
Math.floor(Math.log(bytes) / Math.log(1024)).toString(),
|
Math.floor(Math.log(bytes) / Math.log(1024)).toString(),
|
||||||
|
10,
|
||||||
);
|
);
|
||||||
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
|
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text, TouchableOpacity, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
|
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
|
||||||
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";
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
type ActivityIndicatorProps,
|
type ActivityIndicatorProps,
|
||||||
Platform,
|
Platform,
|
||||||
View,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
|
||||||
interface Props extends ActivityIndicatorProps {}
|
interface Props extends ActivityIndicatorProps {}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ 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 { useCallback, 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 { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
@@ -20,37 +22,31 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
if (Platform.isTV) return null;
|
const isTv = Platform.isTV;
|
||||||
const selectedName = useMemo(
|
|
||||||
() =>
|
|
||||||
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
|
||||||
(x) => x.Type === "Video",
|
|
||||||
)?.DisplayTitle || "",
|
|
||||||
[item, selected],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const commonPrefix = useMemo(() => {
|
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||||
const mediaSources = item.MediaSources || [];
|
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
||||||
if (!mediaSources.length) return "";
|
if (videoStream?.DisplayTitle) {
|
||||||
|
return videoStream.DisplayTitle;
|
||||||
let commonPrefix = "";
|
|
||||||
for (let i = 0; i < mediaSources[0].Name!.length; i++) {
|
|
||||||
const char = mediaSources[0].Name![i];
|
|
||||||
if (mediaSources.every((source) => source.Name![i] === char)) {
|
|
||||||
commonPrefix += char;
|
|
||||||
} else {
|
|
||||||
commonPrefix = commonPrefix.slice(0, -1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return commonPrefix;
|
|
||||||
}, [item.MediaSources]);
|
|
||||||
|
|
||||||
const name = (name?: string | null) => {
|
// Fallback to source name
|
||||||
return name?.replace(commonPrefix, "").toLowerCase();
|
if (source.Name) {
|
||||||
};
|
return source.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort fallback
|
||||||
|
return `Source ${source.Id}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedName = useMemo(() => {
|
||||||
|
if (!selected) return "";
|
||||||
|
return getDisplayName(selected);
|
||||||
|
}, [selected, getDisplayName]);
|
||||||
|
|
||||||
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -88,7 +84,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
{`${name(source.Name)}`}
|
{getDisplayName(source)}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } 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";
|
||||||
@@ -12,6 +5,13 @@ import { useAtom } from "jotai";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
|
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
actorId: string;
|
actorId: string;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
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";
|
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { tc } from "@/utils/textTools";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
text?: string | null;
|
text?: string | null;
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import type { PropsWithChildren, ReactElement } from "react";
|
import type { PropsWithChildren, ReactElement } from "react";
|
||||||
import {
|
import { type NativeScrollEvent, View, type ViewProps } from "react-native";
|
||||||
type NativeScrollEvent,
|
|
||||||
NativeSyntheticEvent,
|
|
||||||
View,
|
|
||||||
type ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
interpolate,
|
interpolate,
|
||||||
useAnimatedRef,
|
useAnimatedRef,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { Platform, View, type 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,13 +1,3 @@
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { chromecast } from "@/utils/profiles/chromecast";
|
|
||||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
|
||||||
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 type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
@@ -15,7 +5,6 @@ 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 { 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,
|
||||||
@@ -33,12 +22,23 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import { chromecast } from "@/utils/profiles/chromecast";
|
||||||
|
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import type { Button } from "./Button";
|
import type { Button } from "./Button";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
selectedOptions: SelectedOptions;
|
selectedOptions: SelectedOptions;
|
||||||
|
isOffline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANIMATION_DURATION = 500;
|
const ANIMATION_DURATION = 500;
|
||||||
@@ -47,6 +47,7 @@ const MIN_PLAYBACK_WIDTH = 15;
|
|||||||
export const PlayButton: React.FC<Props> = ({
|
export const PlayButton: React.FC<Props> = ({
|
||||||
item,
|
item,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
|
isOffline,
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
@@ -76,7 +77,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
router.push(`/player/direct-player?${q}`);
|
router.push(`/player/direct-player?${q}`);
|
||||||
},
|
},
|
||||||
[router],
|
[router, isOffline],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
@@ -91,6 +92,8 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||||
|
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
offline: isOffline ? "true" : "false",
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryString = queryParams.toString();
|
const queryString = queryParams.toString();
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
|
||||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
|
||||||
import type { 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 } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { Alert, TouchableOpacity, View } from "react-native";
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
interpolate,
|
interpolate,
|
||||||
@@ -22,6 +14,10 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import type { Button } from "./Button";
|
import type { Button } from "./Button";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
|
|
||||||
@@ -38,12 +34,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
selectedOptions,
|
selectedOptions,
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { useAllSessions, type useSessionsProps } from "@/hooks/useSessions";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
type BaseItemDto,
|
type BaseItemDto,
|
||||||
@@ -15,9 +13,11 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { useAllSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
import { Text } from "./common/Text";
|
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
|
|||||||
@@ -1,50 +1,18 @@
|
|||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
|
||||||
import type { 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 type React from "react";
|
import type React from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
items: BaseItemDto[];
|
items: BaseItemDto[];
|
||||||
|
isOffline?: boolean;
|
||||||
size?: "default" | "large";
|
size?: "default" | "large";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const invalidateQueries = () => {
|
|
||||||
items.forEach((item) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["item", item.Id],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["resumeItems"],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["continueWatching"],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["nextUp-all"],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["nextUp"],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["episodes"],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["seasons"],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["home"],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const allPlayed = items.every((item) => item.UserData?.Played);
|
const allPlayed = items.every((item) => item.UserData?.Played);
|
||||||
|
const toggle = useMarkAsPlayed(items);
|
||||||
const markAsPlayedStatus = useMarkAsPlayed(items);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
@@ -52,8 +20,7 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
|||||||
fillColor={allPlayed ? "primary" : undefined}
|
fillColor={allPlayed ? "primary" : undefined}
|
||||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
console.log(allPlayed);
|
await toggle(!allPlayed);
|
||||||
await markAsPlayedStatus(!allPlayed);
|
|
||||||
}}
|
}}
|
||||||
size={props.size}
|
size={props.size}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { StyleSheet, View } from "react-native";
|
|
||||||
import { AnimatedCircularProgress } from "react-native-circular-progress";
|
import { AnimatedCircularProgress } from "react-native-circular-progress";
|
||||||
|
|
||||||
type ProgressCircleProps = {
|
type ProgressCircleProps = {
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
@@ -6,12 +12,6 @@ import type {
|
|||||||
TvResult,
|
TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { View, type ViewProps } from "react-native";
|
|
||||||
import { Badge } from "./Badge";
|
import { Badge } from "./Badge";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
@@ -7,6 +6,7 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
type TouchableOpacityProps,
|
type TouchableOpacityProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { router } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { View, type ViewProps } from "react-native";
|
||||||
ScrollView,
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
TouchableOpacity,
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
View,
|
|
||||||
type ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import { ItemCardText } from "./ItemCardText";
|
|
||||||
import { Loader } from "./Loader";
|
|
||||||
import { HorizontalScroll } from "./common/HorrizontalScroll";
|
import { HorizontalScroll } from "./common/HorrizontalScroll";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
||||||
|
import { ItemCardText } from "./ItemCardText";
|
||||||
|
|
||||||
interface SimilarItemsProps extends ViewProps {
|
interface SimilarItemsProps extends ViewProps {
|
||||||
itemId?: string | null;
|
itemId?: string | null;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { tc } from "@/utils/textTools";
|
|
||||||
import type { 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";
|
||||||
|
import { tc } from "@/utils/textTools";
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
if (Platform.isTV) return null;
|
const { t } = useTranslation();
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||||
}, [source]);
|
}, [source]);
|
||||||
@@ -28,9 +30,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
[subtitleStreams, selected],
|
[subtitleStreams, selected],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (subtitleStreams?.length === 0) return null;
|
if (Platform.isTV || subtitleStreams?.length === 0) return null;
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as React from "react";
|
|
||||||
import renderer from "react-test-renderer";
|
import renderer from "react-test-renderer";
|
||||||
|
|
||||||
import { ThemedText } from "../ThemedText";
|
import { ThemedText } from "../ThemedText";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useMemo } from "react";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { StyleSheet, View, type ViewProps } from "react-native";
|
|
||||||
|
|
||||||
const getItemStyle = (index: number, numColumns: number) => {
|
const _getItemStyle = (index: number, numColumns: number) => {
|
||||||
const alignItems = (() => {
|
const alignItems = (() => {
|
||||||
if (numColumns < 2 || index % numColumns === 0) return "flex-start";
|
if (numColumns < 2 || index % numColumns === 0) return "flex-start";
|
||||||
if ((index + 1) % numColumns === 0) return "flex-end";
|
if ((index + 1) % numColumns === 0) return "flex-end";
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import {
|
||||||
import React, {
|
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
interface Props<T> {
|
interface Props<T> {
|
||||||
data: T[];
|
data: T[];
|
||||||
@@ -33,14 +34,17 @@ const Dropdown = <T,>({
|
|||||||
multiple = false,
|
multiple = false,
|
||||||
...props
|
...props
|
||||||
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
||||||
if (Platform.isTV) return null;
|
const isTv = Platform.isTV;
|
||||||
|
|
||||||
const [selected, setSelected] = useState<T[]>();
|
const [selected, setSelected] = useState<T[]>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selected !== undefined) {
|
if (selected !== undefined) {
|
||||||
onSelected(...selected);
|
onSelected(...selected);
|
||||||
}
|
}
|
||||||
}, [selected]);
|
}, [selected, onSelected]);
|
||||||
|
|
||||||
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DisabledSetting disabled={disabled === true} showText={false} {...props}>
|
<DisabledSetting disabled={disabled === true} showText={false} {...props}>
|
||||||
@@ -58,7 +62,7 @@ const Dropdown = <T,>({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<>{title}</>
|
title
|
||||||
)}
|
)}
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
@@ -71,7 +75,7 @@ const Dropdown = <T,>({
|
|||||||
sideOffset={0}
|
sideOffset={0}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>{label}</DropdownMenu.Label>
|
<DropdownMenu.Label>{label}</DropdownMenu.Label>
|
||||||
{data.map((item, idx) =>
|
{data.map((item, _idx) =>
|
||||||
multiple ? (
|
multiple ? (
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
value={
|
value={
|
||||||
@@ -80,7 +84,10 @@ const Dropdown = <T,>({
|
|||||||
: "off"
|
: "off"
|
||||||
}
|
}
|
||||||
key={keyExtractor(item)}
|
key={keyExtractor(item)}
|
||||||
onValueChange={(next: "on" | "off", previous: "on" | "off") => {
|
onValueChange={(
|
||||||
|
next: "on" | "off",
|
||||||
|
_previous: "on" | "off",
|
||||||
|
) => {
|
||||||
setSelected((p) => {
|
setSelected((p) => {
|
||||||
const prev = p || [];
|
const prev = p || [];
|
||||||
if (next === "on") {
|
if (next === "on") {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BlurView, type BlurViewProps } from "expo-blur";
|
import { BlurView, type BlurViewProps } from "expo-blur";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
@@ -6,8 +5,6 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
type TouchableOpacityProps,
|
type TouchableOpacityProps,
|
||||||
View,
|
|
||||||
ViewProps,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
|
||||||
interface Props extends BlurViewProps {
|
interface Props extends BlurViewProps {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FlashList, type FlashListProps } from "@shopify/flash-list";
|
import { FlashList, type FlashListProps } from "@shopify/flash-list";
|
||||||
import React, { forwardRef, useImperativeHandle, useRef } from "react";
|
import React, { useImperativeHandle, useRef } from "react";
|
||||||
import { View, type ViewStyle } from "react-native";
|
import { View, type ViewStyle } from "react-native";
|
||||||
import { Text } from "./Text";
|
import { Text } from "./Text";
|
||||||
|
|
||||||
@@ -19,64 +19,59 @@ interface HorizontalScrollProps<T>
|
|||||||
keyExtractor?: (item: T, index: number) => string;
|
keyExtractor?: (item: T, index: number) => string;
|
||||||
containerStyle?: ViewStyle;
|
containerStyle?: ViewStyle;
|
||||||
contentContainerStyle?: ViewStyle;
|
contentContainerStyle?: ViewStyle;
|
||||||
loadingContainerStyle?: ViewStyle;
|
|
||||||
height?: number;
|
height?: number;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
extraData?: any;
|
extraData?: any;
|
||||||
noItemsText?: string;
|
noItemsText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HorizontalScroll = forwardRef<
|
export const HorizontalScroll = <T,>(
|
||||||
HorizontalScrollRef,
|
props: HorizontalScrollProps<T> & {
|
||||||
HorizontalScrollProps<any>
|
ref?: React.ForwardedRef<HorizontalScrollRef>;
|
||||||
>(
|
},
|
||||||
<T,>(
|
) => {
|
||||||
{
|
const {
|
||||||
data = [],
|
data = [],
|
||||||
keyExtractor,
|
keyExtractor,
|
||||||
renderItem,
|
renderItem,
|
||||||
containerStyle,
|
containerStyle,
|
||||||
contentContainerStyle,
|
contentContainerStyle,
|
||||||
loadingContainerStyle,
|
loading = false,
|
||||||
loading = false,
|
height = 164,
|
||||||
height = 164,
|
extraData,
|
||||||
extraData,
|
noItemsText,
|
||||||
noItemsText,
|
ref,
|
||||||
...props
|
...restProps
|
||||||
}: HorizontalScrollProps<T>,
|
} = props;
|
||||||
ref: React.ForwardedRef<HorizontalScrollRef>,
|
|
||||||
) => {
|
|
||||||
const flashListRef = useRef<FlashList<T>>(null);
|
|
||||||
|
|
||||||
useImperativeHandle(ref!, () => ({
|
const flashListRef = useRef<FlashList<T>>(null);
|
||||||
scrollToIndex: (index: number, viewOffset: number) => {
|
|
||||||
flashListRef.current?.scrollToIndex({
|
|
||||||
index,
|
|
||||||
animated: true,
|
|
||||||
viewPosition: 0,
|
|
||||||
viewOffset,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const renderFlashListItem = ({
|
useImperativeHandle(ref!, () => ({
|
||||||
item,
|
scrollToIndex: (index: number, viewOffset: number) => {
|
||||||
index,
|
flashListRef.current?.scrollToIndex({
|
||||||
}: {
|
index,
|
||||||
item: T;
|
animated: true,
|
||||||
index: number;
|
viewPosition: 0,
|
||||||
}) => <View className='mr-2'>{renderItem(item, index)}</View>;
|
viewOffset,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
if (!data || loading) {
|
const renderFlashListItem = ({ item, index }: { item: T; index: number }) => (
|
||||||
return (
|
<View className='mr-2'>{renderItem(item, index)}</View>
|
||||||
<View className='px-4 mb-2'>
|
);
|
||||||
<View className='bg-neutral-950 h-24 w-full rounded-md mb-2' />
|
|
||||||
<View className='bg-neutral-950 h-10 w-full rounded-md mb-1' />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!data || loading) {
|
||||||
return (
|
return (
|
||||||
|
<View className='px-4 mb-2'>
|
||||||
|
<View className='bg-neutral-950 h-24 w-full rounded-md mb-2' />
|
||||||
|
<View className='bg-neutral-950 h-10 w-full rounded-md mb-1' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[{ height }, containerStyle]}>
|
||||||
<FlashList<T>
|
<FlashList<T>
|
||||||
ref={flashListRef}
|
ref={flashListRef}
|
||||||
data={data}
|
data={data}
|
||||||
@@ -97,8 +92,8 @@ export const HorizontalScroll = forwardRef<
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
);
|
</View>
|
||||||
},
|
);
|
||||||
);
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -14,6 +13,7 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { Text } from "./Text";
|
import { Text } from "./Text";
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ export function InfiniteHorizontalScroll({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
getNextPageParam: (lastPage, pages) => {
|
getNextPageParam: (lastPage, pages) => {
|
||||||
|
|||||||