Compare commits
1 Commits
no-tv
...
feat/vlc-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ea2d81fb4 |
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(rm:*)",
|
|
||||||
"Bash(find:*)",
|
|
||||||
"Bash(grep:*)",
|
|
||||||
"Bash(for file in /Users/fredrikburmester/Documents/GitHub/streamyfin/translations/*.json)",
|
|
||||||
"Bash(do)",
|
|
||||||
"Bash(if grep -q \"live_tv\" \"$file\")",
|
|
||||||
"Bash(then)",
|
|
||||||
"Bash(echo \"Processing $file\")",
|
|
||||||
"Bash(fi)",
|
|
||||||
"Bash(done)",
|
|
||||||
"Bash(bun run:*)",
|
|
||||||
"Bash(pod install:*)",
|
|
||||||
"Bash(bun install:*)",
|
|
||||||
"Bash(ls:*)",
|
|
||||||
"Bash(cat:*)"
|
|
||||||
],
|
|
||||||
"deny": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
EXPO_PUBLIC_WRITE_DEBUG=1
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
EXPO_PUBLIC_WRITE_DEBUG=0
|
|
||||||
1
.gitattributes
vendored
@@ -1 +0,0 @@
|
|||||||
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text
|
|
||||||
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -43,8 +43,6 @@ body:
|
|||||||
label: Version
|
label: Version
|
||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
- 0.29.0
|
|
||||||
- 0.28.0
|
|
||||||
- 0.27.0
|
- 0.27.0
|
||||||
- 0.26.1
|
- 0.26.1
|
||||||
- 0.26.0
|
- 0.26.0
|
||||||
|
|||||||
79
.github/workflows/build-android.yml
vendored
@@ -1,79 +0,0 @@
|
|||||||
name: 🤖 Android APK Build
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
pull_request:
|
|
||||||
branches: [develop, master]
|
|
||||||
push:
|
|
||||||
branches: [develop, master]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
name: 🏗️ Build Android APK
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 📥 Checkout code
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
||||||
show-progress: false
|
|
||||||
submodules: recursive
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
|
||||||
with:
|
|
||||||
bun-version: '1.2.17'
|
|
||||||
|
|
||||||
- name: ☕ Setup JDK
|
|
||||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
|
||||||
with:
|
|
||||||
distribution: 'zulu'
|
|
||||||
java-version: '17'
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
|
||||||
with:
|
|
||||||
path: ~/.bun/install/cache
|
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-cache-
|
|
||||||
|
|
||||||
- name: 📦 Install dependencies
|
|
||||||
run: |
|
|
||||||
bun install --frozen-lockfile
|
|
||||||
bun run submodule-reload
|
|
||||||
|
|
||||||
- name: 💾 Cache Android dependencies
|
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
android/.gradle
|
|
||||||
key: ${{ runner.os }}-android-deps-${{ hashFiles('android/**/build.gradle') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-android-deps-
|
|
||||||
|
|
||||||
- name: 🛠️ Generate project files
|
|
||||||
run: bun run prebuild
|
|
||||||
|
|
||||||
- name: 🚀 Build APK via Bun
|
|
||||||
run: bun run build:android:local
|
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
|
||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: 📤 Upload APK artifact
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: streamyfin-apk-${{ env.DATE_TAG }}
|
|
||||||
path: |
|
|
||||||
android/app/build/outputs/apk/release/*.apk
|
|
||||||
android/app/build/outputs/bundle/release/*.aab
|
|
||||||
retention-days: 7
|
|
||||||
49
.github/workflows/build-ios.yaml
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
name: Automatic Build and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: macos-15
|
||||||
|
name: Build IOS
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
name: Check out repository
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
- run: |
|
||||||
|
bun i && bun run submodule-reload
|
||||||
|
npx expo prebuild
|
||||||
|
- uses: sparkfabrik/ios-build-action@v2.3.0
|
||||||
|
with:
|
||||||
|
upload-to-testflight: false
|
||||||
|
increment-build-number: false
|
||||||
|
build-pods: true
|
||||||
|
pods-path: "ios/Podfile"
|
||||||
|
configuration: Release
|
||||||
|
# Change later to app-store if wanted
|
||||||
|
export-method: appstore
|
||||||
|
#export-method: ad-hoc
|
||||||
|
workspace-path: "ios/Streamyfin.xcodeproj/project.xcworkspace/"
|
||||||
|
project-path: "ios/Streamyfin.xcodeproj"
|
||||||
|
scheme: Streamyfin
|
||||||
|
apple-key-id: ${{ secrets.APPLE_KEY_ID }}
|
||||||
|
apple-key-issuer-id: ${{ secrets.APPLE_KEY_ISSUER_ID }}
|
||||||
|
apple-key-content: ${{ secrets.APPLE_KEY_CONTENT }}
|
||||||
|
team-id: ${{ secrets.TEAM_ID }}
|
||||||
|
team-name: ${{ secrets.TEAM_NAME }}
|
||||||
|
#match-password: ${{ secrets.MATCH_PASSWORD }}
|
||||||
|
#match-git-url: ${{ secrets.MATCH_GIT_URL }}
|
||||||
|
#match-git-basic-authorization: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
|
||||||
|
#match-build-type: "appstore"
|
||||||
|
#browserstack-upload: true
|
||||||
|
#browserstack-username: ${{ secrets.BROWSERSTACK_USERNAME }}
|
||||||
|
#browserstack-access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
|
||||||
|
#fastlane-env: stage
|
||||||
|
ios-app-id: com.stetsed.teststreamyfin
|
||||||
|
output-path: build-${{ github.sha }}.ipa
|
||||||
70
.github/workflows/build-ios.yml
vendored
@@ -1,70 +0,0 @@
|
|||||||
name: 🤖 iOS IPA Build
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
pull_request:
|
|
||||||
branches: [develop, master]
|
|
||||||
push:
|
|
||||||
branches: [develop, master]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: macos-15
|
|
||||||
name: 🏗️ Build iOS IPA
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 📥 Check out repository
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
||||||
show-progress: false
|
|
||||||
submodules: recursive
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
|
||||||
with:
|
|
||||||
bun-version: '1.2.17'
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
|
||||||
with:
|
|
||||||
path: ~/.bun/install/cache
|
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-cache-
|
|
||||||
|
|
||||||
- name: 📦 Install & Prepare
|
|
||||||
run: |
|
|
||||||
bun install --frozen-lockfile
|
|
||||||
bun run submodule-reload
|
|
||||||
|
|
||||||
- name: 🛠️ Generate project files
|
|
||||||
run: bun run prebuild
|
|
||||||
|
|
||||||
- name: 🏗 Setup EAS
|
|
||||||
uses: expo/expo-github-action@main
|
|
||||||
with:
|
|
||||||
eas-version: 16.7.1
|
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
|
||||||
|
|
||||||
- name: 🏗️ Build iOS app
|
|
||||||
run: |
|
|
||||||
eas build -p ios --local --non-interactive
|
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
|
||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: 📤 Upload IPA artifact
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: streamyfin-ipa-${{ env.DATE_TAG }}
|
|
||||||
path: |
|
|
||||||
build-*.ipa
|
|
||||||
retention-days: 7
|
|
||||||
46
.github/workflows/check-lockfile.yml
vendored
@@ -1,46 +0,0 @@
|
|||||||
name: 🔒 Lockfile Consistency Check
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [develop, master]
|
|
||||||
push:
|
|
||||||
branches: [develop, master]
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-lockfile:
|
|
||||||
name: 🔍 Check bun.lock and package.json consistency
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 📥 Checkout repository
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
||||||
show-progress: false
|
|
||||||
submodules: recursive
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
|
||||||
with:
|
|
||||||
bun-version: '1.2.17'
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.bun/install/cache
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
|
||||||
|
|
||||||
- name: 🛡️ Verify lockfile consistency
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
echo "➡️ Checking for discrepancies between bun.lock and package.json..."
|
|
||||||
bun install --frozen-lockfile --dry-run --ignore-scripts
|
|
||||||
echo "✅ Lockfile is consistent with package.json!"
|
|
||||||
43
.github/workflows/ci-codeql.yml
vendored
@@ -1,43 +0,0 @@
|
|||||||
name: 🛡️ CodeQL Analysis
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master, develop]
|
|
||||||
pull_request:
|
|
||||||
branches: [master, develop]
|
|
||||||
schedule:
|
|
||||||
- cron: '24 2 * * *'
|
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analyze:
|
|
||||||
name: 🔎 Analyze with CodeQL
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
security-events: write
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
language: [ 'javascript-typescript' ]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 📥 Checkout repository
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
||||||
show-progress: false
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
|
||||||
uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
|
||||||
with:
|
|
||||||
languages: ${{ matrix.language }}
|
|
||||||
queries: +security-extended,security-and-quality
|
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
|
||||||
uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
|
||||||
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
|
||||||
24
.github/workflows/conflict.yml
vendored
@@ -1,24 +0,0 @@
|
|||||||
name: 🏷️🔀Merge Conflict Labeler
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [develop]
|
|
||||||
pull_request_target:
|
|
||||||
branches: [develop]
|
|
||||||
types: [synchronize]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
label:
|
|
||||||
name: 🏷️ Labeling Merge Conflicts
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
if: ${{ github.repository == 'streamyfin/streamyfin' }}
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
|
||||||
- name: 🚩 Apply merge conflict label
|
|
||||||
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
|
||||||
with:
|
|
||||||
dirtyLabel: 'merge-conflict'
|
|
||||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
|
||||||
repoToken: '${{ secrets.GITHUB_TOKEN }}'
|
|
||||||
41
.github/workflows/lint-pr.yaml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: "Lint PR"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- edited
|
||||||
|
- synchronize
|
||||||
|
- reopened
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
main:
|
||||||
|
name: Validate PR title
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: amannn/action-semantic-pull-request@v5
|
||||||
|
id: lint_pr_title
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- uses: marocchino/sticky-pull-request-comment@v2
|
||||||
|
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
message: |
|
||||||
|
Hey there and thank you for opening this pull request! 👋🏼
|
||||||
|
|
||||||
|
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
|
||||||
|
|
||||||
|
Details:
|
||||||
|
|
||||||
|
```
|
||||||
|
${{ steps.lint_pr_title.outputs.error_message }}
|
||||||
|
```
|
||||||
|
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||||
|
uses: marocchino/sticky-pull-request-comment@v2
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
delete: true
|
||||||
95
.github/workflows/linting.yml
vendored
@@ -1,95 +0,0 @@
|
|||||||
name: 🚦 Security & Quality Gate
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types: [opened, edited, synchronize, reopened]
|
|
||||||
branches: [develop, master]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
validate_pr_title:
|
|
||||||
name: "📝 Validate PR Title"
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3
|
|
||||||
id: lint_pr_title
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- uses: marocchino/sticky-pull-request-comment@d2ad0de260ae8b0235ce059e63f2949ba9e05943 # v2.9.3
|
|
||||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
|
||||||
with:
|
|
||||||
header: pr-title-lint-error
|
|
||||||
message: |
|
|
||||||
Hey there and thank you for opening this pull request! 👋🏼
|
|
||||||
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
|
|
||||||
|
|
||||||
**Error details:**
|
|
||||||
```
|
|
||||||
${{ steps.lint_pr_title.outputs.error_message }}
|
|
||||||
```
|
|
||||||
|
|
||||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
|
||||||
uses: marocchino/sticky-pull-request-comment@d2ad0de260ae8b0235ce059e63f2949ba9e05943 # v2.9.3
|
|
||||||
with:
|
|
||||||
header: pr-title-lint-error
|
|
||||||
delete: true
|
|
||||||
|
|
||||||
dependency-review:
|
|
||||||
name: 🔍 Vulnerable Dependencies
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- name: Checkout Repository
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Dependency Review
|
|
||||||
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
|
|
||||||
with:
|
|
||||||
fail-on-severity: high
|
|
||||||
deny-licenses: GPL-3.0, AGPL-3.0
|
|
||||||
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
|
||||||
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
|
||||||
|
|
||||||
code_quality:
|
|
||||||
name: "🔍 Lint & Test (${{ matrix.command }})"
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
command:
|
|
||||||
- "lint"
|
|
||||||
- "check"
|
|
||||||
steps:
|
|
||||||
- name: "📥 Checkout PR code"
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
submodules: recursive
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
|
||||||
with:
|
|
||||||
node-version: '22.x'
|
|
||||||
|
|
||||||
- name: "🍞 Setup Bun"
|
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
|
||||||
with:
|
|
||||||
bun-version: '1.2.17'
|
|
||||||
|
|
||||||
- name: "📦 Install dependencies"
|
|
||||||
run: bun install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: "🚨 Run ${{ matrix.command }}"
|
|
||||||
run: bun run ${{ matrix.command }}
|
|
||||||
39
.github/workflows/main.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Handle Stale Issues
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "30 1 * * *" # Runs at 1:30 UTC every day
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v9
|
||||||
|
with:
|
||||||
|
# Issue specific settings
|
||||||
|
days-before-issue-stale: 90
|
||||||
|
days-before-issue-close: 7
|
||||||
|
stale-issue-label: "stale"
|
||||||
|
stale-issue-message: |
|
||||||
|
This issue has been automatically marked as stale because it has had no activity in the last 30 days.
|
||||||
|
|
||||||
|
If this issue is still relevant, please leave a comment to keep it open.
|
||||||
|
Otherwise, it will be closed in 7 days if no further activity occurs.
|
||||||
|
|
||||||
|
Thank you for your contributions!
|
||||||
|
close-issue-message: |
|
||||||
|
This issue has been automatically closed because it has been inactive for 7 days since being marked as stale.
|
||||||
|
|
||||||
|
If you believe this issue is still relevant, please feel free to reopen it and add a comment explaining the current status.
|
||||||
|
|
||||||
|
# Pull request settings (disabled)
|
||||||
|
days-before-pr-stale: -1
|
||||||
|
days-before-pr-close: -1
|
||||||
|
|
||||||
|
# Other settings
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
operations-per-run: 100
|
||||||
|
exempt-issue-labels: "Roadmap v1,help needed,enhancement"
|
||||||
18
.github/workflows/notification.yaml
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
name: Discord Pull Request Notification
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: joelwmale/webhook-action@master
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
body: |
|
||||||
|
{
|
||||||
|
"content": "New Pull Request: ${{ github.event.pull_request.title }}\nBy: ${{ github.event.pull_request.user.login }}\n\n${{ github.event.pull_request.html_url }}",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/193271640"
|
||||||
|
}
|
||||||
23
.github/workflows/notification.yml
vendored
@@ -1,23 +0,0 @@
|
|||||||
name: 🛎️ Discord Pull Request Notification
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, reopened]
|
|
||||||
branches: [develop]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
notify:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
steps:
|
|
||||||
- name: 🛎️ Notify Discord
|
|
||||||
uses: Ilshidur/action-discord@0c4b27844ba47cb1c7bee539c8eead5284ce9fa9 # 0.3.2
|
|
||||||
env:
|
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
|
||||||
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
|
|
||||||
with:
|
|
||||||
args: |
|
|
||||||
📢 New Pull Request in **${{ github.repository }}**
|
|
||||||
**Title:** ${{ github.event.pull_request.title }}
|
|
||||||
**By:** ${{ github.event.pull_request.user.login }}
|
|
||||||
**Branch:** ${{ github.event.pull_request.head.ref }}
|
|
||||||
🔗 ${{ github.event.pull_request.html_url }}
|
|
||||||
49
.github/workflows/stale.yml
vendored
@@ -1,49 +0,0 @@
|
|||||||
name: 🕒 Handle Stale Issues
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
# Runs daily at 1:30 AM UTC (3:30 AM CEST - France time)
|
|
||||||
- cron: "30 1 * * *"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
stale-issues:
|
|
||||||
name: 🗑️ Cleanup Stale Issues
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 🔄 Mark/Close Stale Issues
|
|
||||||
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
|
||||||
with:
|
|
||||||
# Global settings
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
operations-per-run: 500 # Increase if you have >1000 issues
|
|
||||||
log-level: debug
|
|
||||||
|
|
||||||
# Issue configuration
|
|
||||||
days-before-issue-stale: 90
|
|
||||||
days-before-issue-close: 7
|
|
||||||
stale-issue-label: "stale"
|
|
||||||
exempt-issue-labels: "Roadmap v1,help needed,enhancement"
|
|
||||||
|
|
||||||
# Notifications messages
|
|
||||||
stale-issue-message: |
|
|
||||||
⏳ This issue has been automatically marked as **stale** because it has had no activity for 90 days.
|
|
||||||
|
|
||||||
**Next steps:**
|
|
||||||
- If this is still relevant, add a comment to keep it open
|
|
||||||
- Otherwise, it will be closed in 7 days
|
|
||||||
|
|
||||||
Thank you for your contributions! 🙌
|
|
||||||
|
|
||||||
close-issue-message: |
|
|
||||||
🚮 This issue has been automatically closed due to inactivity (7 days since being marked stale).
|
|
||||||
|
|
||||||
**Need to reopen?**
|
|
||||||
Click "Reopen" and add a comment explaining why this should stay open.
|
|
||||||
|
|
||||||
# Disable PR handling
|
|
||||||
days-before-pr-stale: -1
|
|
||||||
days-before-pr-close: -1
|
|
||||||
8
.gitignore
vendored
@@ -10,7 +10,7 @@ npm-debug.*
|
|||||||
*.orig.*
|
*.orig.*
|
||||||
web-build/
|
web-build/
|
||||||
modules/vlc-player/android/build
|
modules/vlc-player/android/build
|
||||||
modules/vlc-player/android/.gradle
|
bun.lockb
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -19,7 +19,9 @@ expo-env.d.ts
|
|||||||
|
|
||||||
Streamyfin.app
|
Streamyfin.app
|
||||||
|
|
||||||
|
build-*
|
||||||
*.mp4
|
*.mp4
|
||||||
|
build-*
|
||||||
Streamyfin.app
|
Streamyfin.app
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
@@ -42,7 +44,3 @@ credentials.json
|
|||||||
.idea/
|
.idea/
|
||||||
.ruby-lsp
|
.ruby-lsp
|
||||||
modules/hls-downloader/android/build
|
modules/hls-downloader/android/build
|
||||||
streamyfin-4fec1-firebase-adminsdk.json
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
*.aab
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
lint-staged
|
|
||||||
16
.vscode/settings.json
vendored
@@ -1,24 +1,16 @@
|
|||||||
{
|
{
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.formatOnSave": true,
|
||||||
"[javascript]": {
|
"[javascript]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true
|
||||||
},
|
},
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true
|
||||||
},
|
},
|
||||||
"prettier.printWidth": 120,
|
"prettier.printWidth": 120,
|
||||||
"[swift]": {
|
"[swift]": {
|
||||||
"editor.defaultFormatter": "sswg.swift-lang"
|
"editor.defaultFormatter": "sswg.swift-lang"
|
||||||
},
|
|
||||||
"editor.formatOnSave": true,
|
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
|
||||||
"[typescript]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
|
||||||
"[javascriptreact]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
|
||||||
"editor.formatOnSave": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
58
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||||
|
|
||||||
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.
|
Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
|
||||||
|
|
||||||
<div style="display: flex; flex-direction: row; gap: 8px">
|
<div style="display: flex; flex-direction: row; gap: 8px">
|
||||||
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
||||||
@@ -15,47 +15,47 @@ A simple and user-friendly Jellyfin video streaming client built with Expo. If y
|
|||||||
|
|
||||||
- 🚀 **Skip Intro / Credits Support**
|
- 🚀 **Skip Intro / Credits Support**
|
||||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
||||||
|
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
||||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
||||||
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
|
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
|
||||||
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
||||||
- 👁️ **Sessions View:** View all active sessions currently streaming on your server.
|
|
||||||
|
|
||||||
## 🧪 Experimental Features
|
## 🧪 Experimental Features
|
||||||
|
|
||||||
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These features are still in development, and your patience and feedback are much appreciated as we work to improve them.
|
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These are still in development, and we appreciate your patience and feedback as we work to improve them.
|
||||||
|
|
||||||
### 📥 Downloading
|
### Downloading
|
||||||
|
|
||||||
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
||||||
|
|
||||||
### 🎥 Chromecast
|
### Chromecast
|
||||||
|
|
||||||
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos, but we're working on adding support for subtitles and other features.
|
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features.
|
||||||
|
|
||||||
### 🧩 Streamyfin Plugin
|
### Streamyfin Plugin
|
||||||
|
|
||||||
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
|
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that hold all settings for the client Streamyfin. This allows you to syncronize settings accross all your users, like:
|
||||||
|
|
||||||
- Auto log in to Jellyseerr without the user having to do anything
|
- Auto log in to Jellyseerr without the user having to do anythin
|
||||||
- Choose the default languages
|
- Choose the default languages
|
||||||
- Set download method and search provider
|
- Set download method and search provider
|
||||||
- Customize home screen
|
- Customize homescreen
|
||||||
- And much more...
|
- And more...
|
||||||
|
|
||||||
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
||||||
|
|
||||||
### 🔍 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.
|
||||||
|
|
||||||
## 🛣️ Roadmap for V1
|
## Roadmap for V1
|
||||||
|
|
||||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests.
|
||||||
|
|
||||||
## 📥 Get it now
|
## Get it now
|
||||||
|
|
||||||
<div style="display: flex; gap: 5px;">
|
<div style="display: flex; gap: 5px;">
|
||||||
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||||
@@ -64,9 +64,9 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To
|
|||||||
|
|
||||||
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
|
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
|
||||||
|
|
||||||
### 🧪 Beta testing
|
### Beta testing
|
||||||
|
|
||||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and I'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that I can manually add you.
|
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
|
||||||
|
|
||||||
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
||||||
|
|
||||||
@@ -81,12 +81,11 @@ To access the Streamyfin beta, you need to subscribe to the Member tier (or high
|
|||||||
|
|
||||||
We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas.
|
We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas.
|
||||||
|
|
||||||
### 👨💻 Development info
|
### Development info
|
||||||
|
|
||||||
1. Use node `>20`
|
1. Use node `>20`
|
||||||
2. Install dependencies `bun i && bun run submodule-reload`
|
2. Install dependencies `bun i && bun run submodule-reload`
|
||||||
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||||
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
|
||||||
4. run `npm run prebuild`
|
4. run `npm run prebuild`
|
||||||
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app.
|
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app.
|
||||||
|
|
||||||
@@ -118,24 +117,13 @@ If you have questions or need support, feel free to reach out:
|
|||||||
- GitHub Issues: Report bugs or request features here.
|
- GitHub Issues: Report bugs or request features here.
|
||||||
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
||||||
|
|
||||||
## ❓ FAQ
|
|
||||||
|
|
||||||
1. Q: Why can't I see my libraries in Streamyfin?
|
|
||||||
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only.
|
|
||||||
2. Q: Why can't I see my music library?
|
|
||||||
A: We don't currently support music and are unlikely to support music in the near future.
|
|
||||||
|
|
||||||
## 📝 Credits
|
## 📝 Credits
|
||||||
|
|
||||||
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
||||||
|
|
||||||
## ✨ Acknowledgements
|
## ✨ Acknowledgements
|
||||||
|
|
||||||
We would like to thank the Jellyfin team for their great software and awesome support on discord.
|
### Core Developers
|
||||||
|
|
||||||
Special shoutout to the JF official clients for being an inspiration to ours.
|
|
||||||
|
|
||||||
### 🏆 Core Developers
|
|
||||||
|
|
||||||
Thanks to the following contributors for their significant contributions:
|
Thanks to the following contributors for their significant contributions:
|
||||||
|
|
||||||
@@ -220,12 +208,6 @@ I'd also like to thank the following people and projects for their contributions
|
|||||||
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
|
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
|
||||||
- The Jellyfin devs for always being helpful in the Discord.
|
- The Jellyfin devs for always being helpful in the Discord.
|
||||||
|
|
||||||
## ⭐ Star History
|
## Star History
|
||||||
|
|
||||||
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
||||||
|
|
||||||
## ⚠️ Disclaimer
|
|
||||||
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
|
|
||||||
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur)
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
module.exports = ({ config }) => {
|
module.exports = ({ config }) => {
|
||||||
config.plugins.push([
|
if (process.env.EXPO_TV != "1") {
|
||||||
"react-native-google-cast",
|
config.plugins.push([
|
||||||
{ useDefaultExpandedMediaControls: true },
|
"react-native-google-cast",
|
||||||
]);
|
{ useDefaultExpandedMediaControls: true },
|
||||||
|
]);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
android: {
|
|
||||||
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
|
|
||||||
},
|
|
||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
39
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.29.1",
|
"version": "0.27.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -27,32 +27,26 @@
|
|||||||
"usesNonExemptEncryption": false
|
"usesNonExemptEncryption": false
|
||||||
},
|
},
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
"bundleIdentifier": "com.fredrikburmester.streamyfin"
|
||||||
"icon": {
|
|
||||||
"dark": "./assets/images/icon-plain.png",
|
|
||||||
"light": "./assets/images/icon-ios-light.png",
|
|
||||||
"tinted": "./assets/images/icon-ios-tinted.png"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 56,
|
"versionCode": 53,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-plain.png",
|
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||||
"monochromeImage": "./assets/images/icon-mono.png",
|
|
||||||
"backgroundColor": "#464646"
|
|
||||||
},
|
},
|
||||||
"package": "com.fredrikburmester.streamyfin",
|
"package": "com.fredrikburmester.streamyfin",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"android.permission.FOREGROUND_SERVICE",
|
"android.permission.FOREGROUND_SERVICE",
|
||||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||||
"android.permission.WRITE_SETTINGS"
|
"android.permission.WRITE_SETTINGS"
|
||||||
],
|
]
|
||||||
"googleServicesFile": "./google-services.json"
|
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
"@react-native-tvos/config-tv",
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
|
"@config-plugins/ffmpeg-kit-react-native",
|
||||||
[
|
[
|
||||||
"react-native-video",
|
"react-native-video",
|
||||||
{
|
{
|
||||||
@@ -117,7 +111,6 @@
|
|||||||
["./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",
|
||||||
{
|
{
|
||||||
@@ -125,21 +118,7 @@
|
|||||||
"image": "./assets/images/StreamyFinFinal.png",
|
"image": "./assets/images/StreamyFinFinal.png",
|
||||||
"imageWidth": 100
|
"imageWidth": 100
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
[
|
|
||||||
"expo-notifications",
|
|
||||||
{
|
|
||||||
"icon": "./assets/images/notification.png",
|
|
||||||
"color": "#9333EA"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"react-native-google-cast",
|
|
||||||
{
|
|
||||||
"useDefaultExpandedMediaControls": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"expo-background-task"
|
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
@@ -152,7 +131,7 @@
|
|||||||
"projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
"projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"owner": "streamyfin",
|
"owner": "fredrikburmester",
|
||||||
"runtimeVersion": {
|
"runtimeVersion": {
|
||||||
"policy": "appVersion"
|
"policy": "appVersion"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Stack } from "expo-router";
|
import {Stack} from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function CustomMenuLayout() {
|
export default function CustomMenuLayout() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name="index"
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
import { Platform } from "react-native";
|
||||||
import { useAtom } from "jotai/index";
|
import { FlatList, TouchableOpacity, View } from "react-native";
|
||||||
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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { useAtom } from "jotai/index";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
|
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
|
||||||
|
|
||||||
@@ -25,11 +26,11 @@ export default function menuLinks() {
|
|||||||
const getMenuLinks = useCallback(async () => {
|
const getMenuLinks = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api?.axiosInstance.get(
|
const response = await api?.axiosInstance.get(
|
||||||
`${api?.basePath}/web/config.json`,
|
api?.basePath + "/web/config.json"
|
||||||
);
|
);
|
||||||
const config = response?.data;
|
const config = response?.data;
|
||||||
|
|
||||||
if (!config && !Object.hasOwn(config, "menuLinks")) {
|
if (!config && !config.hasOwnProperty("menuLinks")) {
|
||||||
console.error("Menu links not found");
|
console.error("Menu links not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -45,7 +46,7 @@ export default function menuLinks() {
|
|||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<FlatList
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: 10,
|
paddingTop: 10,
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
@@ -62,7 +63,7 @@ export default function menuLinks() {
|
|||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={item.name}
|
title={item.name}
|
||||||
iconAfter={<Ionicons name='link' size={24} color='white' />}
|
iconAfter={<Ionicons name="link" size={24} color="white" />}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
@@ -75,10 +76,8 @@ export default function menuLinks() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className='flex flex-col items-center justify-center h-full'>
|
<View className="flex flex-col items-center justify-center h-full">
|
||||||
<Text className='font-bold text-xl text-neutral-500'>
|
<Text className="font-bold text-xl text-neutral-500">{t("custom_links.no_links")}</Text>
|
||||||
{t("custom_links.no_links")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Stack } from "expo-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function SearchLayout() {
|
export default function SearchLayout() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name="index"
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
@@ -17,7 +17,7 @@ export default function SearchLayout() {
|
|||||||
backgroundColor: "black",
|
backgroundColor: "black",
|
||||||
},
|
},
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback, useState } from "react";
|
|
||||||
import { RefreshControl, ScrollView, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Favorites } from "@/components/home/Favorites";
|
import { Favorites } from "@/components/home/Favorites";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
import { RefreshControl, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function favorites() {
|
export default function favorites() {
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
@@ -18,7 +18,7 @@ export default function favorites() {
|
|||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@ export default function favorites() {
|
|||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='my-4'>
|
<View className="my-4">
|
||||||
<Favorites />
|
<Favorites />
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Stack, useRouter } from "expo-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
import { Ionicons, Feather } from "@expo/vector-icons";
|
||||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
import { Stack, useRouter } from "expo-router";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null;
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name="index"
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
@@ -27,14 +25,16 @@ export default function IndexLayout() {
|
|||||||
headerLargeStyle: {
|
headerLargeStyle: {
|
||||||
backgroundColor: "black",
|
backgroundColor: "black",
|
||||||
},
|
},
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className="flex flex-row items-center space-x-2">
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<>
|
<>
|
||||||
<Chromecast.Chromecast />
|
<Chromecast.Chromecast />
|
||||||
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
{user && user.Policy?.IsAdministrator && (
|
||||||
|
<SessionsButton />
|
||||||
|
)}
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -43,61 +43,67 @@ export default function IndexLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='downloads/index'
|
name="downloads/index"
|
||||||
options={{
|
options={{
|
||||||
title: t("home.downloads.downloads_title"),
|
title: t("home.downloads.downloads_title"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='downloads/[seriesId]'
|
name="downloads/[seriesId]"
|
||||||
options={{
|
options={{
|
||||||
title: t("home.downloads.tvseries"),
|
title: t("home.downloads.tvseries"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='sessions/index'
|
name="sessions/index"
|
||||||
options={{
|
options={{
|
||||||
title: t("home.sessions.title"),
|
title: t("home.sessions.title"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings'
|
name="settings"
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.settings_title"),
|
title: t("home.settings.settings_title"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/optimized-server/page'
|
name="settings/optimized-server/page"
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/marlin-search/page'
|
name="settings/marlin-search/page"
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/jellyseerr/page'
|
name="settings/dashboard/sessions"
|
||||||
|
options={{
|
||||||
|
title: t("home.settings.dashboard.sessions_title"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/jellyseerr/page"
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/hide-libraries/page'
|
name="settings/hide-libraries/page"
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/logs/page'
|
name="settings/logs/page"
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='intro/page'
|
name="intro/page"
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
title: "",
|
title: "",
|
||||||
@@ -108,12 +114,12 @@ export default function IndexLayout() {
|
|||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='collections/[collectionId]'
|
name="collections/[collectionId]"
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -130,14 +136,14 @@ const SettingsButton = () => {
|
|||||||
router.push("/(auth)/settings");
|
router.push("/(auth)/settings");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Feather name='settings' color={"white"} size={22} />
|
<Feather name="settings" color={"white"} size={22} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SessionsButton = () => {
|
const SessionsButton = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { sessions = [] } = useSessions({} as useSessionsProps);
|
const { sessions = [], _ } = useSessions({} as useSessionsProps);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -145,9 +151,9 @@ const SessionsButton = () => {
|
|||||||
router.push("/(auth)/sessions");
|
router.push("/(auth)/sessions");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='mr-4'>
|
<View className="mr-4">
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='play-circle'
|
name="play-circle"
|
||||||
color={sessions.length === 0 ? "white" : "#9333ea"}
|
color={sessions.length === 0 ? "white" : "#9333ea"}
|
||||||
size={25}
|
size={25}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
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 { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
|
||||||
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import {
|
||||||
SeasonDropdown,
|
SeasonDropdown,
|
||||||
type SeasonIndexState,
|
SeasonIndexState,
|
||||||
} from "@/components/series/SeasonDropdown";
|
} from "@/components/series/SeasonDropdown";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -21,7 +21,7 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
||||||
{},
|
{}
|
||||||
);
|
);
|
||||||
const { downloadedFiles, deleteItems } = useDownload();
|
const { downloadedFiles, deleteItems } = useDownload();
|
||||||
|
|
||||||
@@ -29,9 +29,9 @@ export default function page() {
|
|||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
downloadedFiles
|
downloadedFiles
|
||||||
?.filter((f) => f.item.SeriesId === seriesId)
|
?.filter((f) => f.item.SeriesId == seriesId)
|
||||||
?.sort(
|
?.sort(
|
||||||
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
|
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!
|
||||||
) || []
|
) || []
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -64,7 +64,7 @@ export default function page() {
|
|||||||
() =>
|
() =>
|
||||||
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
||||||
series?.[0]?.item?.ParentIndexNumber,
|
series?.[0]?.item?.ParentIndexNumber,
|
||||||
[groupBySeason],
|
[groupBySeason]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -92,14 +92,14 @@ export default function page() {
|
|||||||
onPress: () => deleteItems(groupBySeason),
|
onPress: () => deleteItems(groupBySeason),
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
},
|
},
|
||||||
],
|
]
|
||||||
);
|
);
|
||||||
}, [groupBySeason]);
|
}, [groupBySeason]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex-1'>
|
<View className="flex-1">
|
||||||
{series.length > 0 && (
|
{series.length > 0 && (
|
||||||
<View className='flex flex-row items-center justify-start my-2 px-4'>
|
<View className="flex flex-row items-center justify-start my-2 px-4">
|
||||||
<SeasonDropdown
|
<SeasonDropdown
|
||||||
item={series[0].item}
|
item={series[0].item}
|
||||||
seasons={series.map((s) => s.item)}
|
seasons={series.map((s) => s.item)}
|
||||||
@@ -112,17 +112,17 @@ export default function page() {
|
|||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
|
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2">
|
||||||
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
|
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
|
<View className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto">
|
||||||
<TouchableOpacity onPress={deleteSeries}>
|
<TouchableOpacity onPress={deleteSeries}>
|
||||||
<Ionicons name='trash' size={20} color='white' />
|
<Ionicons name="trash" size={20} color="white" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<ScrollView key={seasonIndex} className='px-4'>
|
<ScrollView key={seasonIndex} className="px-4">
|
||||||
{groupBySeason.map((episode, index) => (
|
{groupBySeason.map((episode, index) => (
|
||||||
<EpisodeCard key={index} item={episode} />
|
<EpisodeCard key={index} item={episode} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||||
|
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||||
|
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||||
|
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
|
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { t } from 'i18next';
|
||||||
|
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
type BottomSheetBackdropProps,
|
BottomSheetBackdropProps,
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { 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 { writeToLog } from "@/utils/log";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
@@ -45,7 +45,7 @@ export default function page() {
|
|||||||
const groupedBySeries = useMemo(() => {
|
const groupedBySeries = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
const episodes = downloadedFiles?.filter(
|
const episodes = downloadedFiles?.filter(
|
||||||
(f) => f.item.Type === "Episode",
|
(f) => f.item.Type === "Episode"
|
||||||
);
|
);
|
||||||
const series: { [key: string]: DownloadedItem[] } = {};
|
const series: { [key: string]: DownloadedItem[] } = {};
|
||||||
episodes?.forEach((e) => {
|
episodes?.forEach((e) => {
|
||||||
@@ -73,22 +73,14 @@ export default function page() {
|
|||||||
|
|
||||||
const deleteMovies = () =>
|
const deleteMovies = () =>
|
||||||
deleteFileByType("Movie")
|
deleteFileByType("Movie")
|
||||||
.then(() =>
|
.then(() => toast.success(t("home.downloads.toasts.deleted_all_movies_successfully")))
|
||||||
toast.success(
|
|
||||||
t("home.downloads.toasts.deleted_all_movies_successfully"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.catch((reason) => {
|
.catch((reason) => {
|
||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
||||||
});
|
});
|
||||||
const deleteShows = () =>
|
const deleteShows = () =>
|
||||||
deleteFileByType("Episode")
|
deleteFileByType("Episode")
|
||||||
.then(() =>
|
.then(() => toast.success(t("home.downloads.toasts.deleted_all_tvseries_successfully")))
|
||||||
toast.success(
|
|
||||||
t("home.downloads.toasts.deleted_all_tvseries_successfully"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.catch((reason) => {
|
.catch((reason) => {
|
||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||||
@@ -105,28 +97,26 @@ export default function page() {
|
|||||||
paddingBottom: 100,
|
paddingBottom: 100,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='py-4'>
|
<View className="py-4">
|
||||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
<View className="mb-4 flex flex-col space-y-4 px-4">
|
||||||
{settings?.downloadMethod === DownloadMethod.Remux && (
|
{settings?.downloadMethod === DownloadMethod.Remux && (
|
||||||
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
<View className="bg-neutral-900 p-4 rounded-2xl">
|
||||||
<Text className='text-lg font-bold'>
|
<Text className="text-lg font-bold">{t("home.downloads.queue")}</Text>
|
||||||
{t("home.downloads.queue")}
|
<Text className="text-xs opacity-70 text-red-600">
|
||||||
</Text>
|
|
||||||
<Text className='text-xs opacity-70 text-red-600'>
|
|
||||||
{t("home.downloads.queue_hint")}
|
{t("home.downloads.queue_hint")}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='flex flex-col space-y-2 mt-2'>
|
<View className="flex flex-col space-y-2 mt-2">
|
||||||
{queue.map((q, index) => (
|
{queue.map((q, index) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
||||||
}
|
}
|
||||||
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
|
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
||||||
key={index}
|
key={index}
|
||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
<Text className='font-semibold'>{q.item.Name}</Text>
|
<Text className="font-semibold">{q.item.Name}</Text>
|
||||||
<Text className='text-xs opacity-50'>
|
<Text className="text-xs opacity-50">
|
||||||
{q.item.Type}
|
{q.item.Type}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -139,16 +129,14 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name='close' size={24} color='red' />
|
<Ionicons name="close" size={24} color="red" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{queue.length === 0 && (
|
{queue.length === 0 && (
|
||||||
<Text className='opacity-50'>
|
<Text className="opacity-50">{t("home.downloads.no_items_in_queue")}</Text>
|
||||||
{t("home.downloads.no_items_in_queue")}
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -157,19 +145,17 @@ export default function page() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{movies.length > 0 && (
|
{movies.length > 0 && (
|
||||||
<View className='mb-4'>
|
<View className="mb-4">
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||||
<Text className='text-lg font-bold'>
|
<Text className="text-lg font-bold">{t("home.downloads.movies")}</Text>
|
||||||
{t("home.downloads.movies")}
|
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||||
</Text>
|
<Text className="text-xs font-bold">{movies?.length}</Text>
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
|
||||||
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
<View className='px-4 flex flex-row'>
|
<View className="px-4 flex flex-row">
|
||||||
{movies?.map((item) => (
|
{movies?.map((item) => (
|
||||||
<View className='mb-2 last:mb-0' key={item.item.Id}>
|
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
||||||
<MovieCard item={item.item} />
|
<MovieCard item={item.item} />
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -178,22 +164,20 @@ export default function page() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{groupedBySeries.length > 0 && (
|
{groupedBySeries.length > 0 && (
|
||||||
<View className='mb-4'>
|
<View className="mb-4">
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||||
<Text className='text-lg font-bold'>
|
<Text className="text-lg font-bold">{t("home.downloads.tvseries")}</Text>
|
||||||
{t("home.downloads.tvseries")}
|
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||||
</Text>
|
<Text className="text-xs font-bold">
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
|
||||||
<Text className='text-xs font-bold'>
|
|
||||||
{groupedBySeries?.length}
|
{groupedBySeries?.length}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
<View className='px-4 flex flex-row'>
|
<View className="px-4 flex flex-row">
|
||||||
{groupedBySeries?.map((items) => (
|
{groupedBySeries?.map((items) => (
|
||||||
<View
|
<View
|
||||||
className='mb-2 last:mb-0'
|
className="mb-2 last:mb-0"
|
||||||
key={items[0].item.SeriesId}
|
key={items[0].item.SeriesId}
|
||||||
>
|
>
|
||||||
<SeriesCard
|
<SeriesCard
|
||||||
@@ -207,10 +191,8 @@ export default function page() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{downloadedFiles?.length === 0 && (
|
{downloadedFiles?.length === 0 && (
|
||||||
<View className='flex px-4'>
|
<View className="flex px-4">
|
||||||
<Text className='opacity-50'>
|
<Text className="opacity-50">{t("home.downloads.no_downloaded_items")}</Text>
|
||||||
{t("home.downloads.no_downloaded_items")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -233,14 +215,14 @@ export default function page() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className='p-4 space-y-4 mb-4'>
|
<View className="p-4 space-y-4 mb-4">
|
||||||
<Button color='purple' onPress={deleteMovies}>
|
<Button color="purple" onPress={deleteMovies}>
|
||||||
{t("home.downloads.delete_all_movies_button")}
|
{t("home.downloads.delete_all_movies_button")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color='purple' onPress={deleteShows}>
|
<Button color="purple" onPress={deleteShows}>
|
||||||
{t("home.downloads.delete_all_tvseries_button")}
|
{t("home.downloads.delete_all_tvseries_button")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color='red' onPress={deleteAllMedia}>
|
<Button color="red" onPress={deleteAllMedia}>
|
||||||
{t("home.downloads.delete_all_button")}
|
{t("home.downloads.delete_all_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
@@ -266,6 +248,6 @@ function migration_20241124() {
|
|||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: async () => await deleteAllFiles(),
|
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, 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();
|
||||||
@@ -15,26 +15,26 @@ export default function page() {
|
|||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
storage.set("hasShownIntro", true);
|
storage.set("hasShownIntro", true);
|
||||||
}, []),
|
}, [])
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='bg-neutral-900 h-full py-16 px-4 space-y-8'>
|
<View className="bg-neutral-900 h-full py-16 px-4 space-y-8">
|
||||||
<View>
|
<View>
|
||||||
<Text className='text-3xl font-bold text-center mb-2'>
|
<Text className="text-3xl font-bold text-center mb-2">
|
||||||
{t("home.intro.welcome_to_streamyfin")}
|
{t("home.intro.welcome_to_streamyfin")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='text-center'>
|
<Text className="text-center">
|
||||||
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
|
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Text className='text-lg font-bold'>
|
<Text className="text-lg font-bold">
|
||||||
{t("home.intro.features_title")}
|
{t("home.intro.features_title")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='text-xs'>{t("home.intro.features_description")}</Text>
|
<Text className="text-xs">{t("home.intro.features_description")}</Text>
|
||||||
<View className='flex flex-row items-center mt-4'>
|
<View className="flex flex-row items-center mt-4">
|
||||||
<Image
|
<Image
|
||||||
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
||||||
style={{
|
style={{
|
||||||
@@ -42,70 +42,70 @@ export default function page() {
|
|||||||
height: 50,
|
height: 50,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View className='shrink ml-2'>
|
<View className="shrink ml-2">
|
||||||
<Text className='font-bold mb-1'>Jellyseerr</Text>
|
<Text className="font-bold mb-1">Jellyseerr</Text>
|
||||||
<Text className='shrink text-xs'>
|
<Text className="shrink text-xs">
|
||||||
{t("home.intro.jellyseerr_feature_description")}
|
{t("home.intro.jellyseerr_feature_description")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row items-center mt-4'>
|
<View className="flex flex-row items-center mt-4">
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
}}
|
}}
|
||||||
className='flex items-center justify-center'
|
className="flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<Ionicons name='cloud-download-outline' size={32} color='white' />
|
<Ionicons name="cloud-download-outline" size={32} color="white" />
|
||||||
</View>
|
</View>
|
||||||
<View className='shrink ml-2'>
|
<View className="shrink ml-2">
|
||||||
<Text className='font-bold mb-1'>
|
<Text className="font-bold mb-1">
|
||||||
{t("home.intro.downloads_feature_title")}
|
{t("home.intro.downloads_feature_title")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='shrink text-xs'>
|
<Text className="shrink text-xs">
|
||||||
{t("home.intro.downloads_feature_description")}
|
{t("home.intro.downloads_feature_description")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row items-center mt-4'>
|
<View className="flex flex-row items-center mt-4">
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
}}
|
}}
|
||||||
className='flex items-center justify-center'
|
className="flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<Feather name='cast' size={28} color={"white"} />
|
<Feather name="cast" size={28} color={"white"} />
|
||||||
</View>
|
</View>
|
||||||
<View className='shrink ml-2'>
|
<View className="shrink ml-2">
|
||||||
<Text className='font-bold mb-1'>Chromecast</Text>
|
<Text className="font-bold mb-1">Chromecast</Text>
|
||||||
<Text className='shrink text-xs'>
|
<Text className="shrink text-xs">
|
||||||
{t("home.intro.chromecast_feature_description")}
|
{t("home.intro.chromecast_feature_description")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row items-center mt-4'>
|
<View className="flex flex-row items-center mt-4">
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
}}
|
}}
|
||||||
className='flex items-center justify-center'
|
className="flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<Feather name='settings' size={28} color={"white"} />
|
<Feather name="settings" size={28} color={"white"} />
|
||||||
</View>
|
</View>
|
||||||
<View className='shrink ml-2'>
|
<View className="shrink ml-2">
|
||||||
<Text className='font-bold mb-1'>
|
<Text className="font-bold mb-1">
|
||||||
{t("home.intro.centralised_settings_plugin_title")}
|
{t("home.intro.centralised_settings_plugin_title")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='shrink text-xs'>
|
<Text className="shrink text-xs">
|
||||||
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
||||||
<Text
|
<Text
|
||||||
className='text-purple-600'
|
className="text-purple-600"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
Linking.openURL(
|
Linking.openURL(
|
||||||
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
|
"https://github.com/streamyfin/jellyfin-plugin-streamyfin"
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -120,7 +120,7 @@ export default function page() {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.back();
|
router.back();
|
||||||
}}
|
}}
|
||||||
className='mt-4'
|
className="mt-4"
|
||||||
>
|
>
|
||||||
{t("home.intro.done_button")}
|
{t("home.intro.done_button")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -129,9 +129,9 @@ export default function page() {
|
|||||||
router.back();
|
router.back();
|
||||||
router.push("/settings");
|
router.push("/settings");
|
||||||
}}
|
}}
|
||||||
className='mt-4'
|
className="mt-4"
|
||||||
>
|
>
|
||||||
<Text className='text-purple-600 text-center'>
|
<Text className="text-purple-600 text-center">
|
||||||
{t("home.intro.go_to_settings_button")}
|
{t("home.intro.go_to_settings_button")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,29 +1,25 @@
|
|||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
|
||||||
import {
|
|
||||||
HardwareAccelerationType,
|
|
||||||
type SessionInfoDto,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
|
||||||
GeneralCommandType,
|
|
||||||
PlaystateCommand,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { Badge } from "@/components/Badge";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import Poster from "@/components/posters/Poster";
|
import { SessionInfoDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useInterval } from "@/hooks/useInterval";
|
import { useAtomValue } from "jotai";
|
||||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { formatBitrate } from "@/utils/bitrate";
|
import Poster from "@/components/posters/Poster";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { useInterval } from "@/hooks/useInterval";
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { formatTimeString } from "@/utils/time";
|
import { formatTimeString } from "@/utils/time";
|
||||||
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
|
import {
|
||||||
|
Ionicons,
|
||||||
|
Entypo,
|
||||||
|
AntDesign,
|
||||||
|
MaterialCommunityIcons,
|
||||||
|
} from "@expo/vector-icons";
|
||||||
|
import { Badge } from "@/components/Badge";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
||||||
@@ -31,15 +27,15 @@ export default function page() {
|
|||||||
|
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return (
|
||||||
<View className='justify-center items-center h-full'>
|
<View className="justify-center items-center h-full">
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!sessions || sessions.length === 0)
|
if (!sessions || sessions.length == 0)
|
||||||
return (
|
return (
|
||||||
<View className='h-full w-full flex justify-center items-center'>
|
<View className="h-full w-full flex justify-center items-center">
|
||||||
<Text className='text-lg text-neutral-500'>
|
<Text className="text-lg text-neutral-500">
|
||||||
{t("home.sessions.no_active_sessions")}
|
{t("home.sessions.no_active_sessions")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -47,7 +43,7 @@ export default function page() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: 17,
|
paddingTop: 17,
|
||||||
paddingHorizontal: 17,
|
paddingHorizontal: 17,
|
||||||
@@ -81,7 +77,7 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
|||||||
|
|
||||||
return Math.round(
|
return Math.round(
|
||||||
(100 / session.NowPlayingItem?.RunTimeTicks) *
|
(100 / session.NowPlayingItem?.RunTimeTicks) *
|
||||||
(session.NowPlayingItem?.RunTimeTicks - remainingTicks),
|
(session.NowPlayingItem?.RunTimeTicks - remainingTicks)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,108 +95,26 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
|||||||
}
|
}
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
const { data: ipInfo } = useQuery({
|
|
||||||
queryKey: ["ipinfo", session.RemoteEndPoint],
|
|
||||||
cacheTime: Number.POSITIVE_INFINITY,
|
|
||||||
queryFn: async () => {
|
|
||||||
const resp = await api.axiosInstance.get(
|
|
||||||
`https://freeipapi.com/api/json/${session.RemoteEndPoint}`,
|
|
||||||
);
|
|
||||||
return resp.data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle session controls
|
|
||||||
const [isControlLoading, setIsControlLoading] = useState<
|
|
||||||
Record<string, boolean>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
const handleSystemCommand = async (command: GeneralCommandType) => {
|
|
||||||
if (!api || !session.Id) return false;
|
|
||||||
|
|
||||||
setIsControlLoading({ ...isControlLoading, [command]: true });
|
|
||||||
|
|
||||||
try {
|
|
||||||
getSessionApi(api).sendSystemCommand({
|
|
||||||
sessionId: session.Id,
|
|
||||||
command,
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error sending ${command} command:`, error);
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setIsControlLoading({ ...isControlLoading, [command]: false });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePlaystateCommand = async (command: PlaystateCommand) => {
|
|
||||||
if (!api || !session.Id) return false;
|
|
||||||
|
|
||||||
setIsControlLoading({ ...isControlLoading, [command]: true });
|
|
||||||
|
|
||||||
try {
|
|
||||||
getSessionApi(api).sendPlaystateCommand({
|
|
||||||
sessionId: session.Id,
|
|
||||||
command,
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error sending playstate ${command} command:`, error);
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setIsControlLoading({ ...isControlLoading, [command]: false });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePlayPause = async () => {
|
|
||||||
console.log("handlePlayPause");
|
|
||||||
await handlePlaystateCommand(PlaystateCommand.PlayPause);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStop = async () => {
|
|
||||||
await handlePlaystateCommand(PlaystateCommand.Stop);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrevious = async () => {
|
|
||||||
await handlePlaystateCommand(PlaystateCommand.PreviousTrack);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNext = async () => {
|
|
||||||
await handlePlaystateCommand(PlaystateCommand.NextTrack);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleMute = async () => {
|
|
||||||
await handleSystemCommand(GeneralCommandType.ToggleMute);
|
|
||||||
};
|
|
||||||
const handleVolumeUp = async () => {
|
|
||||||
await handleSystemCommand(GeneralCommandType.VolumeUp);
|
|
||||||
};
|
|
||||||
const handleVolumeDown = async () => {
|
|
||||||
await handleSystemCommand(GeneralCommandType.VolumeDown);
|
|
||||||
};
|
|
||||||
|
|
||||||
useInterval(tick, 1000);
|
useInterval(tick, 1000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col shadow-md bg-neutral-900 rounded-2xl mb-4'>
|
<View className="flex flex-col shadow-md bg-neutral-900 rounded-2xl mb-4">
|
||||||
<View className='flex flex-row p-4'>
|
<View className="flex flex-row p-4">
|
||||||
<View className='w-20 pr-4'>
|
<View className="w-20 pr-4">
|
||||||
<Poster
|
<Poster
|
||||||
id={session.NowPlayingItem?.Id}
|
id={session.NowPlayingItem?.Id}
|
||||||
url={getPrimaryImageUrl({ api, item: session.NowPlayingItem })}
|
url={getPrimaryImageUrl({ api, item: session.NowPlayingItem })}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className='w-full flex-1'>
|
<View className="w-full flex-1">
|
||||||
<View className='flex flex-row justify-between'>
|
<View className="flex flex-row justify-between">
|
||||||
<View className='flex-1 pr-4'>
|
<View className="flex-1 pr-4">
|
||||||
{session.NowPlayingItem?.Type === "Episode" ? (
|
{session.NowPlayingItem?.Type === "Episode" ? (
|
||||||
<>
|
<>
|
||||||
<Text className='font-bold'>
|
<Text className="font-bold">
|
||||||
{session.NowPlayingItem?.Name}
|
{session.NowPlayingItem?.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text numberOfLines={1} className='text-xs opacity-50'>
|
<Text numberOfLines={1} className="text-xs opacity-50">
|
||||||
{`S${session.NowPlayingItem.ParentIndexNumber?.toString()}:E${session.NowPlayingItem.IndexNumber?.toString()}`}
|
{`S${session.NowPlayingItem.ParentIndexNumber?.toString()}:E${session.NowPlayingItem.IndexNumber?.toString()}`}
|
||||||
{" - "}
|
{" - "}
|
||||||
{session.NowPlayingItem.SeriesName}
|
{session.NowPlayingItem.SeriesName}
|
||||||
@@ -208,151 +122,48 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text className='font-bold'>
|
<Text className="font-bold">
|
||||||
{session.NowPlayingItem?.Name}
|
{session.NowPlayingItem?.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='text-xs opacity-50'>
|
<Text className="text-xs opacity-50">
|
||||||
{session.NowPlayingItem?.ProductionYear}
|
{session.NowPlayingItem?.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='text-xs opacity-50'>
|
<Text className="text-xs opacity-50">
|
||||||
{session.NowPlayingItem?.SeriesName}
|
{session.NowPlayingItem?.SeriesName}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Text className='text-xs opacity-50 align-right text-right'>
|
<Text className="text-xs opacity-50 align-right text-right">
|
||||||
{session.UserName}
|
{session.UserName}
|
||||||
{"\n"}
|
{"\n"}
|
||||||
{session.Client}
|
{session.Client}
|
||||||
{"\n"}
|
{"\n"}
|
||||||
{session.DeviceName}
|
{session.DeviceName}
|
||||||
{"\n"}
|
|
||||||
{ipInfo?.cityName} {ipInfo?.countryCode}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex-1' />
|
<View className="flex-1" />
|
||||||
<View className='flex flex-col align-bottom'>
|
<View className="flex flex-col align-bottom">
|
||||||
<View className='flex flex-row justify-between align-bottom mb-1'>
|
<View className="flex flex-row justify-between align-bottom mb-1">
|
||||||
<Text className='-ml-0.5 text-xs opacity-50 align-left text-left'>
|
<Text className="-ml-0.5 text-xs opacity-50 align-left text-left">
|
||||||
{!session.PlayState?.IsPaused ? (
|
{!session.PlayState?.IsPaused ? (
|
||||||
<Ionicons name='play' size={14} color='white' />
|
<Ionicons name="play" size={14} color="white" />
|
||||||
) : (
|
) : (
|
||||||
<Ionicons name='pause' size={14} color='white' />
|
<Ionicons name="pause" size={14} color="white" />
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='text-xs opacity-50 align-right text-right'>
|
<Text className="text-xs opacity-50 align-right text-right">
|
||||||
{formatTimeString(remainingTicks, "tick")} left
|
{formatTimeString(remainingTicks, "tick")} left
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='align-bottom bg-gray-800 h-1'>
|
<View className="align-bottom bg-gray-800 h-1">
|
||||||
<View
|
<View
|
||||||
className={"bg-purple-600 h-full"}
|
className={`bg-purple-600 h-full`}
|
||||||
style={{
|
style={{
|
||||||
width: `${getProgressPercentage()}%`,
|
width: `${getProgressPercentage()}%`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Session controls */}
|
|
||||||
<View className='flex flex-row mt-2 space-x-4 justify-center'>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handlePrevious}
|
|
||||||
disabled={isControlLoading[PlaystateCommand.PreviousTrack]}
|
|
||||||
style={{
|
|
||||||
opacity: isControlLoading[PlaystateCommand.PreviousTrack]
|
|
||||||
? 0.5
|
|
||||||
: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name='skip-previous'
|
|
||||||
size={24}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handlePlayPause}
|
|
||||||
disabled={isControlLoading[PlaystateCommand.PlayPause]}
|
|
||||||
style={{
|
|
||||||
opacity: isControlLoading[PlaystateCommand.PlayPause]
|
|
||||||
? 0.5
|
|
||||||
: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{session.PlayState?.IsPaused ? (
|
|
||||||
<Ionicons name='play' size={24} color='white' />
|
|
||||||
) : (
|
|
||||||
<Ionicons name='pause' size={24} color='white' />
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleStop}
|
|
||||||
disabled={isControlLoading[PlaystateCommand.Stop]}
|
|
||||||
style={{
|
|
||||||
opacity: isControlLoading[PlaystateCommand.Stop] ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='stop' size={24} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleNext}
|
|
||||||
disabled={isControlLoading[PlaystateCommand.NextTrack]}
|
|
||||||
style={{
|
|
||||||
opacity: isControlLoading[PlaystateCommand.NextTrack]
|
|
||||||
? 0.5
|
|
||||||
: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name='skip-next'
|
|
||||||
size={24}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleVolumeDown}
|
|
||||||
disabled={isControlLoading[GeneralCommandType.VolumeDown]}
|
|
||||||
style={{
|
|
||||||
opacity: isControlLoading[GeneralCommandType.VolumeDown]
|
|
||||||
? 0.5
|
|
||||||
: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='volume-low' size={24} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleToggleMute}
|
|
||||||
disabled={isControlLoading[GeneralCommandType.ToggleMute]}
|
|
||||||
style={{
|
|
||||||
opacity: isControlLoading[GeneralCommandType.ToggleMute]
|
|
||||||
? 0.5
|
|
||||||
: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name='volume-mute'
|
|
||||||
size={24}
|
|
||||||
color={session.PlayState?.IsMuted ? "red" : "white"}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleVolumeUp}
|
|
||||||
disabled={isControlLoading[GeneralCommandType.VolumeUp]}
|
|
||||||
style={{
|
|
||||||
opacity: isControlLoading[GeneralCommandType.VolumeUp]
|
|
||||||
? 0.5
|
|
||||||
: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='volume-high' size={24} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -367,21 +178,20 @@ interface TranscodingBadgesProps {
|
|||||||
|
|
||||||
const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
|
const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
bitrate: <Ionicons name='speedometer-outline' size={12} color='white' />,
|
bitrate: <Ionicons name="speedometer-outline" size={12} color="white" />,
|
||||||
codec: <Ionicons name='layers-outline' size={12} color='white' />,
|
codec: <Ionicons name="layers-outline" size={12} color="white" />,
|
||||||
videoRange: (
|
videoRange: (
|
||||||
<Ionicons name='color-palette-outline' size={12} color='white' />
|
<Ionicons name="color-palette-outline" size={12} color="white" />
|
||||||
),
|
),
|
||||||
resolution: <Ionicons name='film-outline' size={12} color='white' />,
|
resolution: <Ionicons name="film-outline" size={12} color="white" />,
|
||||||
language: <Ionicons name='language-outline' size={12} color='white' />,
|
language: <Ionicons name="language-outline" size={12} color="white" />,
|
||||||
audioChannels: <Ionicons name='mic-outline' size={12} color='white' />,
|
audioChannels: <Ionicons name="mic-outline" size={12} color="white" />,
|
||||||
hwType: <Ionicons name='hardware-chip-outline' size={12} color='white' />,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const icon = (val: string) => {
|
const icon = (val: string) => {
|
||||||
return (
|
return (
|
||||||
iconMap[val as keyof typeof iconMap] ?? (
|
iconMap[val as keyof typeof iconMap] ?? (
|
||||||
<Ionicons name='layers-outline' size={12} color='white' />
|
<Ionicons name="layers-outline" size={12} color="white" />
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -390,8 +200,6 @@ const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
|
|||||||
switch (key) {
|
switch (key) {
|
||||||
case "bitrate":
|
case "bitrate":
|
||||||
return formatBitrate(val);
|
return formatBitrate(val);
|
||||||
case "hwType":
|
|
||||||
return val === HardwareAccelerationType.None ? "sw" : "hw";
|
|
||||||
default:
|
default:
|
||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
@@ -402,8 +210,8 @@ const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
|
|||||||
.map(([key]) => (
|
.map(([key]) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={key}
|
key={key}
|
||||||
variant='gray'
|
variant="gray"
|
||||||
className='m-0 p-0 pt-0.5 mr-1'
|
className="m-0 p-0 pt-0.5 mr-1"
|
||||||
text={formatVal(key, properties[key as keyof StreamProps])}
|
text={formatVal(key, properties[key as keyof StreamProps])}
|
||||||
iconLeft={icon(key)}
|
iconLeft={icon(key)}
|
||||||
/>
|
/>
|
||||||
@@ -411,7 +219,6 @@ const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface StreamProps {
|
interface StreamProps {
|
||||||
hwType?: HardwareAccelerationType | null | undefined;
|
|
||||||
resolution?: string | null | undefined;
|
resolution?: string | null | undefined;
|
||||||
language?: string | null | undefined;
|
language?: string | null | undefined;
|
||||||
codec?: string | null | undefined;
|
codec?: string | null | undefined;
|
||||||
@@ -423,7 +230,7 @@ interface StreamProps {
|
|||||||
interface TranscodingStreamViewProps {
|
interface TranscodingStreamViewProps {
|
||||||
title: string | undefined;
|
title: string | undefined;
|
||||||
value?: string;
|
value?: string;
|
||||||
isTranscoding: boolean;
|
isTranscoding: Boolean;
|
||||||
transcodeValue?: string | undefined | null;
|
transcodeValue?: string | undefined | null;
|
||||||
properties: StreamProps;
|
properties: StreamProps;
|
||||||
transcodeProperties?: StreamProps;
|
transcodeProperties?: StreamProps;
|
||||||
@@ -438,28 +245,30 @@ const TranscodingStreamView = ({
|
|||||||
transcodeValue,
|
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">
|
||||||
<View className='flex flex-row'>
|
<View className="flex flex-row">
|
||||||
<Text className='text-xs opacity-50 w-20 font-bold text-right pr-4'>
|
<Text className="text-xs opacity-50 w-20 font-bold text-right pr-4">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='flex-1'>
|
<Text className="flex-1">
|
||||||
<TranscodingBadges properties={properties} />
|
<TranscodingBadges properties={properties} />
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{isTranscoding && transcodeProperties ? (
|
{isTranscoding && transcodeProperties ? (
|
||||||
<View className='flex flex-row'>
|
<>
|
||||||
<Text className='-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4'>
|
<View className="flex flex-row">
|
||||||
<MaterialCommunityIcons
|
<Text className="-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4">
|
||||||
name='arrow-right-bottom'
|
<MaterialCommunityIcons
|
||||||
size={14}
|
name="arrow-right-bottom"
|
||||||
color='white'
|
size={14}
|
||||||
/>
|
color="white"
|
||||||
</Text>
|
/>
|
||||||
<Text className='flex-1 text-sm mt-1'>
|
</Text>
|
||||||
<TranscodingBadges properties={transcodeProperties} />
|
<Text className="flex-1 text-sm mt-1">
|
||||||
</Text>
|
<TranscodingBadges properties={transcodeProperties} />
|
||||||
</View>
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -468,7 +277,7 @@ const TranscodingStreamView = ({
|
|||||||
const TranscodingView = ({ session }: SessionCardProps) => {
|
const TranscodingView = ({ session }: SessionCardProps) => {
|
||||||
const videoStream = useMemo(() => {
|
const videoStream = useMemo(() => {
|
||||||
return session.NowPlayingItem?.MediaStreams?.filter(
|
return session.NowPlayingItem?.MediaStreams?.filter(
|
||||||
(s) => s.Type === "Video",
|
(s) => s.Type == "Video"
|
||||||
)[0];
|
)[0];
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
@@ -487,36 +296,35 @@ const TranscodingView = ({ session }: SessionCardProps) => {
|
|||||||
}, [session.PlayState?.SubtitleStreamIndex]);
|
}, [session.PlayState?.SubtitleStreamIndex]);
|
||||||
|
|
||||||
const isTranscoding = useMemo(() => {
|
const isTranscoding = useMemo(() => {
|
||||||
return (
|
return session.PlayState?.PlayMethod == "Transcode";
|
||||||
session.PlayState?.PlayMethod === "Transcode" && session.TranscodingInfo
|
}, [session.PlayState?.PlayMethod]);
|
||||||
);
|
|
||||||
}, [session.PlayState?.PlayMethod, session.TranscodingInfo]);
|
|
||||||
|
|
||||||
const videoStreamTitle = () => {
|
const videoStreamTitle = () => {
|
||||||
return videoStream?.DisplayTitle?.split(" ")[0];
|
return videoStream?.DisplayTitle?.split(" ")[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2'>
|
<View className="flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2">
|
||||||
<TranscodingStreamView
|
<TranscodingStreamView
|
||||||
title='Video'
|
title="Video"
|
||||||
properties={{
|
properties={{
|
||||||
resolution: videoStreamTitle(),
|
resolution: videoStreamTitle(),
|
||||||
bitrate: videoStream?.BitRate,
|
bitrate: videoStream?.BitRate,
|
||||||
codec: videoStream?.Codec,
|
codec: videoStream?.Codec,
|
||||||
}}
|
}}
|
||||||
transcodeProperties={{
|
transcodeProperties={{
|
||||||
hwType: session.TranscodingInfo?.HardwareAccelerationType,
|
|
||||||
bitrate: session.TranscodingInfo?.Bitrate,
|
bitrate: session.TranscodingInfo?.Bitrate,
|
||||||
codec: session.TranscodingInfo?.VideoCodec,
|
codec: session.TranscodingInfo?.VideoCodec,
|
||||||
}}
|
}}
|
||||||
isTranscoding={
|
isTranscoding={
|
||||||
!!(isTranscoding && !session.TranscodingInfo?.IsVideoDirect)
|
isTranscoding && !session.TranscodingInfo?.IsVideoDirect
|
||||||
|
? true
|
||||||
|
: false
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TranscodingStreamView
|
<TranscodingStreamView
|
||||||
title='Audio'
|
title="Audio"
|
||||||
properties={{
|
properties={{
|
||||||
language: audioStream?.Language,
|
language: audioStream?.Language,
|
||||||
bitrate: audioStream?.BitRate,
|
bitrate: audioStream?.BitRate,
|
||||||
@@ -524,24 +332,29 @@ const TranscodingView = ({ session }: SessionCardProps) => {
|
|||||||
audioChannels: audioStream?.ChannelLayout,
|
audioChannels: audioStream?.ChannelLayout,
|
||||||
}}
|
}}
|
||||||
transcodeProperties={{
|
transcodeProperties={{
|
||||||
|
bitrate: session.TranscodingInfo?.Bitrate,
|
||||||
codec: session.TranscodingInfo?.AudioCodec,
|
codec: session.TranscodingInfo?.AudioCodec,
|
||||||
audioChannels: session.TranscodingInfo?.AudioChannels?.toString(),
|
audioChannels: session.TranscodingInfo?.AudioChannels?.toString(),
|
||||||
}}
|
}}
|
||||||
isTranscoding={
|
isTranscoding={
|
||||||
!!(isTranscoding && !session.TranscodingInfo?.IsVideoDirect)
|
isTranscoding && !session.TranscodingInfo?.IsVideoDirect
|
||||||
|
? true
|
||||||
|
: false
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{subtitleStream && (
|
{subtitleStream && (
|
||||||
<TranscodingStreamView
|
<>
|
||||||
title='Subtitle'
|
<TranscodingStreamView
|
||||||
isTranscoding={false}
|
title="Subtitle"
|
||||||
properties={{
|
isTranscoding={false}
|
||||||
language: subtitleStream?.Language,
|
properties={{
|
||||||
codec: subtitleStream?.Codec,
|
language: subtitleStream?.Language,
|
||||||
}}
|
codec: subtitleStream?.Codec,
|
||||||
transcodeValue={null}
|
}}
|
||||||
/>
|
transcodeValue={null}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import { useNavigation, useRouter } from "expo-router";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { 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";
|
||||||
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||||
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
|
|
||||||
import DownloadSettings from "@/components/settings/DownloadSettings";
|
import DownloadSettings from "@/components/settings/DownloadSettings";
|
||||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||||
@@ -20,14 +13,22 @@ 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, userAtom } from "@/providers/JellyfinProvider";
|
import { useJellyfin } 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 React, { useEffect } from "react";
|
||||||
|
import { ScrollView, Switch, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
|
||||||
|
|
||||||
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");
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ export default function settings() {
|
|||||||
logout();
|
logout();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-red-600'>
|
<Text className="text-red-600">
|
||||||
{t("home.settings.log_out_button")}
|
{t("home.settings.log_out_button")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -60,15 +61,15 @@ export default function settings() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='p-4 flex flex-col gap-y-4'>
|
<View className="p-4 flex flex-col gap-y-4">
|
||||||
<UserInfo />
|
<UserInfo />
|
||||||
|
|
||||||
<QuickConnect className='mb-4' />
|
<QuickConnect className="mb-4" />
|
||||||
|
|
||||||
<MediaProvider>
|
<MediaProvider>
|
||||||
<MediaToggles className='mb-4' />
|
<MediaToggles className="mb-4" />
|
||||||
<AudioToggles className='mb-4' />
|
<AudioToggles className="mb-4" />
|
||||||
<SubtitleToggles className='mb-4' />
|
<SubtitleToggles className="mb-4" />
|
||||||
</MediaProvider>
|
</MediaProvider>
|
||||||
|
|
||||||
<OtherSettings />
|
<OtherSettings />
|
||||||
@@ -89,7 +90,7 @@ export default function settings() {
|
|||||||
title={t("home.settings.intro.show_intro")}
|
title={t("home.settings.intro.show_intro")}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
textColor='red'
|
textColor="red"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
storage.set("hasShownIntro", false);
|
storage.set("hasShownIntro", false);
|
||||||
}}
|
}}
|
||||||
@@ -97,7 +98,7 @@ export default function settings() {
|
|||||||
/>
|
/>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
<View className='mb-4'>
|
<View className="mb-4">
|
||||||
<ListGroup title={t("home.settings.logs.logs_title")}>
|
<ListGroup title={t("home.settings.logs.logs_title")}>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/logs/page")}
|
onPress={() => router.push("/settings/logs/page")}
|
||||||
@@ -105,7 +106,7 @@ export default function settings() {
|
|||||||
title={t("home.settings.logs.logs_title")}
|
title={t("home.settings.logs.logs_title")}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
textColor='red'
|
textColor="red"
|
||||||
onPress={onClearLogsClicked}
|
onPress={onClearLogsClicked}
|
||||||
title={t("home.settings.logs.delete_all_logs")}
|
title={t("home.settings.logs.delete_all_logs")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
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 { Switch, View } from "react-native";
|
import { Switch, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
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();
|
||||||
@@ -18,7 +18,7 @@ export default function page() {
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading: isLoading } = useQuery({
|
||||||
queryKey: ["user-views", user?.Id],
|
queryKey: ["user-views", user?.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await getUserViewsApi(api!).getUserViews({
|
const response = await getUserViewsApi(api!).getUserViews({
|
||||||
@@ -33,7 +33,7 @@ export default function page() {
|
|||||||
|
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return (
|
||||||
<View className='mt-4'>
|
<View className="mt-4">
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -41,7 +41,7 @@ export default function page() {
|
|||||||
return (
|
return (
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
||||||
className='px-4'
|
className="px-4"
|
||||||
>
|
>
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
{data?.map((view) => (
|
{data?.map((view) => (
|
||||||
@@ -59,8 +59,8 @@ export default function page() {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [_settings, _updateSettings, pluginSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||||
className='p-4'
|
className="p-4"
|
||||||
>
|
>
|
||||||
<JellyseerrSettings />
|
<JellyseerrSettings />
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
|
|||||||
@@ -1,157 +1,35 @@
|
|||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import * as Sharing from "expo-sharing";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import Collapsible from "react-native-collapsible";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { useLog } from "@/utils/log";
|
||||||
import { Loader } from "@/components/Loader";
|
import { ScrollView, View } from "react-native";
|
||||||
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
|
||||||
const { logs } = useLog();
|
const { logs } = useLog();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const defaultLevels: LogLevel[] = ["INFO", "ERROR", "DEBUG", "WARN"];
|
|
||||||
const codeBlockStyle = {
|
|
||||||
backgroundColor: "#000",
|
|
||||||
padding: 10,
|
|
||||||
fontFamily: "monospace",
|
|
||||||
maxHeight: 300,
|
|
||||||
};
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
const [state, setState] = useState<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
const [order, setOrder] = useState<"asc" | "desc">("desc");
|
|
||||||
const [levels, setLevels] = useState<LogLevel[]>(defaultLevels);
|
|
||||||
|
|
||||||
const filteredLogs = useMemo(
|
|
||||||
() =>
|
|
||||||
logs
|
|
||||||
?.filter((log) => levels.includes(log.level))
|
|
||||||
?.[
|
|
||||||
// Already in asc order as they are recorded. just reverse for desc
|
|
||||||
order === "desc" ? "reverse" : "concat"
|
|
||||||
]?.(),
|
|
||||||
[logs, order, levels],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sharing it as txt while its formatted allows us to share it with many more applications
|
|
||||||
const share = useCallback(async () => {
|
|
||||||
const uri = `${FileSystem.documentDirectory}logs.txt`;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
FileSystem.writeAsStringAsync(uri, JSON.stringify(filteredLogs))
|
|
||||||
.then(() => {
|
|
||||||
setLoading(false);
|
|
||||||
Sharing.shareAsync(uri, { mimeType: "txt", UTI: "txt" });
|
|
||||||
})
|
|
||||||
.catch((e) =>
|
|
||||||
writeErrorLog("Something went wrong attempting to export", e),
|
|
||||||
)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [filteredLogs]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () =>
|
|
||||||
loading ? (
|
|
||||||
<Loader />
|
|
||||||
) : (
|
|
||||||
<TouchableOpacity onPress={share}>
|
|
||||||
<Text>{t("home.settings.logs.export_logs")}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [share, loading]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ScrollView className="p-4">
|
||||||
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
<View className="flex flex-col space-y-2">
|
||||||
<FilterButton
|
{logs?.map((log, index) => (
|
||||||
id='order'
|
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
||||||
queryKey='log'
|
<Text
|
||||||
queryFn={async () => ["asc", "desc"]}
|
className={`
|
||||||
set={(values) => setOrder(values[0])}
|
mb-1
|
||||||
values={[order]}
|
${log.level === "INFO" && "text-blue-500"}
|
||||||
title={t("library.filters.sort_order")}
|
${log.level === "ERROR" && "text-red-500"}
|
||||||
renderItemLabel={(order) => t(`library.filters.${order}`)}
|
`}
|
||||||
showSearch={false}
|
>
|
||||||
/>
|
{log.level}
|
||||||
<FilterButton
|
|
||||||
id='levels'
|
|
||||||
queryKey='log'
|
|
||||||
queryFn={async () => defaultLevels}
|
|
||||||
set={setLevels}
|
|
||||||
values={levels}
|
|
||||||
title={t("home.settings.logs.level")}
|
|
||||||
renderItemLabel={(level) => level}
|
|
||||||
showSearch={false}
|
|
||||||
multiple={true}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<ScrollView className='pb-4 px-4'>
|
|
||||||
<View className='flex flex-col space-y-2'>
|
|
||||||
{filteredLogs?.map((log, index) => (
|
|
||||||
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
|
||||||
<TouchableOpacity
|
|
||||||
disabled={!log.data}
|
|
||||||
onPress={() =>
|
|
||||||
setState((v) => ({
|
|
||||||
...v,
|
|
||||||
[log.timestamp]: !v[log.timestamp],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<View className='flex flex-row justify-between'>
|
|
||||||
<Text
|
|
||||||
className={`mb-1
|
|
||||||
${log.level === "INFO" && "text-blue-500"}
|
|
||||||
${log.level === "ERROR" && "text-red-500"}
|
|
||||||
${log.level === "DEBUG" && "text-purple-500"}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{log.level}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text className='text-xs'>
|
|
||||||
{new Date(log.timestamp).toLocaleString()}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text uiTextView selectable className='text-xs'>
|
|
||||||
{log.message}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{log.data && (
|
|
||||||
<>
|
|
||||||
{!state[log.timestamp] && (
|
|
||||||
<Text className='text-xs mt-0.5'>
|
|
||||||
{t("home.settings.logs.click_for_more_info")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Collapsible collapsed={!state[log.timestamp]}>
|
|
||||||
<View className='mt-2 flex flex-col space-y-2'>
|
|
||||||
<ScrollView className='rounded-xl' style={codeBlockStyle}>
|
|
||||||
<Text>{JSON.stringify(log.data, null, 2)}</Text>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
</Collapsible>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
{filteredLogs?.length === 0 && (
|
|
||||||
<Text className='opacity-50'>
|
|
||||||
{t("home.settings.logs.no_logs_available")}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
<Text uiTextView selectable className="text-xs">
|
||||||
</View>
|
{log.message}
|
||||||
</ScrollView>
|
</Text>
|
||||||
</>
|
</View>
|
||||||
|
))}
|
||||||
|
{logs?.length === 0 && (
|
||||||
|
<Text className="opacity-50">{t("home.settings.logs.no_logs_available")}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
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 React, {useEffect, useMemo, useState} from "react";
|
||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -10,11 +15,7 @@ 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 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();
|
||||||
@@ -38,10 +39,7 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const disabled = useMemo(() => {
|
const disabled = useMemo(() => {
|
||||||
return (
|
return pluginSettings?.searchEngine?.locked === true && pluginSettings?.marlinServerUrl?.locked === true
|
||||||
pluginSettings?.searchEngine?.locked === true &&
|
|
||||||
pluginSettings?.marlinServerUrl?.locked === true
|
|
||||||
);
|
|
||||||
}, [pluginSettings]);
|
}, [pluginSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -49,9 +47,7 @@ export default function page() {
|
|||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity onPress={() => onSave(value)}>
|
<TouchableOpacity onPress={() => onSave(value)}>
|
||||||
<Text className='text-blue-500'>
|
<Text className="text-blue-500">{t("home.settings.plugins.marlin_search.save_button")}</Text>
|
||||||
{t("home.settings.plugins.marlin_search.save_button")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -61,16 +57,17 @@ export default function page() {
|
|||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DisabledSetting disabled={disabled} className='px-4'>
|
<DisabledSetting
|
||||||
|
disabled={disabled}
|
||||||
|
className="px-4"
|
||||||
|
>
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.searchEngine?.locked === true}
|
disabled={pluginSettings?.searchEngine?.locked === true}
|
||||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t("home.settings.plugins.marlin_search.enable_marlin_search")}
|
||||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
|
||||||
)}
|
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
updateSettings({ searchEngine: "Jellyfin" });
|
updateSettings({ searchEngine: "Jellyfin" });
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
@@ -90,30 +87,28 @@ export default function page() {
|
|||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||||
showText={!pluginSettings?.searchEngine?.locked}
|
showText={!pluginSettings?.searchEngine?.locked}
|
||||||
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
className="mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4"
|
||||||
>
|
>
|
||||||
<View className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}>
|
<View
|
||||||
<Text className='mr-4'>
|
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
|
||||||
{t("home.settings.plugins.marlin_search.url")}
|
>
|
||||||
</Text>
|
<Text className="mr-4">{t("home.settings.plugins.marlin_search.url")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
editable={settings.searchEngine === "Marlin"}
|
editable={settings.searchEngine === "Marlin"}
|
||||||
className='text-white'
|
className="text-white"
|
||||||
placeholder={t(
|
placeholder={t("home.settings.plugins.marlin_search.server_url_placeholder")}
|
||||||
"home.settings.plugins.marlin_search.server_url_placeholder",
|
|
||||||
)}
|
|
||||||
value={value}
|
value={value}
|
||||||
keyboardType='url'
|
keyboardType="url"
|
||||||
returnKeyType='done'
|
returnKeyType="done"
|
||||||
autoCapitalize='none'
|
autoCapitalize="none"
|
||||||
textContentType='URL'
|
textContentType="URL"
|
||||||
onChangeText={(text) => setValue(text)}
|
onChangeText={(text) => setValue(text)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
<Text className="text-blue-500" onPress={handleOpenLink}>
|
||||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
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 } from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getOrSetDeviceId } from "@/utils/device";
|
import { getOrSetDeviceId } from "@/utils/device";
|
||||||
import { getStatistics } from "@/utils/optimize-server";
|
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 { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -31,14 +31,14 @@ export default function page() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedUrl = newVal.endsWith("/") ? newVal : `${newVal}/`;
|
const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/";
|
||||||
|
|
||||||
updateSettings({
|
updateSettings({
|
||||||
optimizedVersionsServerUrl: updatedUrl,
|
optimizedVersionsServerUrl: updatedUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
return await getStatistics({
|
return await getStatistics({
|
||||||
url: updatedUrl,
|
url: settings?.optimizedVersionsServerUrl,
|
||||||
authHeader: api?.accessToken,
|
authHeader: api?.accessToken,
|
||||||
deviceId: getOrSetDeviceId(),
|
deviceId: getOrSetDeviceId(),
|
||||||
});
|
});
|
||||||
@@ -67,12 +67,8 @@ export default function page() {
|
|||||||
saveMutation.isPending ? (
|
saveMutation.isPending ? (
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
<ActivityIndicator size={"small"} color={"white"} />
|
||||||
) : (
|
) : (
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
|
||||||
onPress={() => onSave(optimizedVersionsServerUrl)}
|
<Text className="text-blue-500">{t("home.settings.downloads.save_button")}</Text>
|
||||||
>
|
|
||||||
<Text className='text-blue-500'>
|
|
||||||
{t("home.settings.downloads.save_button")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -82,7 +78,7 @@ export default function page() {
|
|||||||
return (
|
return (
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
|
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
|
||||||
className='p-4'
|
className="p-4"
|
||||||
>
|
>
|
||||||
<OptimizedServerForm
|
<OptimizedServerForm
|
||||||
value={optimizedVersionsServerUrl}
|
value={optimizedVersionsServerUrl}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import type { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
|
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 { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
|
import { useTranslation } from "react-i18next";
|
||||||
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();
|
||||||
@@ -68,7 +68,7 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
[api, user?.Id, actorId],
|
[api, user?.Id, actorId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const backdropUrl = useMemo(
|
const backdropUrl = useMemo(
|
||||||
@@ -79,12 +79,12 @@ const page: React.FC = () => {
|
|||||||
quality: 90,
|
quality: 90,
|
||||||
width: 1000,
|
width: 1000,
|
||||||
}),
|
}),
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (l1)
|
if (l1)
|
||||||
return (
|
return (
|
||||||
<View className='justify-center items-center h-full'>
|
<View className="justify-center items-center h-full">
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -105,13 +105,13 @@ const page: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col space-y-4 my-4'>
|
<View className="flex flex-col space-y-4 my-4">
|
||||||
<View className='px-4 mb-4'>
|
<View className="px-4 mb-4">
|
||||||
<MoviesTitleHeader item={item} className='mb-4' />
|
<MoviesTitleHeader item={item} className="mb-4" />
|
||||||
<OverviewText text={item.Overview} />
|
<OverviewText text={item.Overview} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text className='px-4 text-2xl font-bold mb-2 text-neutral-100'>
|
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
||||||
{t("item_card.appeared_in")}
|
{t("item_card.appeared_in")}
|
||||||
</Text>
|
</Text>
|
||||||
<InfiniteHorizontalScroll
|
<InfiniteHorizontalScroll
|
||||||
@@ -133,7 +133,7 @@ const page: React.FC = () => {
|
|||||||
queryFn={fetchItems}
|
queryFn={fetchItems}
|
||||||
queryKey={["actor", "movies", actorId]}
|
queryKey={["actor", "movies", actorId]}
|
||||||
/>
|
/>
|
||||||
<View className='h-12' />
|
<View className="h-12"></View>
|
||||||
</View>
|
</View>
|
||||||
</ParallaxScrollView>
|
</ParallaxScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,22 @@
|
|||||||
import type {
|
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 { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
genreFilterAtom,
|
||||||
|
sortByAtom,
|
||||||
|
SortByOption,
|
||||||
|
sortOptions,
|
||||||
|
sortOrderAtom,
|
||||||
|
SortOrderOption,
|
||||||
|
sortOrderOptions,
|
||||||
|
tagsFilterAtom,
|
||||||
|
yearFilterAtom,
|
||||||
|
} from "@/utils/atoms/filters";
|
||||||
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
ItemSortBy,
|
ItemSortBy,
|
||||||
@@ -11,30 +29,11 @@ import {
|
|||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import type React from "react";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
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 * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
genreFilterAtom,
|
import { FlatList, View } from "react-native";
|
||||||
SortByOption,
|
import { useTranslation } from "react-i18next";
|
||||||
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,8 +42,8 @@ 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
|
||||||
);
|
);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -112,7 +111,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) => parseInt(year)),
|
||||||
includeItemTypes: ["Movie", "Series"],
|
includeItemTypes: ["Movie", "Series"],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -127,7 +126,7 @@ const page: React.FC = () => {
|
|||||||
selectedTags,
|
selectedTags,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
],
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
@@ -152,13 +151,14 @@ const page: React.FC = () => {
|
|||||||
const totalItems = lastPage.TotalRecordCount;
|
const totalItems = lastPage.TotalRecordCount;
|
||||||
const accumulatedItems = pages.reduce(
|
const accumulatedItems = pages.reduce(
|
||||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||||
0,
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
if (accumulatedItems < totalItems) {
|
if (accumulatedItems < totalItems) {
|
||||||
return lastPage?.Items?.length * pages.length;
|
return lastPage?.Items?.length * pages.length;
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
return undefined;
|
|
||||||
},
|
},
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
enabled: !!api && !!user?.Id && !!collection,
|
enabled: !!api && !!user?.Id && !!collection,
|
||||||
@@ -188,8 +188,8 @@ const page: React.FC = () => {
|
|||||||
index % 3 === 0
|
index % 3 === 0
|
||||||
? "flex-end"
|
? "flex-end"
|
||||||
: (index + 1) % 3 === 0
|
: (index + 1) % 3 === 0
|
||||||
? "flex-start"
|
? "flex-start"
|
||||||
: "center",
|
: "center",
|
||||||
width: "89%",
|
width: "89%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -199,14 +199,14 @@ const page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
),
|
),
|
||||||
[orientation],
|
[orientation]
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|
||||||
const ListHeaderComponent = useCallback(
|
const ListHeaderComponent = useCallback(
|
||||||
() => (
|
() => (
|
||||||
<View className=''>
|
<View className="">
|
||||||
<FlatList
|
<FlatList
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
@@ -232,13 +232,13 @@ const page: React.FC = () => {
|
|||||||
key: "genre",
|
key: "genre",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className='mr-1'
|
className="mr-1"
|
||||||
id={collectionId}
|
collectionId={collectionId}
|
||||||
queryKey='genreFilter'
|
queryKey="genreFilter"
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getFilterApi(
|
const response = await getFilterApi(
|
||||||
api,
|
api
|
||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: collectionId,
|
parentId: collectionId,
|
||||||
@@ -259,13 +259,13 @@ const page: React.FC = () => {
|
|||||||
key: "year",
|
key: "year",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className='mr-1'
|
className="mr-1"
|
||||||
id={collectionId}
|
collectionId={collectionId}
|
||||||
queryKey='yearFilter'
|
queryKey="yearFilter"
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getFilterApi(
|
const response = await getFilterApi(
|
||||||
api,
|
api
|
||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: collectionId,
|
parentId: collectionId,
|
||||||
@@ -284,13 +284,13 @@ const page: React.FC = () => {
|
|||||||
key: "tags",
|
key: "tags",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className='mr-1'
|
className="mr-1"
|
||||||
id={collectionId}
|
collectionId={collectionId}
|
||||||
queryKey='tagsFilter'
|
queryKey="tagsFilter"
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getFilterApi(
|
const response = await getFilterApi(
|
||||||
api,
|
api
|
||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: collectionId,
|
parentId: collectionId,
|
||||||
@@ -311,9 +311,9 @@ const page: React.FC = () => {
|
|||||||
key: "sortBy",
|
key: "sortBy",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className='mr-1'
|
className="mr-1"
|
||||||
id={collectionId}
|
collectionId={collectionId}
|
||||||
queryKey='sortBy'
|
queryKey="sortBy"
|
||||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
set={setSortBy}
|
set={setSortBy}
|
||||||
values={sortBy}
|
values={sortBy}
|
||||||
@@ -331,9 +331,9 @@ const page: React.FC = () => {
|
|||||||
key: "sortOrder",
|
key: "sortOrder",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className='mr-1'
|
className="mr-1"
|
||||||
id={collectionId}
|
collectionId={collectionId}
|
||||||
queryKey='sortOrder'
|
queryKey="sortOrder"
|
||||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
set={setSortOrder}
|
set={setSortOrder}
|
||||||
values={sortOrder}
|
values={sortOrder}
|
||||||
@@ -368,7 +368,7 @@ const page: React.FC = () => {
|
|||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
isFetching,
|
isFetching,
|
||||||
],
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!collection) return null;
|
if (!collection) return null;
|
||||||
@@ -376,10 +376,8 @@ const page: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className='flex flex-col items-center justify-center h-full'>
|
<View className="flex flex-col items-center justify-center h-full">
|
||||||
<Text className='font-bold text-xl text-neutral-500'>
|
<Text className="font-bold text-xl text-neutral-500">{t("search.no_results")}</Text>
|
||||||
{t("search.no_results")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
extraData={[
|
extraData={[
|
||||||
@@ -389,7 +387,7 @@ const page: React.FC = () => {
|
|||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
]}
|
]}
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
data={flatData}
|
data={flatData}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
@@ -411,7 +409,7 @@ const page: React.FC = () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
}}
|
}}
|
||||||
/>
|
></View>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ItemContent } from "@/components/ItemContent";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import type React from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
runOnJS,
|
runOnJS,
|
||||||
@@ -12,9 +13,7 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Text } from "@/components/common/Text";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ItemContent } from "@/components/ItemContent";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -76,36 +75,36 @@ const Page: React.FC = () => {
|
|||||||
|
|
||||||
if (isError)
|
if (isError)
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col items-center justify-center h-screen w-screen'>
|
<View className="flex flex-col items-center justify-center h-screen w-screen">
|
||||||
<Text>{t("item_card.could_not_load_item")}</Text>
|
<Text>{t("item_card.could_not_load_item")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-1 relative'>
|
<View className="flex flex-1 relative">
|
||||||
<Animated.View
|
<Animated.View
|
||||||
pointerEvents={"none"}
|
pointerEvents={"none"}
|
||||||
style={[animatedStyle]}
|
style={[animatedStyle]}
|
||||||
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
|
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
height: item?.Type === "Episode" ? 300 : 450,
|
height: item?.Type === "Episode" ? 300 : 450,
|
||||||
}}
|
}}
|
||||||
className='bg-transparent rounded-lg mb-4 w-full'
|
className="bg-transparent rounded-lg mb-4 w-full"
|
||||||
/>
|
></View>
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
|
<View className="h-6 bg-neutral-900 rounded mb-4 w-14"></View>
|
||||||
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
|
<View className="h-10 bg-neutral-900 rounded-lg mb-2 w-1/2"></View>
|
||||||
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
|
<View className="h-3 bg-neutral-900 rounded mb-3 w-8"></View>
|
||||||
<View className='flex flex-row space-x-1 mb-8'>
|
<View className="flex flex-row space-x-1 mb-8">
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||||
</View>
|
</View>
|
||||||
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
|
<View className="h-3 bg-neutral-900 rounded w-2/3 mb-1"></View>
|
||||||
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
|
<View className="h-10 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
||||||
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
<View className="h-12 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
||||||
<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"></View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
{item && <ItemContent item={item} />}
|
{item && <ItemContent item={item} />}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,44 +1,45 @@
|
|||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import {router, useLocalSearchParams, useSegments,} from "expo-router";
|
||||||
import { Image } from "expo-image";
|
import React, {useMemo,} from "react";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import {TouchableOpacity} from "react-native";
|
||||||
import { uniqBy } from "lodash";
|
import {useInfiniteQuery} from "@tanstack/react-query";
|
||||||
import { useMemo } from "react";
|
import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
|
||||||
|
import {Text} from "@/components/common/Text";
|
||||||
|
import {Image} from "expo-image";
|
||||||
|
import Poster from "@/components/posters/Poster";
|
||||||
|
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
||||||
|
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
|
||||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||||
|
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import {COMPANY_LOGO_IMAGE_FILTER} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||||
|
import {uniqBy} from "lodash";
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
|
||||||
import {
|
|
||||||
type MovieResult,
|
|
||||||
type TvResult,
|
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
|
||||||
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const {jellyseerrApi} = useJellyseerr();
|
||||||
|
|
||||||
const { companyId, name, image, type } = local as unknown as {
|
const {companyId, name, image, type} = local as unknown as {
|
||||||
companyId: string;
|
companyId: string,
|
||||||
name: string;
|
name: string,
|
||||||
image: string;
|
image: string,
|
||||||
type: DiscoverSliderType;
|
type: DiscoverSliderType
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
|
||||||
queryKey: ["jellyseerr", "company", type, companyId],
|
queryKey: ["jellyseerr", "company", type, companyId],
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({pageParam}) => {
|
||||||
const params: any = {
|
let params: any = {
|
||||||
page: Number(pageParam),
|
page: Number(pageParam),
|
||||||
};
|
};
|
||||||
|
|
||||||
return jellyseerrApi?.discover(
|
return jellyseerrApi?.discover(
|
||||||
`${
|
(
|
||||||
type === DiscoverSliderType.NETWORKS
|
type == DiscoverSliderType.NETWORKS
|
||||||
? Endpoints.DISCOVER_TV_NETWORK
|
? Endpoints.DISCOVER_TV_NETWORK
|
||||||
: Endpoints.DISCOVER_MOVIES_STUDIO
|
: Endpoints.DISCOVER_MOVIES_STUDIO
|
||||||
}/${companyId}`,
|
) + `/${companyId}`,
|
||||||
params,
|
params
|
||||||
);
|
)
|
||||||
},
|
},
|
||||||
enabled: !!jellyseerrApi && !!companyId,
|
enabled: !!jellyseerrApi && !!companyId,
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
@@ -49,58 +50,46 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const flatData = useMemo(
|
const flatData = useMemo(
|
||||||
() =>
|
() => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
|
||||||
uniqBy(
|
[data]
|
||||||
data?.pages
|
|
||||||
?.filter((p) => p?.results.length)
|
|
||||||
.flatMap((p) => p?.results ?? []),
|
|
||||||
"id",
|
|
||||||
) ?? [],
|
|
||||||
[data],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const backdrops = useMemo(
|
const backdrops = useMemo(
|
||||||
() =>
|
() => jellyseerrApi
|
||||||
jellyseerrApi
|
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
|
||||||
? flatData.map((r) =>
|
: [],
|
||||||
jellyseerrApi.imageProxy(
|
[jellyseerrApi, flatData]
|
||||||
(r as TvResult | MovieResult).backdropPath,
|
|
||||||
"w1920_and_h800_multi_faces",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: [],
|
|
||||||
[jellyseerrApi, flatData],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ParallaxSlideShow
|
<ParallaxSlideShow
|
||||||
data={flatData}
|
data={flatData}
|
||||||
images={backdrops}
|
images={backdrops}
|
||||||
listHeader=''
|
listHeader=""
|
||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage) {
|
if (hasNextPage) {
|
||||||
fetchNextPage();
|
fetchNextPage()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
logo={
|
logo={
|
||||||
<Image
|
<Image
|
||||||
id={companyId}
|
id={companyId}
|
||||||
key={companyId}
|
key={companyId}
|
||||||
className='bottom-1 w-1/2'
|
className="bottom-1 w-1/2"
|
||||||
source={{
|
source={{
|
||||||
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
|
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
|
||||||
}}
|
}}
|
||||||
cachePolicy={"memory-disk"}
|
cachePolicy={"memory-disk"}
|
||||||
contentFit='contain'
|
contentFit="contain"
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: "4/3",
|
aspectRatio: "4/3",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
renderItem={(item, _index) => (
|
renderItem={(item, index) =>
|
||||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
)}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,42 @@
|
|||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import {router, useLocalSearchParams, useSegments,} from "expo-router";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import React, {useMemo,} from "react";
|
||||||
import { uniqBy } from "lodash";
|
import {TouchableOpacity} from "react-native";
|
||||||
import { useMemo } from "react";
|
import {useInfiniteQuery} from "@tanstack/react-query";
|
||||||
import { Text } from "@/components/common/Text";
|
import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
|
||||||
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
import {Text} from "@/components/common/Text";
|
||||||
|
import Poster from "@/components/posters/Poster";
|
||||||
|
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
||||||
|
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
|
||||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||||
|
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import {uniqBy} from "lodash";
|
||||||
|
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
|
||||||
import {
|
|
||||||
type MovieResult,
|
|
||||||
type TvResult,
|
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const {jellyseerrApi} = useJellyseerr();
|
||||||
|
|
||||||
const { genreId, name, type } = local as unknown as {
|
const {genreId, name, type} = local as unknown as {
|
||||||
genreId: string;
|
genreId: string,
|
||||||
name: string;
|
name: string,
|
||||||
type: DiscoverSliderType;
|
type: DiscoverSliderType
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
|
||||||
queryKey: ["jellyseerr", "company", type, genreId],
|
queryKey: ["jellyseerr", "company", type, genreId],
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({pageParam}) => {
|
||||||
const params: any = {
|
let params: any = {
|
||||||
page: Number(pageParam),
|
page: Number(pageParam),
|
||||||
genre: genreId,
|
genre: genreId
|
||||||
};
|
};
|
||||||
|
|
||||||
return jellyseerrApi?.discover(
|
return jellyseerrApi?.discover(
|
||||||
type === DiscoverSliderType.MOVIE_GENRES
|
type == DiscoverSliderType.MOVIE_GENRES
|
||||||
? Endpoints.DISCOVER_MOVIES
|
? Endpoints.DISCOVER_MOVIES
|
||||||
: Endpoints.DISCOVER_TV,
|
: Endpoints.DISCOVER_TV,
|
||||||
params,
|
params
|
||||||
);
|
)
|
||||||
},
|
},
|
||||||
enabled: !!jellyseerrApi && !!genreId,
|
enabled: !!jellyseerrApi && !!genreId,
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
@@ -47,54 +47,41 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const flatData = useMemo(
|
const flatData = useMemo(
|
||||||
() =>
|
() => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
|
||||||
uniqBy(
|
[data]
|
||||||
data?.pages
|
|
||||||
?.filter((p) => p?.results.length)
|
|
||||||
.flatMap((p) => p?.results ?? []),
|
|
||||||
"id",
|
|
||||||
) ?? [],
|
|
||||||
[data],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const backdrops = useMemo(
|
const backdrops = useMemo(
|
||||||
() =>
|
() => jellyseerrApi
|
||||||
jellyseerrApi
|
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
|
||||||
? flatData.map((r) =>
|
: [],
|
||||||
jellyseerrApi.imageProxy(
|
[jellyseerrApi, flatData]
|
||||||
(r as TvResult | MovieResult).backdropPath,
|
|
||||||
"w1920_and_h800_multi_faces",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: [],
|
|
||||||
[jellyseerrApi, flatData],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ParallaxSlideShow
|
<ParallaxSlideShow
|
||||||
data={flatData}
|
data={flatData}
|
||||||
images={backdrops}
|
images={backdrops}
|
||||||
listHeader=''
|
listHeader=""
|
||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage) {
|
if (hasNextPage) {
|
||||||
fetchNextPage();
|
fetchNextPage()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
logo={
|
logo={
|
||||||
<Text
|
<Text
|
||||||
className='text-4xl font-bold text-center bottom-1'
|
className="text-4xl font-bold text-center bottom-1"
|
||||||
style={{
|
style={{
|
||||||
...textShadowStyle.shadow,
|
...textShadowStyle.shadow,
|
||||||
shadowRadius: 10,
|
shadowRadius: 10
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
{name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
renderItem={(item, _index) => (
|
renderItem={(item, index) =>
|
||||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
)}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,3 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetTextInput,
|
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
|
||||||
import type React from "react";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { GenreTags } from "@/components/GenreTags";
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
@@ -27,44 +11,56 @@ import { ItemActions } from "@/components/series/SeriesActions";
|
|||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||||
import {
|
import {
|
||||||
type IssueType,
|
IssueType,
|
||||||
IssueTypeName,
|
IssueTypeName,
|
||||||
} from "@/utils/jellyseerr/server/constants/issue";
|
} from "@/utils/jellyseerr/server/constants/issue";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
import type {
|
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
MovieResult,
|
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
TvResult,
|
import { useTranslation } from "react-i18next";
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetTextInput,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
|
const { mediaTitle, releaseYear, posterSrc, ...result } =
|
||||||
params as unknown as {
|
params as unknown as {
|
||||||
mediaTitle: string;
|
mediaTitle: string;
|
||||||
releaseYear: number;
|
releaseYear: number;
|
||||||
canRequest: string;
|
canRequest: string;
|
||||||
posterSrc: string;
|
posterSrc: string;
|
||||||
mediaType: MediaType;
|
} & Partial<MovieResult | TvResult>;
|
||||||
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
const [issueType, setIssueType] = useState<IssueType>();
|
const [issueType, setIssueType] = useState<IssueType>();
|
||||||
const [issueMessage, setIssueMessage] = useState<string>();
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
|
||||||
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
@@ -75,7 +71,7 @@ const Page: React.FC = () => {
|
|||||||
refetch,
|
refetch,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||||
queryKey: ["jellyseerr", "detail", mediaType, result.id],
|
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
@@ -83,9 +79,9 @@ const Page: React.FC = () => {
|
|||||||
retryOnMount: true,
|
retryOnMount: true,
|
||||||
refetchInterval: 0,
|
refetchInterval: 0,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return mediaType === MediaType.MOVIE
|
return result.mediaType === MediaType.MOVIE
|
||||||
? jellyseerrApi?.movieDetails(result.id!)
|
? jellyseerrApi?.movieDetails(result.id!!)
|
||||||
: jellyseerrApi?.tvDetails(result.id!);
|
: jellyseerrApi?.tvDetails(result.id!!);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,7 +96,7 @@ const Page: React.FC = () => {
|
|||||||
appearsOnIndex={0}
|
appearsOnIndex={0}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
[],
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const submitIssue = useCallback(() => {
|
const submitIssue = useCallback(() => {
|
||||||
@@ -115,18 +111,10 @@ const Page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||||
|
|
||||||
const setRequestBody = useCallback(
|
|
||||||
(body: MediaRequestBody) => {
|
|
||||||
_setRequestBody(body);
|
|
||||||
advancedReqModalRef?.current?.present?.();
|
|
||||||
},
|
|
||||||
[requestBody, _setRequestBody, advancedReqModalRef],
|
|
||||||
);
|
|
||||||
|
|
||||||
const request = useCallback(async () => {
|
const request = useCallback(async () => {
|
||||||
const body: MediaRequestBody = {
|
const body: MediaRequestBody = {
|
||||||
mediaId: Number(result.id!),
|
mediaId: Number(result.id!!),
|
||||||
mediaType: mediaType!,
|
mediaType: result.mediaType!!,
|
||||||
tvdbId: details?.externalIds?.tvdbId,
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
seasons: (details as TvDetails)?.seasons
|
seasons: (details as TvDetails)?.seasons
|
||||||
?.filter?.((s) => s.seasonNumber !== 0)
|
?.filter?.((s) => s.seasonNumber !== 0)
|
||||||
@@ -134,7 +122,7 @@ const Page: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (hasAdvancedRequestPermission) {
|
if (hasAdvancedRequestPermission) {
|
||||||
setRequestBody(body);
|
advancedReqModalRef?.current?.present?.(body);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,15 +132,15 @@ const Page: React.FC = () => {
|
|||||||
const isAnime = useMemo(
|
const isAnime = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
|
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
|
||||||
mediaType === MediaType.TV,
|
result.mediaType === MediaType.TV,
|
||||||
[details],
|
[details]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (details) {
|
if (details) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
|
<TouchableOpacity className="rounded-full p-2 bg-neutral-800/80">
|
||||||
<ItemActions item={details} />
|
<ItemActions item={details} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
@@ -162,14 +150,14 @@ const Page: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className='flex-1 relative'
|
className="flex-1 relative"
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ParallaxScrollView
|
<ParallaxScrollView
|
||||||
className='flex-1 opacity-100'
|
className="flex-1 opacity-100"
|
||||||
headerHeight={300}
|
headerHeight={300}
|
||||||
headerImage={
|
headerImage={
|
||||||
<View>
|
<View>
|
||||||
@@ -184,7 +172,7 @@ const Page: React.FC = () => {
|
|||||||
source={{
|
source={{
|
||||||
uri: jellyseerrApi?.imageProxy(
|
uri: jellyseerrApi?.imageProxy(
|
||||||
result.backdropPath,
|
result.backdropPath,
|
||||||
"w1920_and_h800_multi_faces",
|
"w1920_and_h800_multi_faces"
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -194,12 +182,12 @@ const Page: React.FC = () => {
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
className='flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900'
|
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='image-outline'
|
name="image-outline"
|
||||||
size={24}
|
size={24}
|
||||||
color='white'
|
color="white"
|
||||||
style={{ opacity: 0.4 }}
|
style={{ opacity: 0.4 }}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -207,31 +195,23 @@ const Page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col'>
|
<View className="flex flex-col">
|
||||||
<View className='space-y-4'>
|
<View className="space-y-4">
|
||||||
<View className='px-4'>
|
<View className="px-4">
|
||||||
<View className='flex flex-row justify-between w-full'>
|
<View className="flex flex-row justify-between w-full">
|
||||||
<View className='flex flex-col w-56'>
|
<View className="flex flex-col w-56">
|
||||||
<JellyserrRatings
|
<JellyserrRatings result={result as MovieResult | TvResult} />
|
||||||
result={
|
|
||||||
result as
|
|
||||||
| MovieResult
|
|
||||||
| TvResult
|
|
||||||
| MovieDetails
|
|
||||||
| TvDetails
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Text
|
<Text
|
||||||
uiTextView
|
uiTextView
|
||||||
selectable
|
selectable
|
||||||
className='font-bold text-2xl mb-1'
|
className="font-bold text-2xl mb-1"
|
||||||
>
|
>
|
||||||
{mediaTitle}
|
{mediaTitle}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='opacity-50'>{releaseYear}</Text>
|
<Text className="opacity-50">{releaseYear}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Image
|
<Image
|
||||||
className='absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl'
|
className="absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl"
|
||||||
cachePolicy={"memory-disk"}
|
cachePolicy={"memory-disk"}
|
||||||
transition={300}
|
transition={300}
|
||||||
source={{
|
source={{
|
||||||
@@ -239,80 +219,48 @@ const Page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View>
|
<View className="mb-4">
|
||||||
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
||||||
</View>
|
</View>
|
||||||
{isLoading || isFetching ? (
|
{isLoading || isFetching ? (
|
||||||
<Button
|
<Button loading={true} disabled={true} color="purple"></Button>
|
||||||
loading={true}
|
|
||||||
disabled={true}
|
|
||||||
color='purple'
|
|
||||||
className='mt-4'
|
|
||||||
/>
|
|
||||||
) : canRequest ? (
|
) : canRequest ? (
|
||||||
<Button color='purple' onPress={request} className='mt-4'>
|
<Button color="purple" onPress={request}>
|
||||||
{t("jellyseerr.request_button")}
|
{t("jellyseerr.request_button")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
details?.mediaInfo?.jellyfinMediaId && (
|
<Button
|
||||||
<View className='flex flex-row space-x-2 mt-4'>
|
className="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 name="warning-outline" size={24} color="white" />
|
||||||
iconLeft={
|
}
|
||||||
<Ionicons
|
style={{
|
||||||
name='warning-outline'
|
borderWidth: 1,
|
||||||
size={20}
|
borderStyle: "solid",
|
||||||
color='white'
|
}}
|
||||||
/>
|
>
|
||||||
}
|
{t("jellyseerr.report_issue_button")}
|
||||||
style={{
|
</Button>
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: "solid",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='text-sm'>
|
|
||||||
{t("jellyseerr.report_issue_button")}
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
|
|
||||||
onPress={() => {
|
|
||||||
const url =
|
|
||||||
mediaType === MediaType.MOVIE
|
|
||||||
? `/(auth)/(tabs)/(search)/items/page?id=${details?.mediaInfo.jellyfinMediaId}`
|
|
||||||
: `/(auth)/(tabs)/(search)/series/${details?.mediaInfo.jellyfinMediaId}`;
|
|
||||||
// @ts-expect-error
|
|
||||||
router.push(url);
|
|
||||||
}}
|
|
||||||
iconLeft={
|
|
||||||
<Ionicons name='play-outline' size={20} color='white' />
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: "solid",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='text-sm'>Play</Text>
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
<OverviewText text={result.overview} className='mt-4' />
|
<OverviewText text={result.overview} className="mt-4" />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{mediaType === MediaType.TV && (
|
{result.mediaType === MediaType.TV && (
|
||||||
<JellyseerrSeasons
|
<JellyseerrSeasons
|
||||||
isLoading={isLoading || isFetching}
|
isLoading={isLoading || isFetching}
|
||||||
|
result={result as TvResult}
|
||||||
details={details as TvDetails}
|
details={details as TvDetails}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
hasAdvancedRequest={hasAdvancedRequestPermission}
|
hasAdvancedRequest={hasAdvancedRequestPermission}
|
||||||
onAdvancedRequest={(data) => setRequestBody(data)}
|
onAdvancedRequest={(data) =>
|
||||||
|
advancedReqModalRef?.current?.present(data)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<DetailFacts
|
<DetailFacts
|
||||||
className='p-2 border border-neutral-800 bg-neutral-900 rounded-xl'
|
className="p-2 border border-neutral-800 bg-neutral-900 rounded-xl"
|
||||||
details={details}
|
details={details}
|
||||||
/>
|
/>
|
||||||
<Cast details={details} />
|
<Cast details={details} />
|
||||||
@@ -321,17 +269,14 @@ const Page: React.FC = () => {
|
|||||||
</ParallaxScrollView>
|
</ParallaxScrollView>
|
||||||
<RequestModal
|
<RequestModal
|
||||||
ref={advancedReqModalRef}
|
ref={advancedReqModalRef}
|
||||||
requestBody={requestBody}
|
|
||||||
title={mediaTitle}
|
title={mediaTitle}
|
||||||
id={result.id!}
|
id={result.id!!}
|
||||||
type={mediaType}
|
type={result.mediaType as MediaType}
|
||||||
isAnime={isAnime}
|
isAnime={isAnime}
|
||||||
onRequested={() => {
|
onRequested={() => {
|
||||||
_setRequestBody(undefined);
|
|
||||||
advancedReqModalRef?.current?.close();
|
advancedReqModalRef?.current?.close();
|
||||||
refetch();
|
refetch();
|
||||||
}}
|
}}
|
||||||
onDismiss={() => _setRequestBody(undefined)}
|
|
||||||
/>
|
/>
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={bottomSheetModalRef}
|
ref={bottomSheetModalRef}
|
||||||
@@ -345,22 +290,22 @@ const Page: React.FC = () => {
|
|||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||||
<View>
|
<View>
|
||||||
<Text className='font-bold text-2xl text-neutral-100'>
|
<Text className="font-bold text-2xl text-neutral-100">
|
||||||
{t("jellyseerr.whats_wrong")}
|
{t("jellyseerr.whats_wrong")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col space-y-2 items-start'>
|
<View className="flex flex-col space-y-2 items-start">
|
||||||
<View className='flex flex-col'>
|
<View className="flex flex-col">
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className='flex flex-col'>
|
<View className="flex flex-col">
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
<Text className="opacity-50 mb-1 text-xs">
|
||||||
{t("jellyseerr.issue_type")}
|
{t("jellyseerr.issue_type")}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<Text style={{}} className='' numberOfLines={1}>
|
<Text style={{}} className="" numberOfLines={1}>
|
||||||
{issueType
|
{issueType
|
||||||
? IssueTypeName[issueType]
|
? IssueTypeName[issueType]
|
||||||
: t("jellyseerr.select_an_issue")}
|
: t("jellyseerr.select_an_issue")}
|
||||||
@@ -370,8 +315,8 @@ const Page: React.FC = () => {
|
|||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
loop={false}
|
loop={false}
|
||||||
side='bottom'
|
side="bottom"
|
||||||
align='center'
|
align="center"
|
||||||
alignOffset={0}
|
alignOffset={0}
|
||||||
avoidCollisions={true}
|
avoidCollisions={true}
|
||||||
collisionPadding={0}
|
collisionPadding={0}
|
||||||
@@ -382,7 +327,7 @@ const Page: React.FC = () => {
|
|||||||
</DropdownMenu.Label>
|
</DropdownMenu.Label>
|
||||||
{Object.entries(IssueTypeName)
|
{Object.entries(IssueTypeName)
|
||||||
.reverse()
|
.reverse()
|
||||||
.map(([key, value], _idx) => (
|
.map(([key, value], idx) => (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key={value}
|
key={value}
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
@@ -398,14 +343,14 @@ const Page: React.FC = () => {
|
|||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
|
||||||
<BottomSheetTextInput
|
<BottomSheetTextInput
|
||||||
multiline
|
multiline
|
||||||
maxLength={254}
|
maxLength={254}
|
||||||
style={{ color: "white" }}
|
style={{ color: "white" }}
|
||||||
clearButtonMode='always'
|
clearButtonMode="always"
|
||||||
placeholder={t("jellyseerr.describe_the_issue")}
|
placeholder={t("jellyseerr.describe_the_issue")}
|
||||||
placeholderTextColor='#9CA3AF'
|
placeholderTextColor="#9CA3AF"
|
||||||
// Issue with multiline + Textinput inside a portal
|
// Issue with multiline + Textinput inside a portal
|
||||||
// https://github.com/callstack/react-native-paper/issues/1668
|
// https://github.com/callstack/react-native-paper/issues/1668
|
||||||
defaultValue={issueMessage}
|
defaultValue={issueMessage}
|
||||||
@@ -413,7 +358,7 @@ const Page: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
<Button className="mt-auto" onPress={submitIssue} color="purple">
|
||||||
{t("jellyseerr.submit_button")}
|
{t("jellyseerr.submit_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,30 +1,25 @@
|
|||||||
|
import {
|
||||||
|
useLocalSearchParams,
|
||||||
|
useSegments,
|
||||||
|
} from "expo-router";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
|
||||||
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 { Text } from "@/components/common/Text";
|
||||||
import type {
|
import { Image } from "expo-image";
|
||||||
MovieResult,
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
TvResult,
|
import {orderBy, uniqBy} from "lodash";
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||||
|
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||||
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
|
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const {
|
const { jellyseerrApi, jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
|
||||||
jellyseerrApi,
|
|
||||||
jellyseerrUser,
|
|
||||||
jellyseerrRegion: region,
|
|
||||||
jellyseerrLocale: locale,
|
|
||||||
} = useJellyseerr();
|
|
||||||
|
|
||||||
const { personId } = local as { personId: string };
|
const { personId } = local as { personId: string };
|
||||||
|
|
||||||
@@ -39,27 +34,18 @@ export default function page() {
|
|||||||
|
|
||||||
const castedRoles: PersonCreditCast[] = useMemo(
|
const castedRoles: PersonCreditCast[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
uniqBy(
|
uniqBy(orderBy(
|
||||||
orderBy(
|
data?.combinedCredits?.cast,
|
||||||
data?.combinedCredits?.cast,
|
["voteCount", "voteAverage"],
|
||||||
["voteCount", "voteAverage"],
|
"desc"
|
||||||
"desc",
|
), 'id'),
|
||||||
),
|
[data?.combinedCredits]
|
||||||
"id",
|
|
||||||
),
|
|
||||||
[data?.combinedCredits],
|
|
||||||
);
|
);
|
||||||
const backdrops = useMemo(
|
const backdrops = useMemo(
|
||||||
() =>
|
() => jellyseerrApi
|
||||||
jellyseerrApi
|
? castedRoles.map((c) => jellyseerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"))
|
||||||
? castedRoles.map((c) =>
|
: [],
|
||||||
jellyseerrApi.imageProxy(
|
[jellyseerrApi, data?.combinedCredits]
|
||||||
c.backdropPath,
|
|
||||||
"w1920_and_h800_multi_faces",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: [],
|
|
||||||
[jellyseerrApi, data?.combinedCredits],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -72,15 +58,15 @@ export default function page() {
|
|||||||
<Image
|
<Image
|
||||||
key={data?.details?.id}
|
key={data?.details?.id}
|
||||||
id={data?.details?.id.toString()}
|
id={data?.details?.id.toString()}
|
||||||
className='rounded-full bottom-1'
|
className="rounded-full bottom-1"
|
||||||
source={{
|
source={{
|
||||||
uri: jellyseerrApi?.imageProxy(
|
uri: jellyseerrApi?.imageProxy(
|
||||||
data?.details?.profilePath,
|
data?.details?.profilePath,
|
||||||
"w600_and_h600_bestv2",
|
"w600_and_h600_bestv2"
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
cachePolicy={"memory-disk"}
|
cachePolicy={"memory-disk"}
|
||||||
contentFit='cover'
|
contentFit="cover"
|
||||||
style={{
|
style={{
|
||||||
width: 125,
|
width: 125,
|
||||||
height: 125,
|
height: 125,
|
||||||
@@ -89,27 +75,27 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
HeaderContent={() => (
|
HeaderContent={() => (
|
||||||
<>
|
<>
|
||||||
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
|
<Text className="font-bold text-2xl mb-1">
|
||||||
<Text className='opacity-50'>
|
{data?.details?.name}
|
||||||
|
</Text>
|
||||||
|
<Text className="opacity-50">
|
||||||
{t("jellyseerr.born")}{" "}
|
{t("jellyseerr.born")}{" "}
|
||||||
{new Date(data?.details?.birthday!).toLocaleDateString(
|
{new Date(data?.details?.birthday!!).toLocaleDateString(
|
||||||
`${locale}-${region}`,
|
`${locale}-${region}`,
|
||||||
{
|
{
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
},
|
}
|
||||||
)}{" "}
|
)}{" "}
|
||||||
| {data?.details?.placeOfBirth}
|
| {data?.details?.placeOfBirth}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
MainContent={() => (
|
MainContent={() => (
|
||||||
<OverviewText text={data?.details?.biography} className='mt-4' />
|
<OverviewText text={data?.details?.biography} className="mt-4" />
|
||||||
)}
|
|
||||||
renderItem={(item, _index) => (
|
|
||||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
|
||||||
)}
|
)}
|
||||||
|
renderItem={(item, index) => <JellyseerrPoster item={item as MovieResult | TvResult} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type {
|
||||||
|
MaterialTopTabNavigationEventMap,
|
||||||
|
MaterialTopTabNavigationOptions,
|
||||||
|
} from "@react-navigation/material-top-tabs";
|
||||||
|
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
|
||||||
|
import { ParamListBase, TabNavigationState } from "@react-navigation/native";
|
||||||
|
import { Stack, withLayoutContext } from "expo-router";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const { Navigator } = createMaterialTopTabNavigator();
|
||||||
|
|
||||||
|
export const Tab = withLayoutContext<
|
||||||
|
MaterialTopTabNavigationOptions,
|
||||||
|
typeof Navigator,
|
||||||
|
TabNavigationState<ParamListBase>,
|
||||||
|
MaterialTopTabNavigationEventMap
|
||||||
|
>(Navigator);
|
||||||
|
|
||||||
|
const Layout = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: "Live TV" }} />
|
||||||
|
<Tab
|
||||||
|
initialRouteName="programs"
|
||||||
|
keyboardDismissMode="none"
|
||||||
|
screenOptions={{
|
||||||
|
tabBarBounces: true,
|
||||||
|
tabBarLabelStyle: { fontSize: 10 },
|
||||||
|
tabBarItemStyle: {
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
tabBarStyle: { backgroundColor: "black" },
|
||||||
|
animationEnabled: true,
|
||||||
|
lazy: true,
|
||||||
|
swipeEnabled: true,
|
||||||
|
tabBarIndicatorStyle: { backgroundColor: "#9334E9" },
|
||||||
|
tabBarScrollEnabled: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab.Screen name="programs" />
|
||||||
|
<Tab.Screen name="guide" />
|
||||||
|
<Tab.Screen name="channels" />
|
||||||
|
<Tab.Screen name="recordings" />
|
||||||
|
</Tab>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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 { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const { data: channels } = useQuery({
|
||||||
|
queryKey: ["livetv", "channels"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await getLiveTvApi(api!).getLiveTvChannels({
|
||||||
|
startIndex: 0,
|
||||||
|
limit: 500,
|
||||||
|
enableFavoriteSorting: true,
|
||||||
|
userId: user?.Id,
|
||||||
|
addCurrentProgram: false,
|
||||||
|
enableUserData: false,
|
||||||
|
enableImageTypes: ["Primary"],
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-1">
|
||||||
|
<FlashList
|
||||||
|
data={channels?.Items}
|
||||||
|
estimatedItemSize={76}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View className="flex flex-row items-center px-4 mb-2">
|
||||||
|
<View className="w-22 mr-4 rounded-lg overflow-hidden">
|
||||||
|
<ItemImage
|
||||||
|
style={{
|
||||||
|
aspectRatio: "1/1",
|
||||||
|
width: 60,
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
item={item}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text className="font-bold">{item.Name}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
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 { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dimensions,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
const HOUR_HEIGHT = 30;
|
||||||
|
const ITEMS_PER_PAGE = 20;
|
||||||
|
|
||||||
|
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [date, setDate] = useState<Date>(new Date());
|
||||||
|
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({
|
||||||
|
queryKey: ["livetv", "channels", currentPage],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await getLiveTvApi(api!).getLiveTvChannels({
|
||||||
|
startIndex: (currentPage - 1) * ITEMS_PER_PAGE,
|
||||||
|
limit: ITEMS_PER_PAGE,
|
||||||
|
enableFavoriteSorting: true,
|
||||||
|
userId: user?.Id,
|
||||||
|
addCurrentProgram: false,
|
||||||
|
enableUserData: false,
|
||||||
|
enableImageTypes: ["Primary"],
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: programs } = useQuery({
|
||||||
|
queryKey: ["livetv", "programs", date, currentPage],
|
||||||
|
queryFn: async () => {
|
||||||
|
const startOfDay = new Date(date);
|
||||||
|
startOfDay.setHours(0, 0, 0, 0);
|
||||||
|
const endOfDay = new Date(date);
|
||||||
|
endOfDay.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const isToday = startOfDay.toDateString() === now.toDateString();
|
||||||
|
|
||||||
|
const res = await getLiveTvApi(api!).getPrograms({
|
||||||
|
getProgramsDto: {
|
||||||
|
MaxStartDate: endOfDay.toISOString(),
|
||||||
|
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
|
||||||
|
ChannelIds: channels?.Items?.map((c) => c.Id).filter(
|
||||||
|
Boolean
|
||||||
|
) as string[],
|
||||||
|
ImageTypeLimit: 1,
|
||||||
|
EnableImages: false,
|
||||||
|
SortBy: ["StartDate"],
|
||||||
|
EnableTotalRecordCount: false,
|
||||||
|
EnableUserData: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: !!channels,
|
||||||
|
});
|
||||||
|
|
||||||
|
const screenWidth = Dimensions.get("window").width;
|
||||||
|
|
||||||
|
const [scrollX, setScrollX] = useState(0);
|
||||||
|
|
||||||
|
const handleNextPage = useCallback(() => {
|
||||||
|
setCurrentPage((prev) => prev + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePrevPage = useCallback(() => {
|
||||||
|
setCurrentPage((prev) => Math.max(1, prev - 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
nestedScrollEnabled
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
key={"home"}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PageButtons
|
||||||
|
currentPage={currentPage}
|
||||||
|
onPrevPage={handlePrevPage}
|
||||||
|
onNextPage={handleNextPage}
|
||||||
|
isNextDisabled={
|
||||||
|
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="flex flex-row">
|
||||||
|
<View className="flex flex-col w-[64px]">
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: HOUR_HEIGHT,
|
||||||
|
}}
|
||||||
|
className="bg-neutral-800"
|
||||||
|
></View>
|
||||||
|
{channels?.Items?.map((c, i) => (
|
||||||
|
<View className="h-16 w-16 mr-4 rounded-lg overflow-hidden" key={i}>
|
||||||
|
<ItemImage
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
resizeMode: "contain",
|
||||||
|
}}
|
||||||
|
item={c}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<ScrollView
|
||||||
|
style={{
|
||||||
|
width: screenWidth - 64,
|
||||||
|
}}
|
||||||
|
horizontal
|
||||||
|
scrollEnabled
|
||||||
|
onScroll={(e) => {
|
||||||
|
setScrollX(e.nativeEvent.contentOffset.x);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<HourHeader height={HOUR_HEIGHT} />
|
||||||
|
{channels?.Items?.map((c, i) => (
|
||||||
|
<MemoizedLiveTVGuideRow
|
||||||
|
channel={c}
|
||||||
|
programs={programs?.Items}
|
||||||
|
key={c.Id}
|
||||||
|
scrollX={scrollX}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageButtonsProps {
|
||||||
|
currentPage: number;
|
||||||
|
onPrevPage: () => void;
|
||||||
|
onNextPage: () => void;
|
||||||
|
isNextDisabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PageButtons: React.FC<PageButtonsProps> = ({
|
||||||
|
currentPage,
|
||||||
|
onPrevPage,
|
||||||
|
onNextPage,
|
||||||
|
isNextDisabled,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPrevPage}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="flex flex-row items-center"
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-back"
|
||||||
|
size={24}
|
||||||
|
color={currentPage === 1 ? "gray" : "white"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
className={`ml-1 ${
|
||||||
|
currentPage === 1 ? "text-gray-500" : "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("live_tv.previous")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="text-white">Page {currentPage}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onNextPage}
|
||||||
|
disabled={isNextDisabled}
|
||||||
|
className="flex flex-row items-center"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
|
||||||
|
>
|
||||||
|
{t("live_tv.next")}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-forward"
|
||||||
|
size={24}
|
||||||
|
color={isNextDisabled ? "gray" : "white"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React from "react";
|
||||||
|
import { ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
nestedScrollEnabled
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
key={"home"}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
paddingTop: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col space-y-2">
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryKey={["livetv", "recommended"]}
|
||||||
|
title={t("live_tv.on_now")}
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return [] as BaseItemDto[];
|
||||||
|
const res = await getLiveTvApi(api).getRecommendedPrograms({
|
||||||
|
userId: user?.Id,
|
||||||
|
isAiring: true,
|
||||||
|
limit: 24,
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
|
||||||
|
});
|
||||||
|
return res.data.Items || [];
|
||||||
|
}}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryKey={["livetv", "shows"]}
|
||||||
|
title={t("live_tv.shows")}
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return [] as BaseItemDto[];
|
||||||
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
|
userId: user?.Id,
|
||||||
|
hasAired: false,
|
||||||
|
limit: 9,
|
||||||
|
isMovie: false,
|
||||||
|
isSeries: true,
|
||||||
|
isSports: false,
|
||||||
|
isNews: false,
|
||||||
|
isKids: false,
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
|
||||||
|
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||||
|
});
|
||||||
|
return res.data.Items || [];
|
||||||
|
}}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryKey={["livetv", "movies"]}
|
||||||
|
title={t("live_tv.movies")}
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return [] as BaseItemDto[];
|
||||||
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
|
userId: user?.Id,
|
||||||
|
hasAired: false,
|
||||||
|
limit: 9,
|
||||||
|
isMovie: true,
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
fields: ["ChannelInfo"],
|
||||||
|
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||||
|
});
|
||||||
|
return res.data.Items || [];
|
||||||
|
}}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryKey={["livetv", "sports"]}
|
||||||
|
title={t("live_tv.sports")}
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return [] as BaseItemDto[];
|
||||||
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
|
userId: user?.Id,
|
||||||
|
hasAired: false,
|
||||||
|
limit: 9,
|
||||||
|
isSports: true,
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
fields: ["ChannelInfo"],
|
||||||
|
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||||
|
});
|
||||||
|
return res.data.Items || [];
|
||||||
|
}}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryKey={["livetv", "kids"]}
|
||||||
|
title={t("live_tv.for_kids")}
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return [] as BaseItemDto[];
|
||||||
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
|
userId: user?.Id,
|
||||||
|
hasAired: false,
|
||||||
|
limit: 9,
|
||||||
|
isKids: true,
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
fields: ["ChannelInfo"],
|
||||||
|
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||||
|
});
|
||||||
|
return res.data.Items || [];
|
||||||
|
}}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryKey={["livetv", "news"]}
|
||||||
|
title={t("live_tv.news")}
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return [] as BaseItemDto[];
|
||||||
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
|
userId: user?.Id,
|
||||||
|
hasAired: false,
|
||||||
|
limit: 9,
|
||||||
|
isNews: true,
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
fields: ["ChannelInfo"],
|
||||||
|
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||||
|
});
|
||||||
|
return res.data.Items || [];
|
||||||
|
}}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import React from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<View className="flex items-center justify-center h-full -mt-12">
|
||||||
|
<Text>{t("live_tv.coming_soon")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,3 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import type React from "react";
|
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, View } from "react-native";
|
|
||||||
import { AddToFavorites } from "@/components/AddToFavorites";
|
import { AddToFavorites } from "@/components/AddToFavorites";
|
||||||
import { DownloadItems } from "@/components/DownloadItem";
|
import { DownloadItems } from "@/components/DownloadItem";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
@@ -18,6 +8,15 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useEffect, useMemo } from "react";
|
||||||
|
import { Platform, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -50,7 +49,7 @@ const page: React.FC = () => {
|
|||||||
quality: 90,
|
quality: 90,
|
||||||
width: 1000,
|
width: 1000,
|
||||||
}),
|
}),
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
const logoUrl = useMemo(
|
||||||
@@ -59,7 +58,7 @@ const page: React.FC = () => {
|
|||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
}),
|
}),
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: allEpisodes, isLoading } = useQuery({
|
const { data: allEpisodes, isLoading } = useQuery({
|
||||||
@@ -84,24 +83,26 @@ const page: React.FC = () => {
|
|||||||
item &&
|
item &&
|
||||||
allEpisodes &&
|
allEpisodes &&
|
||||||
allEpisodes.length > 0 && (
|
allEpisodes.length > 0 && (
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<AddToFavorites item={item} />
|
<AddToFavorites item={item} />
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<DownloadItems
|
<>
|
||||||
size='large'
|
<DownloadItems
|
||||||
title={t("item_card.download.download_series")}
|
size="large"
|
||||||
items={allEpisodes || []}
|
title={t("item_card.download.download_series")}
|
||||||
MissingDownloadIconComponent={() => (
|
items={allEpisodes || []}
|
||||||
<Ionicons name='download' size={22} color='white' />
|
MissingDownloadIconComponent={() => (
|
||||||
)}
|
<Ionicons name="download" size={22} color="white" />
|
||||||
DownloadedIconComponent={() => (
|
)}
|
||||||
<Ionicons
|
DownloadedIconComponent={() => (
|
||||||
name='checkmark-done-outline'
|
<Ionicons
|
||||||
size={24}
|
name="checkmark-done-outline"
|
||||||
color='#9333ea'
|
size={24}
|
||||||
/>
|
color="#9333ea"
|
||||||
)}
|
/>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
@@ -125,23 +126,25 @@ const page: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
logo={
|
logo={
|
||||||
logoUrl ? (
|
<>
|
||||||
<Image
|
{logoUrl ? (
|
||||||
source={{
|
<Image
|
||||||
uri: logoUrl,
|
source={{
|
||||||
}}
|
uri: logoUrl,
|
||||||
style={{
|
}}
|
||||||
height: 130,
|
style={{
|
||||||
width: "100%",
|
height: 130,
|
||||||
resizeMode: "contain",
|
width: "100%",
|
||||||
}}
|
resizeMode: "contain",
|
||||||
/>
|
}}
|
||||||
) : null
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col pt-4'>
|
<View className="flex flex-col pt-4">
|
||||||
<SeriesHeader item={item} />
|
<SeriesHeader item={item} />
|
||||||
<View className='mb-4'>
|
<View className="mb-4">
|
||||||
<NextUp seriesId={seriesId} />
|
<NextUp seriesId={seriesId} />
|
||||||
</View>
|
</View>
|
||||||
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
||||||
|
|||||||
@@ -1,4 +1,35 @@
|
|||||||
import type {
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { FlatList, useWindowDimensions, 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 { Loader } from "@/components/Loader";
|
||||||
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
genreFilterAtom,
|
||||||
|
getSortByPreference,
|
||||||
|
getSortOrderPreference,
|
||||||
|
sortByAtom,
|
||||||
|
SortByOption,
|
||||||
|
sortByPreferenceAtom,
|
||||||
|
sortOptions,
|
||||||
|
sortOrderAtom,
|
||||||
|
SortOrderOption,
|
||||||
|
sortOrderOptions,
|
||||||
|
sortOrderPreferenceAtom,
|
||||||
|
tagsFilterAtom,
|
||||||
|
yearFilterAtom,
|
||||||
|
} from "@/utils/atoms/filters";
|
||||||
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
@@ -9,38 +40,8 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { 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 { 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 { useTranslation } from "react-i18next";
|
||||||
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();
|
||||||
@@ -57,7 +58,7 @@ const Page = () => {
|
|||||||
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
||||||
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
||||||
const [sortOrderPreference, setOderByPreference] = useAtom(
|
const [sortOrderPreference, setOderByPreference] = useAtom(
|
||||||
sortOrderPreferenceAtom,
|
sortOrderPreferenceAtom
|
||||||
);
|
);
|
||||||
|
|
||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
@@ -87,7 +88,7 @@ const Page = () => {
|
|||||||
}
|
}
|
||||||
_setSortBy(sortBy);
|
_setSortBy(sortBy);
|
||||||
},
|
},
|
||||||
[libraryId, sortByPreference],
|
[libraryId, sortByPreference]
|
||||||
);
|
);
|
||||||
|
|
||||||
const setSortOrder = useCallback(
|
const setSortOrder = useCallback(
|
||||||
@@ -101,7 +102,7 @@ const Page = () => {
|
|||||||
}
|
}
|
||||||
_setSortOrder(sortOrder);
|
_setSortOrder(sortOrder);
|
||||||
},
|
},
|
||||||
[libraryId, sortOrderPreference],
|
[libraryId, sortOrderPreference]
|
||||||
);
|
);
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
@@ -168,7 +169,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) => parseInt(year)),
|
||||||
includeItemTypes: itemType ? [itemType] : undefined,
|
includeItemTypes: itemType ? [itemType] : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -184,7 +185,7 @@ const Page = () => {
|
|||||||
selectedTags,
|
selectedTags,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
],
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||||
@@ -210,13 +211,14 @@ const Page = () => {
|
|||||||
const totalItems = lastPage.TotalRecordCount;
|
const totalItems = lastPage.TotalRecordCount;
|
||||||
const accumulatedItems = pages.reduce(
|
const accumulatedItems = pages.reduce(
|
||||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||||
0,
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
if (accumulatedItems < totalItems) {
|
if (accumulatedItems < totalItems) {
|
||||||
return lastPage?.Items?.length * pages.length;
|
return lastPage?.Items?.length * pages.length;
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
return undefined;
|
|
||||||
},
|
},
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
enabled: !!api && !!user?.Id && !!library,
|
enabled: !!api && !!user?.Id && !!library,
|
||||||
@@ -246,8 +248,8 @@ const Page = () => {
|
|||||||
? index % nrOfCols === 0
|
? index % nrOfCols === 0
|
||||||
? "flex-end"
|
? "flex-end"
|
||||||
: (index + 1) % nrOfCols === 0
|
: (index + 1) % nrOfCols === 0
|
||||||
? "flex-start"
|
? "flex-start"
|
||||||
: "center"
|
: "center"
|
||||||
: "center",
|
: "center",
|
||||||
width: "89%",
|
width: "89%",
|
||||||
}}
|
}}
|
||||||
@@ -258,14 +260,14 @@ const Page = () => {
|
|||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
),
|
),
|
||||||
[orientation],
|
[orientation]
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|
||||||
const ListHeaderComponent = useCallback(
|
const ListHeaderComponent = useCallback(
|
||||||
() => (
|
() => (
|
||||||
<View className=''>
|
<View className="">
|
||||||
<FlatList
|
<FlatList
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
@@ -284,13 +286,13 @@ const Page = () => {
|
|||||||
key: "genre",
|
key: "genre",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className='mr-1'
|
className="mr-1"
|
||||||
id={libraryId}
|
collectionId={libraryId}
|
||||||
queryKey='genreFilter'
|
queryKey="genreFilter"
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getFilterApi(
|
const response = await getFilterApi(
|
||||||
api,
|
api
|
||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: libraryId,
|
parentId: libraryId,
|
||||||
@@ -311,13 +313,13 @@ const Page = () => {
|
|||||||
key: "year",
|
key: "year",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className='mr-1'
|
className="mr-1"
|
||||||
id={libraryId}
|
collectionId={libraryId}
|
||||||
queryKey='yearFilter'
|
queryKey="yearFilter"
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getFilterApi(
|
const response = await getFilterApi(
|
||||||
api,
|
api
|
||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: libraryId,
|
parentId: libraryId,
|
||||||
@@ -336,13 +338,13 @@ const Page = () => {
|
|||||||
key: "tags",
|
key: "tags",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className='mr-1'
|
className="mr-1"
|
||||||
id={libraryId}
|
collectionId={libraryId}
|
||||||
queryKey='tagsFilter'
|
queryKey="tagsFilter"
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getFilterApi(
|
const response = await getFilterApi(
|
||||||
api,
|
api
|
||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: libraryId,
|
parentId: libraryId,
|
||||||
@@ -363,9 +365,9 @@ const Page = () => {
|
|||||||
key: "sortBy",
|
key: "sortBy",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className='mr-1'
|
className="mr-1"
|
||||||
id={libraryId}
|
collectionId={libraryId}
|
||||||
queryKey='sortBy'
|
queryKey="sortBy"
|
||||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
set={setSortBy}
|
set={setSortBy}
|
||||||
values={sortBy}
|
values={sortBy}
|
||||||
@@ -383,9 +385,9 @@ const Page = () => {
|
|||||||
key: "sortOrder",
|
key: "sortOrder",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className='mr-1'
|
className="mr-1"
|
||||||
id={libraryId}
|
collectionId={libraryId}
|
||||||
queryKey='sortOrder'
|
queryKey="sortOrder"
|
||||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
set={setSortOrder}
|
set={setSortOrder}
|
||||||
values={sortOrder}
|
values={sortOrder}
|
||||||
@@ -420,29 +422,34 @@ const Page = () => {
|
|||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
isFetching,
|
isFetching,
|
||||||
],
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
if (isLoading || isLibraryLoading)
|
if (isLoading || isLibraryLoading)
|
||||||
return (
|
return (
|
||||||
<View className='w-full h-full flex items-center justify-center'>
|
<View className="w-full h-full flex items-center justify-center">
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (flatData.length === 0)
|
||||||
|
return (
|
||||||
|
<View className="h-full w-full flex justify-center items-center">
|
||||||
|
<Text className="text-lg text-neutral-500">{t("library.no_items_found")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
key={orientation}
|
key={orientation}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className='flex flex-col items-center justify-center h-full'>
|
<View className="flex flex-col items-center justify-center h-full">
|
||||||
<Text className='font-bold text-xl text-neutral-500'>
|
<Text className="font-bold text-xl text-neutral-500">{t("library.no_results")}</Text>
|
||||||
{t("library.no_results")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
data={flatData}
|
data={flatData}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
extraData={[orientation, nrOfCols]}
|
extraData={[orientation, nrOfCols]}
|
||||||
@@ -467,7 +474,7 @@ const Page = () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
}}
|
}}
|
||||||
/>
|
></View>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
|
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 +16,7 @@ export default function IndexLayout() {
|
|||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name="index"
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
@@ -27,7 +25,7 @@ export default function IndexLayout() {
|
|||||||
headerLargeStyle: {
|
headerLargeStyle: {
|
||||||
backgroundColor: "black",
|
backgroundColor: "black",
|
||||||
},
|
},
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
!pluginSettings?.libraryOptions?.locked &&
|
!pluginSettings?.libraryOptions?.locked &&
|
||||||
@@ -35,9 +33,9 @@ export default function IndexLayout() {
|
|||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='ellipsis-horizontal-outline'
|
name="ellipsis-horizontal-outline"
|
||||||
size={24}
|
size={24}
|
||||||
color='white'
|
color="white"
|
||||||
/>
|
/>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
@@ -52,9 +50,9 @@ export default function IndexLayout() {
|
|||||||
<DropdownMenu.Label>
|
<DropdownMenu.Label>
|
||||||
{t("library.options.display")}
|
{t("library.options.display")}
|
||||||
</DropdownMenu.Label>
|
</DropdownMenu.Label>
|
||||||
<DropdownMenu.Group key='display-group'>
|
<DropdownMenu.Group key="display-group">
|
||||||
<DropdownMenu.Sub>
|
<DropdownMenu.Sub>
|
||||||
<DropdownMenu.SubTrigger key='image-style-trigger'>
|
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||||
{t("library.options.display")}
|
{t("library.options.display")}
|
||||||
</DropdownMenu.SubTrigger>
|
</DropdownMenu.SubTrigger>
|
||||||
<DropdownMenu.SubContent
|
<DropdownMenu.SubContent
|
||||||
@@ -65,7 +63,7 @@ export default function IndexLayout() {
|
|||||||
sideOffset={10}
|
sideOffset={10}
|
||||||
>
|
>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
key='display-option-1'
|
key="display-option-1"
|
||||||
value={settings.libraryOptions.display === "row"}
|
value={settings.libraryOptions.display === "row"}
|
||||||
onValueChange={() =>
|
onValueChange={() =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -77,12 +75,12 @@ export default function IndexLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemIndicator />
|
<DropdownMenu.ItemIndicator />
|
||||||
<DropdownMenu.ItemTitle key='display-title-1'>
|
<DropdownMenu.ItemTitle key="display-title-1">
|
||||||
{t("library.options.row")}
|
{t("library.options.row")}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
key='display-option-2'
|
key="display-option-2"
|
||||||
value={settings.libraryOptions.display === "list"}
|
value={settings.libraryOptions.display === "list"}
|
||||||
onValueChange={() =>
|
onValueChange={() =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -94,14 +92,14 @@ export default function IndexLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemIndicator />
|
<DropdownMenu.ItemIndicator />
|
||||||
<DropdownMenu.ItemTitle key='display-title-2'>
|
<DropdownMenu.ItemTitle key="display-title-2">
|
||||||
{t("library.options.list")}
|
{t("library.options.list")}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
</DropdownMenu.SubContent>
|
</DropdownMenu.SubContent>
|
||||||
</DropdownMenu.Sub>
|
</DropdownMenu.Sub>
|
||||||
<DropdownMenu.Sub>
|
<DropdownMenu.Sub>
|
||||||
<DropdownMenu.SubTrigger key='image-style-trigger'>
|
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||||
{t("library.options.image_style")}
|
{t("library.options.image_style")}
|
||||||
</DropdownMenu.SubTrigger>
|
</DropdownMenu.SubTrigger>
|
||||||
<DropdownMenu.SubContent
|
<DropdownMenu.SubContent
|
||||||
@@ -112,7 +110,7 @@ export default function IndexLayout() {
|
|||||||
sideOffset={10}
|
sideOffset={10}
|
||||||
>
|
>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
key='poster-option'
|
key="poster-option"
|
||||||
value={
|
value={
|
||||||
settings.libraryOptions.imageStyle === "poster"
|
settings.libraryOptions.imageStyle === "poster"
|
||||||
}
|
}
|
||||||
@@ -126,12 +124,12 @@ export default function IndexLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemIndicator />
|
<DropdownMenu.ItemIndicator />
|
||||||
<DropdownMenu.ItemTitle key='poster-title'>
|
<DropdownMenu.ItemTitle key="poster-title">
|
||||||
{t("library.options.poster")}
|
{t("library.options.poster")}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
key='cover-option'
|
key="cover-option"
|
||||||
value={settings.libraryOptions.imageStyle === "cover"}
|
value={settings.libraryOptions.imageStyle === "cover"}
|
||||||
onValueChange={() =>
|
onValueChange={() =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -143,17 +141,17 @@ export default function IndexLayout() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemIndicator />
|
<DropdownMenu.ItemIndicator />
|
||||||
<DropdownMenu.ItemTitle key='cover-title'>
|
<DropdownMenu.ItemTitle key="cover-title">
|
||||||
{t("library.options.cover")}
|
{t("library.options.cover")}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
</DropdownMenu.SubContent>
|
</DropdownMenu.SubContent>
|
||||||
</DropdownMenu.Sub>
|
</DropdownMenu.Sub>
|
||||||
</DropdownMenu.Group>
|
</DropdownMenu.Group>
|
||||||
<DropdownMenu.Group key='show-titles-group'>
|
<DropdownMenu.Group key="show-titles-group">
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
disabled={settings.libraryOptions.imageStyle === "poster"}
|
disabled={settings.libraryOptions.imageStyle === "poster"}
|
||||||
key='show-titles-option'
|
key="show-titles-option"
|
||||||
value={settings.libraryOptions.showTitles}
|
value={settings.libraryOptions.showTitles}
|
||||||
onValueChange={(newValue: string) => {
|
onValueChange={(newValue: string) => {
|
||||||
if (settings.libraryOptions.imageStyle === "poster")
|
if (settings.libraryOptions.imageStyle === "poster")
|
||||||
@@ -161,30 +159,30 @@ export default function IndexLayout() {
|
|||||||
updateSettings({
|
updateSettings({
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
...settings.libraryOptions,
|
...settings.libraryOptions,
|
||||||
showTitles: newValue === "on",
|
showTitles: newValue === "on" ? true : false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemIndicator />
|
<DropdownMenu.ItemIndicator />
|
||||||
<DropdownMenu.ItemTitle key='show-titles-title'>
|
<DropdownMenu.ItemTitle key="show-titles-title">
|
||||||
{t("library.options.show_titles")}
|
{t("library.options.show_titles")}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
key='show-stats-option'
|
key="show-stats-option"
|
||||||
value={settings.libraryOptions.showStats}
|
value={settings.libraryOptions.showStats}
|
||||||
onValueChange={(newValue: string) => {
|
onValueChange={(newValue: string) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
...settings.libraryOptions,
|
...settings.libraryOptions,
|
||||||
showStats: newValue === "on",
|
showStats: newValue === "on" ? true : false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemIndicator />
|
<DropdownMenu.ItemIndicator />
|
||||||
<DropdownMenu.ItemTitle key='show-stats-title'>
|
<DropdownMenu.ItemTitle key="show-stats-title">
|
||||||
{t("library.options.show_stats")}
|
{t("library.options.show_stats")}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
@@ -197,12 +195,12 @@ export default function IndexLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='[libraryId]'
|
name="[libraryId]"
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -210,12 +208,12 @@ export default function IndexLayout() {
|
|||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='collections/[collectionId]'
|
name="collections/[collectionId]"
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
getUserViewsApi,
|
getUserViewsApi,
|
||||||
@@ -6,14 +11,9 @@ import { FlashList } from "@shopify/flash-list";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { StyleSheet, View } from "react-native";
|
import { StyleSheet, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { useTranslation } from "react-i18next";
|
||||||
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);
|
||||||
@@ -23,7 +23,7 @@ export default function index() {
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading: isLoading } = useQuery({
|
||||||
queryKey: ["user-views", user?.Id],
|
queryKey: ["user-views", user?.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await getUserViewsApi(api!).getUserViews({
|
const response = await getUserViewsApi(api!).getUserViews({
|
||||||
@@ -41,7 +41,7 @@ export default function index() {
|
|||||||
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
||||||
.filter((l) => l.CollectionType !== "music")
|
.filter((l) => l.CollectionType !== "music")
|
||||||
.filter((l) => l.CollectionType !== "books") || [],
|
.filter((l) => l.CollectionType !== "books") || [],
|
||||||
[data, settings?.hiddenLibraries],
|
[data, settings?.hiddenLibraries]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,24 +65,22 @@ export default function index() {
|
|||||||
|
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return (
|
||||||
<View className='justify-center items-center h-full'>
|
<View className="justify-center items-center h-full">
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!libraries)
|
if (!libraries)
|
||||||
return (
|
return (
|
||||||
<View className='h-full w-full flex justify-center items-center'>
|
<View className="h-full w-full flex justify-center items-center">
|
||||||
<Text className='text-lg text-neutral-500'>
|
<Text className="text-lg text-neutral-500">{t("library.no_libraries_found")}</Text>
|
||||||
{t("library.no_libraries_found")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
extraData={settings}
|
extraData={settings}
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: 17,
|
paddingTop: 17,
|
||||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
||||||
@@ -99,10 +97,10 @@ export default function index() {
|
|||||||
style={{
|
style={{
|
||||||
height: StyleSheet.hairlineWidth,
|
height: StyleSheet.hairlineWidth,
|
||||||
}}
|
}}
|
||||||
className='bg-neutral-800 mx-2 my-4'
|
className="bg-neutral-800 mx-2 my-4"
|
||||||
/>
|
></View>
|
||||||
) : (
|
) : (
|
||||||
<View className='h-4' />
|
<View className="h-4" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
estimatedItemSize={200}
|
estimatedItemSize={200}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
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 { Platform } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function SearchLayout() {
|
export default function SearchLayout() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name="index"
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
@@ -20,7 +20,7 @@ export default function SearchLayout() {
|
|||||||
backgroundColor: "black",
|
backgroundColor: "black",
|
||||||
},
|
},
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -28,26 +28,26 @@ export default function SearchLayout() {
|
|||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='collections/[collectionId]'
|
name="collections/[collectionId]"
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name='jellyseerr/page' options={commonScreenOptions} />
|
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='jellyseerr/person/[personId]'
|
name="jellyseerr/person/[personId]"
|
||||||
options={commonScreenOptions}
|
options={commonScreenOptions}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='jellyseerr/company/[companyId]'
|
name="jellyseerr/company/[companyId]"
|
||||||
options={commonScreenOptions}
|
options={commonScreenOptions}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='jellyseerr/genre/[genreId]'
|
name="jellyseerr/genre/[genreId]"
|
||||||
options={commonScreenOptions}
|
options={commonScreenOptions}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,34 +1,10 @@
|
|||||||
import type {
|
import { Input } from "@/components/common/Input";
|
||||||
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,
|
|
||||||
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 { 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 ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import { Tag } from "@/components/GenreTags";
|
import { Tag } from "@/components/GenreTags";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import {
|
import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||||
JellyseerrSearchSort,
|
|
||||||
JellyserrIndexPage,
|
|
||||||
} from "@/components/jellyseerr/JellyseerrIndexPage";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||||
@@ -36,6 +12,27 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
|||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import {
|
||||||
|
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 { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
@@ -53,8 +50,6 @@ export default function search() {
|
|||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { q } = params as { q: string };
|
const { q } = params as { q: string };
|
||||||
@@ -65,27 +60,17 @@ export default function search() {
|
|||||||
const [debouncedSearch] = useDebounce(search, 500);
|
const [debouncedSearch] = useDebounce(search, 500);
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
|
||||||
useState<JellyseerrSearchSort>(
|
|
||||||
JellyseerrSearchSort[
|
|
||||||
JellyseerrSearchSort.DEFAULT
|
|
||||||
] as unknown as JellyseerrSearchSort,
|
|
||||||
);
|
|
||||||
const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<
|
|
||||||
"asc" | "desc"
|
|
||||||
>("desc");
|
|
||||||
|
|
||||||
const searchEngine = useMemo(() => {
|
const searchEngine = useMemo(() => {
|
||||||
return settings?.searchEngine || "Jellyfin";
|
return settings?.searchEngine || "Jellyfin";
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (q && q.length > 0) {
|
if (q && q.length > 0) setSearch(q);
|
||||||
setSearch(q);
|
|
||||||
}
|
|
||||||
}, [q]);
|
}, [q]);
|
||||||
|
|
||||||
const searchFn = useCallback(
|
const searchFn = useCallback(
|
||||||
@@ -96,52 +81,45 @@ export default function search() {
|
|||||||
types: BaseItemKind[];
|
types: BaseItemKind[];
|
||||||
query: string;
|
query: string;
|
||||||
}): Promise<BaseItemDto[]> => {
|
}): Promise<BaseItemDto[]> => {
|
||||||
if (!api || !query) {
|
if (!api || !query) return [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (searchEngine === "Jellyfin") {
|
if (searchEngine === "Jellyfin") {
|
||||||
const searchApi = await getItemsApi(api).getItems({
|
const searchApi = await getSearchApi(api).getSearchHints({
|
||||||
searchTerm: query,
|
searchTerm: query,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
includeItemTypes: types,
|
includeItemTypes: types,
|
||||||
recursive: true,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (searchApi.data.Items as BaseItemDto[]) || [];
|
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
|
||||||
|
} else {
|
||||||
|
if (!settings?.marlinServerUrl) return [];
|
||||||
|
|
||||||
|
const url = `${
|
||||||
|
settings.marlinServerUrl
|
||||||
|
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
||||||
|
.map((type) => encodeURIComponent(type))
|
||||||
|
.join("&includeItemTypes=")}`;
|
||||||
|
|
||||||
|
const response1 = await axios.get(url);
|
||||||
|
|
||||||
|
const ids = response1.data.ids;
|
||||||
|
|
||||||
|
if (!ids || !ids.length) return [];
|
||||||
|
|
||||||
|
const response2 = await getItemsApi(api).getItems({
|
||||||
|
ids,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (response2.data.Items as BaseItemDto[]) || [];
|
||||||
}
|
}
|
||||||
if (!settings?.marlinServerUrl) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${
|
|
||||||
settings.marlinServerUrl
|
|
||||||
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
|
||||||
.map((type) => encodeURIComponent(type))
|
|
||||||
.join("&includeItemTypes=")}`;
|
|
||||||
|
|
||||||
const response1 = await axios.get(url);
|
|
||||||
|
|
||||||
const ids = response1.data.ids;
|
|
||||||
|
|
||||||
if (!ids || !ids.length) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const response2 = await getItemsApi(api).getItems({
|
|
||||||
ids,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return (response2.data.Items as BaseItemDto[]) || [];
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error during search:", error);
|
console.error("Error during search:", error);
|
||||||
return []; // Ensure an empty array is returned in case of an error
|
return []; // Ensure an empty array is returned in case of an error
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, searchEngine, settings],
|
[api, searchEngine, settings]
|
||||||
);
|
);
|
||||||
|
|
||||||
type HeaderSearchBarRef = {
|
type HeaderSearchBarRef = {
|
||||||
@@ -171,10 +149,8 @@ export default function search() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = eventBus.on("searchTabPressed", () => {
|
const unsubscribe = eventBus.on("searchTabPressed", () => {
|
||||||
// Screen not active
|
// Screen not actuve
|
||||||
if (!searchBarRef.current) {
|
if (!searchBarRef.current) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Screen is active, focus search bar
|
// Screen is active, focus search bar
|
||||||
searchBarRef.current?.focus();
|
searchBarRef.current?.focus();
|
||||||
});
|
});
|
||||||
@@ -251,28 +227,25 @@ export default function search() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
keyboardDismissMode='on-drag'
|
keyboardDismissMode="on-drag"
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className='flex flex-col'
|
className="flex flex-col"
|
||||||
style={{
|
style={{
|
||||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{jellyseerrApi && (
|
{jellyseerrApi && (
|
||||||
<ScrollView
|
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
|
||||||
horizontal
|
|
||||||
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
|
||||||
>
|
|
||||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||||
<Tag
|
<Tag
|
||||||
text={t("search.library")}
|
text={t("search.library")}
|
||||||
textClass='p-1'
|
textClass="p-1"
|
||||||
className={
|
className={
|
||||||
searchType === "Library" ? "bg-purple-600" : undefined
|
searchType === "Library" ? "bg-purple-600" : undefined
|
||||||
}
|
}
|
||||||
@@ -281,49 +254,16 @@ export default function search() {
|
|||||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||||
<Tag
|
<Tag
|
||||||
text={t("search.discover")}
|
text={t("search.discover")}
|
||||||
textClass='p-1'
|
textClass="p-1"
|
||||||
className={
|
className={
|
||||||
searchType === "Discover" ? "bg-purple-600" : undefined
|
searchType === "Discover" ? "bg-purple-600" : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{searchType === "Discover" &&
|
</View>
|
||||||
!loading &&
|
|
||||||
noResults &&
|
|
||||||
debouncedSearch.length > 0 && (
|
|
||||||
<View className='flex flex-row justify-end items-center space-x-1'>
|
|
||||||
<FilterButton
|
|
||||||
id='search'
|
|
||||||
queryKey='jellyseerr_search'
|
|
||||||
queryFn={async () =>
|
|
||||||
Object.keys(JellyseerrSearchSort).filter((v) =>
|
|
||||||
Number.isNaN(Number(v)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
set={(value) => setJellyseerrOrderBy(value[0])}
|
|
||||||
values={[jellyseerrOrderBy]}
|
|
||||||
title={t("library.filters.sort_by")}
|
|
||||||
renderItemLabel={(item) =>
|
|
||||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
|
||||||
}
|
|
||||||
showSearch={false}
|
|
||||||
/>
|
|
||||||
<FilterButton
|
|
||||||
id='order'
|
|
||||||
queryKey='jellysearr_search'
|
|
||||||
queryFn={async () => ["asc", "desc"]}
|
|
||||||
set={(value) => setJellyseerrSortOrder(value[0])}
|
|
||||||
values={[jellyseerrSortOrder]}
|
|
||||||
title={t("library.filters.sort_order")}
|
|
||||||
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
|
||||||
showSearch={false}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View className='mt-2'>
|
<View className="mt-2">
|
||||||
<LoadingSkeleton isLoading={loading} />
|
<LoadingSkeleton isLoading={loading} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -331,50 +271,50 @@ export default function search() {
|
|||||||
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
header={t("search.movies")}
|
header={t("search.movies")}
|
||||||
items={movies}
|
ids={movies?.map((m) => m.Id!)}
|
||||||
renderItem={(item: BaseItemDto) => (
|
renderItem={(item: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className='flex flex-col w-28 mr-2'
|
className="flex flex-col w-28 mr-2"
|
||||||
item={item}
|
item={item}
|
||||||
>
|
>
|
||||||
<MoviePoster item={item} key={item.Id} />
|
<MoviePoster item={item} key={item.Id} />
|
||||||
<Text numberOfLines={2} className='mt-2'>
|
<Text numberOfLines={2} className="mt-2">
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='opacity-50 text-xs'>
|
<Text className="opacity-50 text-xs">
|
||||||
{item.ProductionYear}
|
{item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
items={series}
|
ids={series?.map((m) => m.Id!)}
|
||||||
header={t("search.series")}
|
header={t("search.series")}
|
||||||
renderItem={(item: BaseItemDto) => (
|
renderItem={(item: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
item={item}
|
item={item}
|
||||||
className='flex flex-col w-28 mr-2'
|
className="flex flex-col w-28 mr-2"
|
||||||
>
|
>
|
||||||
<SeriesPoster item={item} key={item.Id} />
|
<SeriesPoster item={item} key={item.Id} />
|
||||||
<Text numberOfLines={2} className='mt-2'>
|
<Text numberOfLines={2} className="mt-2">
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='opacity-50 text-xs'>
|
<Text className="opacity-50 text-xs">
|
||||||
{item.ProductionYear}
|
{item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
items={episodes}
|
ids={episodes?.map((m) => m.Id!)}
|
||||||
header={t("search.episodes")}
|
header={t("search.episodes")}
|
||||||
renderItem={(item: BaseItemDto) => (
|
renderItem={(item: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className='flex flex-col w-44 mr-2'
|
className="flex flex-col w-44 mr-2"
|
||||||
>
|
>
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster item={item} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
@@ -382,29 +322,29 @@ export default function search() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
items={collections}
|
ids={collections?.map((m) => m.Id!)}
|
||||||
header={t("search.collections")}
|
header={t("search.collections")}
|
||||||
renderItem={(item: BaseItemDto) => (
|
renderItem={(item: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
item={item}
|
item={item}
|
||||||
className='flex flex-col w-28 mr-2'
|
className="flex flex-col w-28 mr-2"
|
||||||
>
|
>
|
||||||
<MoviePoster item={item} key={item.Id} />
|
<MoviePoster item={item} key={item.Id} />
|
||||||
<Text numberOfLines={2} className='mt-2'>
|
<Text numberOfLines={2} className="mt-2">
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
items={actors}
|
ids={actors?.map((m) => m.Id!)}
|
||||||
header={t("search.actors")}
|
header={t("search.actors")}
|
||||||
renderItem={(item: BaseItemDto) => (
|
renderItem={(item: BaseItemDto) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className='flex flex-col w-28 mr-2'
|
className="flex flex-col w-28 mr-2"
|
||||||
>
|
>
|
||||||
<MoviePoster item={item} />
|
<MoviePoster item={item} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
@@ -413,39 +353,35 @@ export default function search() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<JellyserrIndexPage
|
<JellyserrIndexPage searchQuery={debouncedSearch} />
|
||||||
searchQuery={debouncedSearch}
|
|
||||||
sortType={jellyseerrOrderBy}
|
|
||||||
order={jellyseerrSortOrder}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{searchType === "Library" &&
|
{searchType === "Library" && (
|
||||||
(!loading && noResults && debouncedSearch.length > 0 ? (
|
<>
|
||||||
<View>
|
{!loading && noResults && debouncedSearch.length > 0 ? (
|
||||||
<Text className='text-center text-lg font-bold mt-4'>
|
<View>
|
||||||
{t("search.no_results_found_for")}
|
<Text className="text-center text-lg font-bold mt-4">
|
||||||
</Text>
|
{t("search.no_results_found_for")}
|
||||||
<Text className='text-xs text-purple-600 text-center'>
|
</Text>
|
||||||
"{debouncedSearch}"
|
<Text className="text-xs text-purple-600 text-center">
|
||||||
</Text>
|
"{debouncedSearch}"
|
||||||
</View>
|
</Text>
|
||||||
) : debouncedSearch.length === 0 ? (
|
</View>
|
||||||
<View className='mt-4 flex flex-col items-center space-y-2'>
|
) : debouncedSearch.length === 0 ? (
|
||||||
{exampleSearches.map((e) => (
|
<View className="mt-4 flex flex-col items-center space-y-2">
|
||||||
<TouchableOpacity
|
{exampleSearches.map((e) => (
|
||||||
onPress={() => {
|
<TouchableOpacity
|
||||||
setSearch(e);
|
onPress={() => setSearch(e)}
|
||||||
searchBarRef.current?.setText(e);
|
key={e}
|
||||||
}}
|
className="mb-2"
|
||||||
key={e}
|
>
|
||||||
className='mb-2'
|
<Text className="text-purple-600">{e}</Text>
|
||||||
>
|
</TouchableOpacity>
|
||||||
<Text className='text-purple-600'>{e}</Text>
|
))}
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
))}
|
) : null}
|
||||||
</View>
|
</>
|
||||||
) : null)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
|
import React, { useCallback, useRef } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createNativeBottomTabNavigator,
|
createNativeBottomTabNavigator,
|
||||||
type NativeBottomTabNavigationEventMap,
|
NativeBottomTabNavigationEventMap,
|
||||||
} from "@bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
|
|
||||||
const { Navigator } = createNativeBottomTabNavigator();
|
const { Navigator } = createNativeBottomTabNavigator();
|
||||||
|
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
||||||
|
|
||||||
import type { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "@react-navigation/native";
|
} from "@react-navigation/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 { eventBus } from "@/utils/eventBus";
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
|
|
||||||
export const NativeTabs = withLayoutContext<
|
export const NativeTabs = withLayoutContext<
|
||||||
BottomTabNavigationOptions,
|
BottomTabNavigationOptions,
|
||||||
@@ -44,12 +46,12 @@ export default function TabLayout() {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, []),
|
}, [])
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden={false} style='light' />
|
<SystemBars hidden={false} style="light" />
|
||||||
<NativeTabs
|
<NativeTabs
|
||||||
sidebarAdaptable={false}
|
sidebarAdaptable={false}
|
||||||
ignoresTopSafeArea
|
ignoresTopSafeArea
|
||||||
@@ -57,20 +59,20 @@ export default function TabLayout() {
|
|||||||
backgroundColor: "#121212",
|
backgroundColor: "#121212",
|
||||||
}}
|
}}
|
||||||
tabBarActiveTintColor={Colors.primary}
|
tabBarActiveTintColor={Colors.primary}
|
||||||
scrollEdgeAppearance='default'
|
scrollEdgeAppearance="default"
|
||||||
>
|
>
|
||||||
<NativeTabs.Screen redirect name='index' />
|
<NativeTabs.Screen redirect name="index" />
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
listeners={({ navigation }) => ({
|
listeners={({ navigation }) => ({
|
||||||
tabPress: (_e) => {
|
tabPress: (e) => {
|
||||||
eventBus.emit("scrollToTop");
|
eventBus.emit("scrollToTop");
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
name='(home)'
|
name="(home)"
|
||||||
options={{
|
options={{
|
||||||
title: t("tabs.home"),
|
title: t("tabs.home"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS == "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
require("@/assets/icons/house.fill.png")
|
require("@/assets/icons/house.fill.png")
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
@@ -81,15 +83,15 @@ export default function TabLayout() {
|
|||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
listeners={({ navigation }) => ({
|
listeners={({ navigation }) => ({
|
||||||
tabPress: (_e) => {
|
tabPress: (e) => {
|
||||||
eventBus.emit("searchTabPressed");
|
eventBus.emit("searchTabPressed");
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
name='(search)'
|
name="(search)"
|
||||||
options={{
|
options={{
|
||||||
title: t("tabs.search"),
|
title: t("tabs.search"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS == "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
require("@/assets/icons/magnifyingglass.png")
|
require("@/assets/icons/magnifyingglass.png")
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
@@ -99,11 +101,11 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
name='(favorites)'
|
name="(favorites)"
|
||||||
options={{
|
options={{
|
||||||
title: t("tabs.favorites"),
|
title: t("tabs.favorites"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS == "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
focused
|
focused
|
||||||
? require("@/assets/icons/heart.fill.png")
|
? require("@/assets/icons/heart.fill.png")
|
||||||
@@ -115,11 +117,11 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
name='(libraries)'
|
name="(libraries)"
|
||||||
options={{
|
options={{
|
||||||
title: t("tabs.library"),
|
title: t("tabs.library"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS == "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
require("@/assets/icons/server.rack.png")
|
require("@/assets/icons/server.rack.png")
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
@@ -129,13 +131,13 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
name='(custom-links)'
|
name="(custom-links)"
|
||||||
options={{
|
options={{
|
||||||
title: t("tabs.custom_links"),
|
title: t("tabs.custom_links"),
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS == "android"
|
||||||
? ({ focused }) => require("@/assets/icons/list.png")
|
? ({ focused }) => require("@/assets/icons/list.png")
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
focused
|
focused
|
||||||
|
|||||||
@@ -1,13 +1,39 @@
|
|||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
|
import React, { useEffect } from "react";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
|
const [settings] = useSettings();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
|
if (settings.defaultVideoOrientation) {
|
||||||
|
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
|
if (settings.autoRotate === true) {
|
||||||
|
ScreenOrientation.unlockAsync();
|
||||||
|
} else {
|
||||||
|
ScreenOrientation.lockAsync(
|
||||||
|
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden />
|
<SystemBars hidden />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='direct-player'
|
name="direct-player"
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
autoHideHomeIndicator: true,
|
autoHideHomeIndicator: true,
|
||||||
|
|||||||
@@ -1,49 +1,41 @@
|
|||||||
import {
|
|
||||||
type BaseItemDto,
|
|
||||||
type MediaSourceInfo,
|
|
||||||
PlaybackOrder,
|
|
||||||
type PlaybackProgressInfo,
|
|
||||||
PlaybackStartInfo,
|
|
||||||
RepeatMode,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
|
||||||
getPlaystateApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
|
||||||
import { router, useGlobalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Alert, View } from "react-native";
|
|
||||||
import { 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 { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
import { VlcPlayerView } from "@/modules";
|
import { VlcPlayerView } from "@/modules/vlc-player";
|
||||||
import type {
|
import {
|
||||||
|
OnDiscoveryStateChangedPayload,
|
||||||
PipStartedPayload,
|
PipStartedPayload,
|
||||||
PlaybackStatePayload,
|
PlaybackStatePayload,
|
||||||
ProgressUpdatePayload,
|
ProgressUpdatePayload,
|
||||||
VlcPlayerViewRef,
|
VlcPlayerViewRef, VLCRendererItem,
|
||||||
} from "@/modules/VlcPlayer.types";
|
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
||||||
|
const downloadProvider = !Platform.isTV ? require("@/providers/DownloadProvider") : null;
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
import native from "@/utils/profiles/native";
|
||||||
import generateDeviceProfile from "@/utils/profiles/native";
|
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
|
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||||
const downloadProvider = require("@/providers/DownloadProvider");
|
import { getPlaystateApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas";
|
import { useGlobalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, { useCallback, useMemo, useRef, useState, useEffect } from "react";
|
||||||
|
import { Alert, View, Platform } from "react-native";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { BaseItemDto, MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
|
||||||
|
import {ListGroup} from "@/components/list/ListGroup";
|
||||||
|
import {ListItem} from "@/components/list/ListItem";
|
||||||
|
import {storage} from "@/utils/mmkv";
|
||||||
|
import {t} from "i18next";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||||
@@ -54,23 +46,21 @@ 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 [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||||
// Load persisted state from storage
|
|
||||||
const saved = storage.getBoolean(IGNORE_SAFE_AREAS_KEY);
|
|
||||||
return saved ?? false;
|
|
||||||
});
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||||
const [isPipStarted, setIsPipStarted] = useState(false);
|
const [isPipStarted, setIsPipStarted] = useState(false);
|
||||||
|
const [rendererItems, setRendererItems] = useState<VLCRendererItem[]>([]);
|
||||||
|
const discoveryModal = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
const cacheProgress = useSharedValue(0);
|
const cacheProgress = useSharedValue(0);
|
||||||
const VolumeManager = require("react-native-volume-manager");
|
let getDownloadedItem = null;
|
||||||
|
if (!Platform.isTV) {
|
||||||
const getDownloadedItem = downloadProvider.useDownload();
|
getDownloadedItem = downloadProvider.useDownload();
|
||||||
|
}
|
||||||
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
@@ -81,11 +71,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,
|
||||||
@@ -93,7 +78,6 @@ export default function page() {
|
|||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
bitrateValue: bitrateValueStr,
|
bitrateValue: bitrateValueStr,
|
||||||
offline: offlineStr,
|
offline: offlineStr,
|
||||||
playbackPosition: playbackPositionFromUrl,
|
|
||||||
} = useGlobalSearchParams<{
|
} = useGlobalSearchParams<{
|
||||||
itemId: string;
|
itemId: string;
|
||||||
audioIndex: string;
|
audioIndex: string;
|
||||||
@@ -101,22 +85,14 @@ export default function page() {
|
|||||||
mediaSourceId: string;
|
mediaSourceId: string;
|
||||||
bitrateValue: string;
|
bitrateValue: string;
|
||||||
offline: string;
|
offline: string;
|
||||||
/** Playback position in ticks. */
|
|
||||||
playbackPosition?: string;
|
|
||||||
}>();
|
}>();
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
|
|
||||||
const audioIndex = audioIndexStr
|
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||||
? Number.parseInt(audioIndexStr, 10)
|
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
||||||
: undefined;
|
const bitrateValue = bitrateValueStr ? parseInt(bitrateValueStr, 10) : BITRATES[0].value;
|
||||||
const subtitleIndex = subtitleIndexStr
|
|
||||||
? Number.parseInt(subtitleIndexStr, 10)
|
|
||||||
: -1;
|
|
||||||
const bitrateValue = bitrateValueStr
|
|
||||||
? Number.parseInt(bitrateValueStr, 10)
|
|
||||||
: BITRATES[0].value;
|
|
||||||
|
|
||||||
const [item, setItem] = useState<BaseItemDto | null>(null);
|
const [item, setItem] = useState<BaseItemDto | null>(null);
|
||||||
const [itemStatus, setItemStatus] = useState({
|
const [itemStatus, setItemStatus] = useState({
|
||||||
@@ -124,20 +100,12 @@ export default function page() {
|
|||||||
isError: false,
|
isError: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Gets the initial playback position from the URL or the item's user data. */
|
|
||||||
const getInitialPlaybackTicks = useCallback((): number => {
|
|
||||||
if (playbackPositionFromUrl) {
|
|
||||||
return Number.parseInt(playbackPositionFromUrl, 10);
|
|
||||||
}
|
|
||||||
return item?.UserData?.PlaybackPositionTicks ?? 0;
|
|
||||||
}, [playbackPositionFromUrl, item]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchItemData = async () => {
|
const fetchItemData = async () => {
|
||||||
setItemStatus({ isLoading: true, isError: false });
|
setItemStatus({ isLoading: true, isError: false });
|
||||||
try {
|
try {
|
||||||
let fetchedItem: BaseItemDto | null = null;
|
let fetchedItem: BaseItemDto | null = null;
|
||||||
if (offline) {
|
if (offline && !Platform.isTV) {
|
||||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||||
if (data) fetchedItem = data.item as BaseItemDto;
|
if (data) fetchedItem = data.item as BaseItemDto;
|
||||||
} else {
|
} else {
|
||||||
@@ -148,10 +116,11 @@ export default function page() {
|
|||||||
fetchedItem = res.data;
|
fetchedItem = res.data;
|
||||||
}
|
}
|
||||||
setItem(fetchedItem);
|
setItem(fetchedItem);
|
||||||
setItemStatus({ isLoading: false, isError: false });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch item:", error);
|
console.error("Failed to fetch item:", error);
|
||||||
setItemStatus({ isLoading: false, isError: true });
|
setItemStatus({ isLoading: false, isError: true });
|
||||||
|
} finally {
|
||||||
|
setItemStatus({ isLoading: false, isError: false });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,11 +143,9 @@ export default function page() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStreamData = async () => {
|
const fetchStreamData = async () => {
|
||||||
setStreamStatus({ isLoading: true, isError: false });
|
|
||||||
const native = await generateDeviceProfile();
|
|
||||||
try {
|
try {
|
||||||
let result: Stream | null = null;
|
let result: Stream | null = null;
|
||||||
if (offline) {
|
if (offline && !Platform.isTV) {
|
||||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||||
if (!data?.mediaSource) return;
|
if (!data?.mediaSource) return;
|
||||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||||
@@ -189,7 +156,7 @@ export default function page() {
|
|||||||
const res = await getStreamUrl({
|
const res = await getStreamUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
startTimeTicks: getInitialPlaybackTicks(),
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
audioStreamIndex: audioIndex,
|
audioStreamIndex: audioIndex,
|
||||||
maxStreamingBitrate: bitrateValue,
|
maxStreamingBitrate: bitrateValue,
|
||||||
@@ -200,49 +167,32 @@ export default function page() {
|
|||||||
if (!res) return;
|
if (!res) return;
|
||||||
const { mediaSource, sessionId, url } = res;
|
const { mediaSource, sessionId, url } = res;
|
||||||
if (!sessionId || !mediaSource || !url) {
|
if (!sessionId || !mediaSource || !url) {
|
||||||
Alert.alert(
|
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
|
||||||
t("player.error"),
|
|
||||||
t("player.failed_to_get_stream_url"),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
result = { mediaSource, sessionId, url };
|
result = { mediaSource, sessionId, url };
|
||||||
}
|
}
|
||||||
setStream(result);
|
setStream(result);
|
||||||
setStreamStatus({ isLoading: false, isError: false });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch stream:", error);
|
console.error("Failed to fetch stream:", error);
|
||||||
setStreamStatus({ isLoading: false, isError: true });
|
setStreamStatus({ isLoading: false, isError: true });
|
||||||
|
} finally {
|
||||||
|
setStreamStatus({ isLoading: false, isError: false });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchStreamData();
|
fetchStreamData();
|
||||||
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
|
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
|
||||||
|
|
||||||
useEffect(() => {
|
const togglePlay = useCallback(async () => {
|
||||||
if (!stream) return;
|
if (!api) return;
|
||||||
|
|
||||||
const reportPlaybackStart = async () => {
|
|
||||||
await getPlaystateApi(api!).reportPlaybackStart({
|
|
||||||
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
reportPlaybackStart();
|
|
||||||
}, [stream]);
|
|
||||||
|
|
||||||
const togglePlay = async () => {
|
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
setIsPlaying(!isPlaying);
|
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
reportPlaybackProgress();
|
|
||||||
} else {
|
} else {
|
||||||
videoRef.current?.play();
|
videoRef.current?.play();
|
||||||
await getPlaystateApi(api!).reportPlaybackStart({
|
|
||||||
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
}, [isPlaying, api, item, stream, videoRef, audioIndex, subtitleIndex, mediaSourceId, offline, progress]);
|
||||||
|
|
||||||
const reportPlaybackStopped = useCallback(async () => {
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
if (offline) return;
|
if (offline) return;
|
||||||
@@ -255,15 +205,7 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidateProgressCache();
|
revalidateProgressCache();
|
||||||
}, [
|
}, [api, item, mediaSourceId, stream]);
|
||||||
api,
|
|
||||||
item,
|
|
||||||
mediaSourceId,
|
|
||||||
stream,
|
|
||||||
progress,
|
|
||||||
offline,
|
|
||||||
revalidateProgressCache,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
@@ -278,24 +220,6 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
}, [navigation, stop]);
|
}, [navigation, stop]);
|
||||||
|
|
||||||
const currentPlayStateInfo = () => {
|
|
||||||
if (!stream) return;
|
|
||||||
return {
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: msToTicks(progress.get()),
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream.sessionId,
|
|
||||||
isMuted: isMuted,
|
|
||||||
canSeek: true,
|
|
||||||
repeatMode: RepeatMode.RepeatNone,
|
|
||||||
playbackOrder: PlaybackOrder.Default,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
const onProgress = useCallback(
|
||||||
async (data: ProgressUpdatePayload) => {
|
async (data: ProgressUpdatePayload) => {
|
||||||
if (isSeeking.get() || isPlaybackStopped) return;
|
if (isSeeking.get() || isPlaybackStopped) return;
|
||||||
@@ -307,141 +231,107 @@ export default function page() {
|
|||||||
|
|
||||||
progress.set(currentTime);
|
progress.set(currentTime);
|
||||||
|
|
||||||
// Update the playback position in the URL.
|
|
||||||
router.setParams({
|
|
||||||
playbackPosition: msToTicks(currentTime).toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (offline) return;
|
if (offline) return;
|
||||||
|
|
||||||
|
const currentTimeInTicks = msToTicks(currentTime);
|
||||||
|
|
||||||
if (!item?.Id || !stream) return;
|
if (!item?.Id || !stream) return;
|
||||||
|
|
||||||
reportPlaybackProgress();
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
|
itemId: item.Id,
|
||||||
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
positionTicks: Math.floor(currentTimeInTicks),
|
||||||
|
isPaused: !isPlaying,
|
||||||
|
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: stream.sessionId,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[
|
[item?.Id, audioIndex, subtitleIndex, mediaSourceId, isPlaying, stream, isSeeking, isPlaybackStopped, isBuffering]
|
||||||
item?.Id,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
isPlaying,
|
|
||||||
stream,
|
|
||||||
isSeeking,
|
|
||||||
isPlaybackStopped,
|
|
||||||
isBuffering,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPipStarted = useCallback((e: PipStartedPayload) => {
|
const changePlaybackState = useCallback(
|
||||||
const { pipStarted } = e.nativeEvent;
|
async (isPlaying: boolean) => {
|
||||||
setIsPipStarted(pipStarted);
|
if (!api || offline || !stream) return;
|
||||||
}, []);
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
|
itemId: item?.Id!,
|
||||||
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
positionTicks: msToTicks(progress.get()),
|
||||||
|
isPaused: !isPlaying,
|
||||||
|
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: stream.sessionId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[api, offline, stream, item?.Id, audioIndex, subtitleIndex, mediaSourceId, progress]
|
||||||
|
);
|
||||||
|
|
||||||
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. */
|
|
||||||
const startPosition = useMemo(() => {
|
const startPosition = useMemo(() => {
|
||||||
if (offline) return 0;
|
if (offline) return 0;
|
||||||
return ticksToSeconds(getInitialPlaybackTicks());
|
return item?.UserData?.PlaybackPositionTicks ? ticksToSeconds(item.UserData.PlaybackPositionTicks) : 0;
|
||||||
}, [offline, getInitialPlaybackTicks]);
|
}, [item]);
|
||||||
|
|
||||||
const volumeUpCb = useCallback(async () => {
|
const reportPlaybackStart = useCallback(async () => {
|
||||||
try {
|
if (offline || !stream) return;
|
||||||
const { volume: currentVolume } = await VolumeManager.getVolume();
|
await getPlaystateApi(api!).onPlaybackStart({
|
||||||
const newVolume = Math.min(currentVolume + 0.1, 1.0);
|
itemId: item?.Id!,
|
||||||
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
|
||||||
|
});
|
||||||
|
hasReportedRef.current = true;
|
||||||
|
}, [api, item, stream]);
|
||||||
|
|
||||||
await VolumeManager.setVolume(newVolume);
|
const hasReportedRef = useRef(false);
|
||||||
} catch (error) {
|
useEffect(() => {
|
||||||
console.error("Error adjusting volume:", error);
|
if (stream && !hasReportedRef.current) {
|
||||||
|
reportPlaybackStart();
|
||||||
|
hasReportedRef.current = true; // Mark as reported
|
||||||
}
|
}
|
||||||
}, []);
|
}, [stream]);
|
||||||
const [previousVolume, setPreviousVolume] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const toggleMuteCb = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const { volume: currentVolume } = await VolumeManager.getVolume();
|
|
||||||
const currentVolumePercent = currentVolume * 100;
|
|
||||||
|
|
||||||
if (currentVolumePercent > 0) {
|
|
||||||
// Currently not muted, so mute
|
|
||||||
setPreviousVolume(currentVolumePercent);
|
|
||||||
await VolumeManager.setVolume(0);
|
|
||||||
setIsMuted(true);
|
|
||||||
} else {
|
|
||||||
// Currently muted, so restore previous volume
|
|
||||||
const volumeToRestore = previousVolume || 50; // Default to 50% if no previous volume
|
|
||||||
await VolumeManager.setVolume(volumeToRestore / 100);
|
|
||||||
setPreviousVolume(null);
|
|
||||||
setIsMuted(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error toggling mute:", error);
|
|
||||||
}
|
|
||||||
}, [previousVolume]);
|
|
||||||
const volumeDownCb = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const { volume: currentVolume } = await VolumeManager.getVolume();
|
|
||||||
const newVolume = Math.max(currentVolume - 0.1, 0); // Decrease by 10%
|
|
||||||
console.log(
|
|
||||||
"Volume Down",
|
|
||||||
Math.round(currentVolume * 100),
|
|
||||||
"→",
|
|
||||||
Math.round(newVolume * 100),
|
|
||||||
);
|
|
||||||
await VolumeManager.setVolume(newVolume);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error adjusting volume:", error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setVolumeCb = useCallback(async (newVolume: number) => {
|
|
||||||
try {
|
|
||||||
const clampedVolume = Math.max(0, Math.min(newVolume, 100));
|
|
||||||
console.log("Setting volume to", clampedVolume);
|
|
||||||
await VolumeManager.setVolume(clampedVolume / 100);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error setting volume:", error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
isPlaying: isPlaying,
|
isPlaying: isPlaying,
|
||||||
togglePlay: togglePlay,
|
togglePlay: togglePlay,
|
||||||
stopPlayback: stop,
|
stopPlayback: stop,
|
||||||
offline,
|
offline,
|
||||||
toggleMute: toggleMuteCb,
|
|
||||||
volumeUp: volumeUpCb,
|
|
||||||
volumeDown: volumeDownCb,
|
|
||||||
setVolume: setVolumeCb,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onPlaybackStateChanged = useCallback(
|
const onPipStarted = useCallback((e: PipStartedPayload) => {
|
||||||
async (e: PlaybackStatePayload) => {
|
const { pipStarted } = e.nativeEvent;
|
||||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
setIsPipStarted(pipStarted);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDiscoveryStateChanged = useCallback((e: OnDiscoveryStateChangedPayload) => {
|
||||||
|
const {renderers} = e.nativeEvent;
|
||||||
|
setRendererItems(renderers);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startDiscovery = useCallback(async () => {
|
||||||
|
videoRef?.current?.pause?.()
|
||||||
|
videoRef?.current?.stopDiscovery?.()
|
||||||
|
videoRef?.current?.startDiscovery?.()
|
||||||
|
discoveryModal?.current?.present?.()
|
||||||
|
}, [rendererItems, videoRef])
|
||||||
|
|
||||||
|
const onPlaybackStateChanged = useCallback(async (e: PlaybackStatePayload) => {
|
||||||
|
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||||
|
|
||||||
if (state === "Playing") {
|
if (state === "Playing") {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
reportPlaybackProgress();
|
await changePlaybackState(true);
|
||||||
await activateKeepAwakeAsync();
|
if (!Platform.isTV) await activateKeepAwakeAsync();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === "Paused") {
|
if (state === "Paused") {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
reportPlaybackProgress();
|
await changePlaybackState(false);
|
||||||
await deactivateKeepAwake();
|
if (!Platform.isTV) await deactivateKeepAwake();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,19 +342,16 @@ export default function page() {
|
|||||||
setIsBuffering(true);
|
setIsBuffering(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[reportPlaybackProgress],
|
[changePlaybackState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const allAudio =
|
const allAudio = stream?.mediaSource.MediaStreams?.filter((audio) => audio.Type === "Audio") || [];
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
|
||||||
(audio) => audio.Type === "Audio",
|
|
||||||
) || [];
|
|
||||||
|
|
||||||
// Move all the external subtitles last, because vlc places them last.
|
// Move all the external subtitles last, because vlc places them last.
|
||||||
const allSubs =
|
const allSubs =
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
stream?.mediaSource.MediaStreams?.filter((sub) => sub.Type === "Subtitle").sort(
|
||||||
(sub) => sub.Type === "Subtitle",
|
(a, b) => Number(a.IsExternal) - Number(b.IsExternal)
|
||||||
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
|
) || [];
|
||||||
|
|
||||||
const externalSubtitles = allSubs
|
const externalSubtitles = allSubs
|
||||||
.filter((sub: any) => sub.DeliveryMethod === "External")
|
.filter((sub: any) => sub.DeliveryMethod === "External")
|
||||||
@@ -475,20 +362,13 @@ export default function page() {
|
|||||||
|
|
||||||
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
||||||
|
|
||||||
const chosenSubtitleTrack = allSubs.find(
|
const chosenSubtitleTrack = allSubs.find((sub) => sub.Index === subtitleIndex);
|
||||||
(sub) => sub.Index === subtitleIndex,
|
|
||||||
);
|
|
||||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||||
|
|
||||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||||
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||||
if (
|
if (chosenSubtitleTrack && (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)) {
|
||||||
chosenSubtitleTrack &&
|
const finalIndex = notTranscoding ? allSubs.indexOf(chosenSubtitleTrack) : textSubs.indexOf(chosenSubtitleTrack);
|
||||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
|
||||||
) {
|
|
||||||
const finalIndex = notTranscoding
|
|
||||||
? allSubs.indexOf(chosenSubtitleTrack)
|
|
||||||
: textSubs.indexOf(chosenSubtitleTrack);
|
|
||||||
initOptions.push(`--sub-track=${finalIndex}`);
|
initOptions.push(`--sub-track=${finalIndex}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,16 +386,16 @@ export default function page() {
|
|||||||
|
|
||||||
if (itemStatus.isLoading || streamStatus.isLoading) {
|
if (itemStatus.isLoading || streamStatus.isLoading) {
|
||||||
return (
|
return (
|
||||||
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (itemStatus.isError || streamStatus.isError)
|
if (!item || !stream || itemStatus.isError || streamStatus.isError)
|
||||||
return (
|
return (
|
||||||
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
<Text className='text-white'>{t("player.error")}</Text>
|
<Text className="text-white">{t("player.error")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -548,48 +428,88 @@ export default function page() {
|
|||||||
progressUpdateInterval={1000}
|
progressUpdateInterval={1000}
|
||||||
onVideoStateChange={onPlaybackStateChanged}
|
onVideoStateChange={onPlaybackStateChanged}
|
||||||
onPipStarted={onPipStarted}
|
onPipStarted={onPipStarted}
|
||||||
|
onDiscoveryStateChanged={onDiscoveryStateChanged}
|
||||||
onVideoLoadEnd={() => {
|
onVideoLoadEnd={() => {
|
||||||
setIsVideoLoaded(true);
|
setIsVideoLoaded(true);
|
||||||
}}
|
}}
|
||||||
onVideoError={(e) => {
|
onVideoError={(e) => {
|
||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
Alert.alert(
|
Alert.alert(t("player.error"), t("player.an_error_occured_while_playing_the_video"));
|
||||||
t("player.error"),
|
|
||||||
t("player.an_error_occured_while_playing_the_video"),
|
|
||||||
);
|
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{videoRef.current && !isPipStarted && isMounted === true && item ? (
|
{videoRef.current && !isPipStarted && isMounted === true ? (
|
||||||
<Controls
|
<>
|
||||||
mediaSource={stream?.mediaSource}
|
<Controls
|
||||||
item={item}
|
mediaSource={stream?.mediaSource}
|
||||||
videoRef={videoRef}
|
item={item}
|
||||||
togglePlay={togglePlay}
|
videoRef={videoRef}
|
||||||
isPlaying={isPlaying}
|
togglePlay={togglePlay}
|
||||||
isSeeking={isSeeking}
|
isPlaying={isPlaying}
|
||||||
progress={progress}
|
isSeeking={isSeeking}
|
||||||
cacheProgress={cacheProgress}
|
progress={progress}
|
||||||
isBuffering={isBuffering}
|
cacheProgress={cacheProgress}
|
||||||
showControls={showControls}
|
isBuffering={isBuffering}
|
||||||
setShowControls={setShowControls}
|
showControls={showControls}
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
setShowControls={setShowControls}
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||||
isVideoLoaded={isVideoLoaded}
|
ignoreSafeAreas={ignoreSafeAreas}
|
||||||
startPictureInPicture={videoRef?.current?.startPictureInPicture}
|
isVideoLoaded={isVideoLoaded}
|
||||||
play={videoRef.current?.play}
|
startPictureInPicture={videoRef?.current?.startPictureInPicture}
|
||||||
pause={videoRef.current?.pause}
|
play={videoRef.current?.play}
|
||||||
seek={videoRef.current?.seekTo}
|
pause={videoRef.current?.pause}
|
||||||
enableTrickplay={true}
|
seek={videoRef.current?.seekTo}
|
||||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
enableTrickplay={true}
|
||||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
getAudioTracks={videoRef.current?.getAudioTracks}
|
||||||
offline={offline}
|
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
||||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
offline={offline}
|
||||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
||||||
setAudioTrack={videoRef.current.setAudioTrack}
|
setSubtitleURL={videoRef.current.setSubtitleURL}
|
||||||
isVlc
|
setAudioTrack={videoRef.current.setAudioTrack}
|
||||||
/>
|
startDiscovery={startDiscovery}
|
||||||
|
isVlc
|
||||||
|
/>
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={discoveryModal}
|
||||||
|
enableDynamicSizing
|
||||||
|
enableDismissOnClose
|
||||||
|
snapPoints={["100%"]}
|
||||||
|
onDismiss={() => {
|
||||||
|
videoRef.current?.stopDiscovery?.()
|
||||||
|
videoRef.current?.play?.()
|
||||||
|
}}
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
backdropComponent={(sheetProps: BottomSheetBackdropProps) =>
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...sheetProps}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BottomSheetView>
|
||||||
|
<ListGroup title={t("player.device_discovery")} className="mt-4 h-1/3">
|
||||||
|
{rendererItems.map((renderItem, index) => (
|
||||||
|
<ListItem
|
||||||
|
onPress={() => {
|
||||||
|
// todo: set renderer item on player to change to device
|
||||||
|
}}
|
||||||
|
icon="cast"
|
||||||
|
title={renderItem.name}
|
||||||
|
key={index}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ScrollViewStyleReset } from "expo-router/html";
|
import { ScrollViewStyleReset } from "expo-router/html";
|
||||||
import type { PropsWithChildren } from "react";
|
import { type PropsWithChildren } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This file is web-only and used to configure the root HTML for every web page during static rendering.
|
* This file is web-only and used to configure the root HTML for every web page during static rendering.
|
||||||
@@ -7,13 +7,13 @@ import type { PropsWithChildren } from "react";
|
|||||||
*/
|
*/
|
||||||
export default function Root({ children }: PropsWithChildren) {
|
export default function Root({ children }: PropsWithChildren) {
|
||||||
return (
|
return (
|
||||||
<html lang='en'>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charSet='utf-8' />
|
<meta charSet="utf-8" />
|
||||||
<meta httpEquiv='X-UA-Compatible' content='IE=edge' />
|
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<meta
|
<meta
|
||||||
name='viewport'
|
name="viewport"
|
||||||
content='width=device-width, initial-scale=1, shrink-to-fit=no'
|
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ export default function NotFoundScreen() {
|
|||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Oops!" }} />
|
<Stack.Screen options={{ title: "Oops!" }} />
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
<ThemedText type='title'>This screen doesn't exist.</ThemedText>
|
<ThemedText type="title">This screen doesn't exist.</ThemedText>
|
||||||
<Link href={"/home"} style={styles.link}>
|
<Link href={"/home"} style={styles.link}>
|
||||||
<ThemedText type='link'>Go to home screen!</ThemedText>
|
<ThemedText type="link">Go to home screen!</ThemedText>
|
||||||
</Link>
|
</Link>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
</>
|
</>
|
||||||
|
|||||||
283
app/_layout.tsx
@@ -1,12 +1,8 @@
|
|||||||
import "@/augmentations";
|
import "@/augmentations";
|
||||||
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";
|
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 {
|
||||||
apiAtom,
|
|
||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
getTokenFromStorage,
|
getTokenFromStorage,
|
||||||
JellyfinProvider,
|
JellyfinProvider,
|
||||||
@@ -14,58 +10,37 @@ import {
|
|||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
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 { Settings, useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
||||||
BACKGROUND_FETCH_TASK,
|
import { LogProvider, writeToLog } from "@/utils/log";
|
||||||
BACKGROUND_FETCH_TASK_SESSIONS,
|
|
||||||
registerBackgroundFetchAsyncSessions,
|
|
||||||
} from "@/utils/background-tasks";
|
|
||||||
import {
|
|
||||||
LogProvider,
|
|
||||||
writeDebugLog,
|
|
||||||
writeErrorLog,
|
|
||||||
writeToLog,
|
|
||||||
} from "@/utils/log";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||||
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
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
|
||||||
import * as Device from "expo-device";
|
? require("expo-background-fetch")
|
||||||
|
: null;
|
||||||
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 { router, Stack } 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";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
||||||
import * as TaskManager from "expo-task-manager";
|
|
||||||
|
|
||||||
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 } from "react";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
import { Appearance, AppState } 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 { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
|
||||||
import type { EventSubscription } from "expo-modules-core";
|
|
||||||
import type {
|
|
||||||
Notification,
|
|
||||||
NotificationResponse,
|
|
||||||
} from "expo-notifications/build/Notifications.types";
|
|
||||||
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { Toaster } from "sonner-native";
|
import { Toaster } from "sonner-native";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { store } from "@/utils/store";
|
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
@@ -105,13 +80,13 @@ function useNotificationObserver() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redirect(response?.notification);
|
redirect(response?.notification);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const subscription = Notifications.addNotificationResponseReceivedListener(
|
const subscription = Notifications.addNotificationResponseReceivedListener(
|
||||||
(response: { notification: any }) => {
|
(response: { notification: any }) => {
|
||||||
redirect(response.notification);
|
redirect(response.notification);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -122,24 +97,6 @@ function useNotificationObserver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK_SESSIONS, async () => {
|
|
||||||
console.log("TaskManager ~ sessions trigger");
|
|
||||||
|
|
||||||
const api = store.get(apiAtom);
|
|
||||||
if (api === null || api === undefined) {
|
|
||||||
return { value: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getSessionApi(api).getSessions({
|
|
||||||
activeWithinSeconds: 360,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = response.data.filter((s) => s.NowPlayingItem);
|
|
||||||
Notifications.setBadgeCountAsync(result.length);
|
|
||||||
|
|
||||||
return { value: "success" };
|
|
||||||
});
|
|
||||||
|
|
||||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||||
console.log("TaskManager ~ trigger");
|
console.log("TaskManager ~ trigger");
|
||||||
|
|
||||||
@@ -147,18 +104,20 @@ if (!Platform.isTV) {
|
|||||||
|
|
||||||
const settingsData = storage.getString("settings");
|
const settingsData = storage.getString("settings");
|
||||||
|
|
||||||
if (!settingsData) return { value: null };
|
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||||
const url = settings?.optimizedVersionsServerUrl;
|
const url = settings?.optimizedVersionsServerUrl;
|
||||||
|
|
||||||
if (!settings?.autoDownload || !url) return { value: null };
|
if (!settings?.autoDownload || !url)
|
||||||
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const token = getTokenFromStorage();
|
const token = getTokenFromStorage();
|
||||||
const deviceId = getOrSetDeviceId();
|
const deviceId = getOrSetDeviceId();
|
||||||
const baseDirectory = FileSystem.documentDirectory;
|
const baseDirectory = FileSystem.documentDirectory;
|
||||||
|
|
||||||
if (!token || !deviceId || !baseDirectory) return { value: null };
|
if (!token || !deviceId || !baseDirectory)
|
||||||
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const jobs = await getAllJobsByDeviceId({
|
const jobs = await getAllJobsByDeviceId({
|
||||||
deviceId,
|
deviceId,
|
||||||
@@ -168,9 +127,9 @@ if (!Platform.isTV) {
|
|||||||
|
|
||||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
||||||
|
|
||||||
for (const job of jobs) {
|
for (let job of jobs) {
|
||||||
if (job.status === "completed") {
|
if (job.status === "completed") {
|
||||||
const downloadUrl = `${url}download/${job.id}`;
|
const downloadUrl = url + "download/" + job.id;
|
||||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
|
||||||
if (tasks.find((task: { id: string }) => task.id === job.id)) {
|
if (tasks.find((task: { id: string }) => task.id === job.id)) {
|
||||||
@@ -191,7 +150,7 @@ if (!Platform.isTV) {
|
|||||||
})
|
})
|
||||||
.done(() => {
|
.done(() => {
|
||||||
console.log("TaskManager ~ Download completed: ", job.id);
|
console.log("TaskManager ~ Download completed: ", job.id);
|
||||||
_saveDownloadedItemInfo(job.item);
|
saveDownloadedItemInfo(job.item);
|
||||||
BackGroundDownloader.completeHandler(job.id);
|
BackGroundDownloader.completeHandler(job.id);
|
||||||
cancelJobById({
|
cancelJobById({
|
||||||
authHeader: token,
|
authHeader: token,
|
||||||
@@ -203,7 +162,7 @@ if (!Platform.isTV) {
|
|||||||
title: job.item.Name,
|
title: job.item.Name,
|
||||||
body: "Download completed",
|
body: "Download completed",
|
||||||
data: {
|
data: {
|
||||||
url: "/downloads",
|
url: `/downloads`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
trigger: null,
|
trigger: null,
|
||||||
@@ -217,7 +176,7 @@ if (!Platform.isTV) {
|
|||||||
title: job.item.Name,
|
title: job.item.Name,
|
||||||
body: "Download failed",
|
body: "Download failed",
|
||||||
data: {
|
data: {
|
||||||
url: "/downloads",
|
url: `/downloads`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
trigger: null,
|
trigger: null,
|
||||||
@@ -229,14 +188,14 @@ if (!Platform.isTV) {
|
|||||||
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
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 { value: "success" };
|
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkAndRequestPermissions = async () => {
|
const checkAndRequestPermissions = async () => {
|
||||||
try {
|
try {
|
||||||
const hasAskedBefore = storage.getString(
|
const hasAskedBefore = storage.getString(
|
||||||
"hasAskedForNotificationPermission",
|
"hasAskedForNotificationPermission"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasAskedBefore !== "true") {
|
if (hasAskedBefore !== "true") {
|
||||||
@@ -258,7 +217,7 @@ const checkAndRequestPermissions = async () => {
|
|||||||
writeToLog(
|
writeToLog(
|
||||||
"ERROR",
|
"ERROR",
|
||||||
"Error checking/requesting notification permissions:",
|
"Error checking/requesting notification permissions:",
|
||||||
error,
|
error
|
||||||
);
|
);
|
||||||
console.error("Error checking/requesting notification permissions:", error);
|
console.error("Error checking/requesting notification permissions:", error);
|
||||||
}
|
}
|
||||||
@@ -294,176 +253,54 @@ const queryClient = new QueryClient({
|
|||||||
|
|
||||||
function Layout() {
|
function Layout() {
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const appState = useRef(AppState.currentState);
|
const appState = useRef(AppState.currentState);
|
||||||
const segments = useSegments();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
i18n.changeLanguage(
|
i18n.changeLanguage(
|
||||||
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en",
|
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en"
|
||||||
);
|
);
|
||||||
}, [settings?.preferedLanguage, i18n]);
|
}, [settings?.preferedLanguage, i18n]);
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
useNotificationObserver();
|
useNotificationObserver();
|
||||||
|
|
||||||
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>();
|
|
||||||
const notificationListener = useRef<EventSubscription>();
|
|
||||||
const responseListener = useRef<EventSubscription>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (expoPushToken && api && user) {
|
checkAndRequestPermissions();
|
||||||
api
|
|
||||||
?.post("/Streamyfin/device", {
|
|
||||||
token: expoPushToken.data,
|
|
||||||
deviceId: getOrSetDeviceId(),
|
|
||||||
userId: user.Id,
|
|
||||||
})
|
|
||||||
.then((_) => console.log("Posted expo push token"))
|
|
||||||
.catch((_) =>
|
|
||||||
writeErrorLog("Failed to push expo push token to plugin"),
|
|
||||||
);
|
|
||||||
} else console.log("No token available");
|
|
||||||
}, [api, expoPushToken, user]);
|
|
||||||
|
|
||||||
async function registerNotifications() {
|
|
||||||
if (Platform.OS === "android") {
|
|
||||||
console.log("Setting android notification channel 'default'");
|
|
||||||
await Notifications?.setNotificationChannelAsync("default", {
|
|
||||||
name: "default",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await checkAndRequestPermissions();
|
|
||||||
|
|
||||||
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
|
|
||||||
await registerBackgroundFetchAsyncSessions();
|
|
||||||
}
|
|
||||||
|
|
||||||
// only create push token for real devices (pointless for emulators)
|
|
||||||
if (Device.isDevice) {
|
|
||||||
Notifications?.getExpoPushTokenAsync()
|
|
||||||
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
|
|
||||||
.catch((reason: any) => console.log("Failed to get token", reason));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
registerNotifications();
|
|
||||||
|
|
||||||
notificationListener.current =
|
|
||||||
Notifications?.addNotificationReceivedListener(
|
|
||||||
(notification: Notification) => {
|
|
||||||
console.log(
|
|
||||||
"Notification received while app running",
|
|
||||||
notification,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
responseListener.current =
|
|
||||||
Notifications?.addNotificationResponseReceivedListener(
|
|
||||||
(response: NotificationResponse) => {
|
|
||||||
// Currently the notifications supported by the plugin will send data for deep links.
|
|
||||||
const { title, data } = response.notification.request.content;
|
|
||||||
|
|
||||||
writeDebugLog(
|
|
||||||
`Notification ${title} opened`,
|
|
||||||
response.notification.request.content,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (data && Object.keys(data).length > 0) {
|
|
||||||
const type = data?.type?.toLower?.();
|
|
||||||
const itemId = data?.id;
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case "movie":
|
|
||||||
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
|
|
||||||
break;
|
|
||||||
case "episode":
|
|
||||||
// We just clicked a notification for an individual episode.
|
|
||||||
if (itemId) {
|
|
||||||
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
|
|
||||||
}
|
|
||||||
// summarized season notification for multiple episodes. Bring them to series season
|
|
||||||
else {
|
|
||||||
const seriesId = data.seriesId;
|
|
||||||
const seasonIndex = data.seasonIndex;
|
|
||||||
|
|
||||||
if (seasonIndex) {
|
|
||||||
router.push(
|
|
||||||
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
router.push(`/(auth)/(tabs)/home/series/${seriesId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
notificationListener.current &&
|
|
||||||
Notifications?.removeNotificationSubscription(
|
|
||||||
notificationListener.current,
|
|
||||||
);
|
|
||||||
responseListener.current &&
|
|
||||||
Notifications?.removeNotificationSubscription(
|
|
||||||
responseListener.current,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.isTV) {
|
// If the user has auto rotate enabled, unlock the orientation
|
||||||
return;
|
if (Platform.isTV) return;
|
||||||
}
|
if (settings.autoRotate === true) {
|
||||||
|
|
||||||
if (segments.includes("direct-player" as never)) {
|
|
||||||
if (
|
|
||||||
!settings.followDeviceOrientation &&
|
|
||||||
settings.defaultVideoOrientation
|
|
||||||
) {
|
|
||||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.followDeviceOrientation === true) {
|
|
||||||
ScreenOrientation.unlockAsync();
|
ScreenOrientation.unlockAsync();
|
||||||
} else {
|
} else {
|
||||||
|
// If the user has auto rotate disabled, lock the orientation to portrait
|
||||||
ScreenOrientation.lockAsync(
|
ScreenOrientation.lockAsync(
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}, [settings]);
|
||||||
settings.followDeviceOrientation,
|
|
||||||
settings.defaultVideoOrientation,
|
|
||||||
segments,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = AppState.addEventListener(
|
const subscription = AppState.addEventListener(
|
||||||
"change",
|
"change",
|
||||||
(nextAppState) => {
|
(nextAppState) => {
|
||||||
if (
|
if (
|
||||||
appState.current.match(/inactive|background/) &&
|
appState.current.match(/inactive|background/) &&
|
||||||
nextAppState === "active"
|
nextAppState === "active"
|
||||||
) {
|
) {
|
||||||
BackGroundDownloader.checkForExistingDownloads();
|
BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
);
|
||||||
);
|
|
||||||
|
|
||||||
BackGroundDownloader.checkForExistingDownloads();
|
BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
subscription.remove();
|
subscription.remove();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -474,11 +311,11 @@ function Layout() {
|
|||||||
<WebSocketProvider>
|
<WebSocketProvider>
|
||||||
<DownloadProvider>
|
<DownloadProvider>
|
||||||
<BottomSheetModalProvider>
|
<BottomSheetModalProvider>
|
||||||
<SystemBars style='light' hidden={false} />
|
<SystemBars style="light" hidden={false} />
|
||||||
<ThemeProvider value={DarkTheme}>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack 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: "",
|
||||||
@@ -486,7 +323,7 @@ function Layout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='(auth)/player'
|
name="(auth)/player"
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
title: "",
|
title: "",
|
||||||
@@ -494,14 +331,14 @@ function Layout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='login'
|
name="login"
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
title: "",
|
title: "",
|
||||||
headerTransparent: true,
|
headerTransparent: true,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name='+not-found' />
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Toaster
|
<Toaster
|
||||||
duration={4000}
|
duration={4000}
|
||||||
@@ -529,10 +366,10 @@ function Layout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _saveDownloadedItemInfo(item: BaseItemDto) {
|
function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||||
try {
|
try {
|
||||||
const downloadedItems = storage.getString("downloadedItems");
|
const downloadedItems = storage.getString("downloadedItems");
|
||||||
const items: BaseItemDto[] = downloadedItems
|
let items: BaseItemDto[] = downloadedItems
|
||||||
? JSON.parse(downloadedItems)
|
? JSON.parse(downloadedItems)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
|||||||
290
app/login.tsx
@@ -1,21 +1,3 @@
|
|||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
|
||||||
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import type React from "react";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Keyboard,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
SafeAreaView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -23,7 +5,24 @@ import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
|||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
SafeAreaView,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Keyboard } from "react-native";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { t } from "i18next";
|
||||||
const CredentialsSchema = z.object({
|
const CredentialsSchema = z.object({
|
||||||
username: z.string().min(1, t("login.username_required")),
|
username: z.string().min(1, t("login.username_required")),
|
||||||
});
|
});
|
||||||
@@ -59,7 +58,7 @@ const Login: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (_apiUrl) {
|
if (_apiUrl) {
|
||||||
await setServer({
|
setServer({
|
||||||
address: _apiUrl,
|
address: _apiUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,10 +81,10 @@ const Login: React.FC = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
removeServer();
|
removeServer();
|
||||||
}}
|
}}
|
||||||
className='flex flex-row items-center'
|
className="flex flex-row items-center"
|
||||||
>
|
>
|
||||||
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
|
||||||
<Text className='ml-2 text-purple-600'>
|
<Text className="ml-2 text-purple-600">
|
||||||
{t("login.change_server")}
|
{t("login.change_server")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -108,7 +107,7 @@ const Login: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occured"),
|
t("login.an_unexpected_error_occured")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -177,12 +176,12 @@ const Login: React.FC = () => {
|
|||||||
if (result === undefined) {
|
if (result === undefined) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.could_not_connect_to_server"),
|
t("login.could_not_connect_to_server")
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await setServer({ address: url });
|
setServer({ address: url });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleQuickConnect = async () => {
|
const handleQuickConnect = async () => {
|
||||||
@@ -196,13 +195,13 @@ const Login: React.FC = () => {
|
|||||||
{
|
{
|
||||||
text: t("login.got_it"),
|
text: t("login.got_it"),
|
||||||
},
|
},
|
||||||
],
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} 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,127 +212,134 @@ 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='px-4 -mt-20 w-full'>
|
<View className="flex flex-col h-full relative items-center justify-center">
|
||||||
<View className='flex flex-col space-y-2'>
|
<View className="px-4 -mt-20 w-full">
|
||||||
<Text className='text-2xl font-bold -mb-2'>
|
<View className="flex flex-col space-y-2">
|
||||||
{serverName ? (
|
<Text className="text-2xl font-bold -mb-2">
|
||||||
<>
|
<>
|
||||||
{`${t("login.login_to_title")} `}
|
{serverName ? (
|
||||||
<Text className='text-purple-600'>{serverName}</Text>
|
<>
|
||||||
|
{t("login.login_to_title") + " "}
|
||||||
|
<Text className="text-purple-600">{serverName}</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("login.login_title")
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
</Text>
|
||||||
t("login.login_title")
|
<Text className="text-xs text-neutral-400">
|
||||||
)}
|
{api.basePath}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='text-xs text-neutral-400'>{api.basePath}</Text>
|
<Input
|
||||||
<Input
|
placeholder={t("login.username_placeholder")}
|
||||||
placeholder={t("login.username_placeholder")}
|
onChangeText={(text) =>
|
||||||
onChangeText={(text) =>
|
setCredentials({ ...credentials, username: text })
|
||||||
setCredentials({ ...credentials, username: text })
|
}
|
||||||
}
|
value={credentials.username}
|
||||||
value={credentials.username}
|
secureTextEntry={false}
|
||||||
keyboardType='default'
|
keyboardType="default"
|
||||||
returnKeyType='done'
|
returnKeyType="done"
|
||||||
autoCapitalize='none'
|
autoCapitalize="none"
|
||||||
// Changed from username to oneTimeCode because it is a known issue in RN
|
textContentType="username"
|
||||||
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
|
clearButtonMode="while-editing"
|
||||||
textContentType='oneTimeCode'
|
maxLength={500}
|
||||||
clearButtonMode='while-editing'
|
/>
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("login.password_placeholder")}
|
placeholder={t("login.password_placeholder")}
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials({ ...credentials, password: text })
|
setCredentials({ ...credentials, password: text })
|
||||||
}
|
}
|
||||||
value={credentials.password}
|
value={credentials.password}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
keyboardType='default'
|
keyboardType="default"
|
||||||
returnKeyType='done'
|
returnKeyType="done"
|
||||||
autoCapitalize='none'
|
autoCapitalize="none"
|
||||||
textContentType='password'
|
textContentType="password"
|
||||||
clearButtonMode='while-editing'
|
clearButtonMode="while-editing"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
<View className='flex flex-row items-center justify-between'>
|
<View className="flex flex-row items-center justify-between">
|
||||||
<Button
|
<Button
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
className='flex-1 mr-2'
|
className="flex-1 mr-2"
|
||||||
>
|
>
|
||||||
{t("login.login_button")}
|
{t("login.login_button")}
|
||||||
</Button>
|
</Button>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handleQuickConnect}
|
onPress={handleQuickConnect}
|
||||||
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
|
className="p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name='cellphone-lock'
|
name="cellphone-lock"
|
||||||
size={24}
|
size={24}
|
||||||
color='white'
|
color="white"
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
<View className="absolute bottom-0 left-0 w-full px-4 mb-2"></View>
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View className='flex flex-col h-full items-center justify-center w-full'>
|
|
||||||
<View className='flex flex-col 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>
|
|
||||||
<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>
|
||||||
</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>
|
||||||
|
<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={(server) => {
|
||||||
|
setServerURL(server.address);
|
||||||
|
if (server.serverName) {
|
||||||
|
setServerName(server.serverName);
|
||||||
|
}
|
||||||
|
handleConnect(server.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PreviousServersList
|
||||||
|
onServerSelect={(s) => {
|
||||||
|
handleConnect(s.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 305 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 22 KiB |
@@ -1,21 +1,17 @@
|
|||||||
import { Api, AUTHORIZATION_HEADER } from "@jellyfin/sdk";
|
import { Api, AUTHORIZATION_HEADER } from "@jellyfin/sdk";
|
||||||
import type { AxiosRequestConfig, AxiosResponse } from "axios";
|
import { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||||
import type { StreamyfinPluginConfig } from "@/utils/atoms/settings";
|
import { StreamyfinPluginConfig } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
declare module "@jellyfin/sdk" {
|
declare module "@jellyfin/sdk" {
|
||||||
interface Api {
|
interface Api {
|
||||||
get<T, D = any>(
|
get<T, D = any>(
|
||||||
url: string,
|
url: string,
|
||||||
config?: AxiosRequestConfig<D>,
|
config?: AxiosRequestConfig<D>
|
||||||
): Promise<AxiosResponse<T>>;
|
): Promise<AxiosResponse<T>>;
|
||||||
post<T, D = any>(
|
post<T, D = any>(
|
||||||
url: string,
|
url: string,
|
||||||
data: D,
|
data: D,
|
||||||
config?: AxiosRequestConfig<D>,
|
config?: AxiosRequestConfig<D>
|
||||||
): Promise<AxiosResponse<T>>;
|
|
||||||
delete<T, D = any>(
|
|
||||||
url: string,
|
|
||||||
config?: AxiosRequestConfig<D>,
|
|
||||||
): Promise<AxiosResponse<T>>;
|
): Promise<AxiosResponse<T>>;
|
||||||
getStreamyfinPluginConfig(): Promise<AxiosResponse<StreamyfinPluginConfig>>;
|
getStreamyfinPluginConfig(): Promise<AxiosResponse<StreamyfinPluginConfig>>;
|
||||||
}
|
}
|
||||||
@@ -23,7 +19,7 @@ declare module "@jellyfin/sdk" {
|
|||||||
|
|
||||||
Api.prototype.get = function <T, D = any>(
|
Api.prototype.get = function <T, D = any>(
|
||||||
url: string,
|
url: string,
|
||||||
config: AxiosRequestConfig<D> = {},
|
config: AxiosRequestConfig<D> = {}
|
||||||
): Promise<AxiosResponse<T>> {
|
): Promise<AxiosResponse<T>> {
|
||||||
return this.axiosInstance.get<T>(`${this.basePath}${url}`, {
|
return this.axiosInstance.get<T>(`${this.basePath}${url}`, {
|
||||||
...(config ?? {}),
|
...(config ?? {}),
|
||||||
@@ -34,20 +30,11 @@ Api.prototype.get = function <T, D = any>(
|
|||||||
Api.prototype.post = function <T, D = any>(
|
Api.prototype.post = function <T, D = any>(
|
||||||
url: string,
|
url: string,
|
||||||
data: D,
|
data: D,
|
||||||
config: AxiosRequestConfig<D>,
|
config: AxiosRequestConfig<D>
|
||||||
): Promise<AxiosResponse<T>> {
|
): Promise<AxiosResponse<T>> {
|
||||||
return this.axiosInstance.post<T>(`${this.basePath}${url}`, data, {
|
return this.axiosInstance.post<T>(`${this.basePath}${url}`, {
|
||||||
...(config || {}),
|
|
||||||
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
Api.prototype.delete = function <T, D = any>(
|
|
||||||
url: string,
|
|
||||||
config: AxiosRequestConfig<D>,
|
|
||||||
): Promise<AxiosResponse<T>> {
|
|
||||||
return this.axiosInstance.delete<T>(`${this.basePath}${url}`, {
|
|
||||||
...(config || {}),
|
...(config || {}),
|
||||||
|
data,
|
||||||
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
|
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
import { MMKV } from "react-native-mmkv";
|
import {MMKV} from "react-native-mmkv";
|
||||||
|
|
||||||
declare module "react-native-mmkv" {
|
declare module "react-native-mmkv" {
|
||||||
interface MMKV {
|
interface MMKV {
|
||||||
get<T>(key: string): T | undefined;
|
get<T>(key: string): T | undefined
|
||||||
setAny(key: string, value: any | undefined): void;
|
setAny(key: string, value: any | undefined): void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MMKV.prototype.get = function <T>(key: string): T | undefined {
|
MMKV.prototype.get = function <T> (key: string): T | undefined {
|
||||||
const serializedItem = this.getString(key);
|
const serializedItem = this.getString(key);
|
||||||
return serializedItem ? JSON.parse(serializedItem) : undefined;
|
return serializedItem ? JSON.parse(serializedItem) : undefined;
|
||||||
};
|
}
|
||||||
|
|
||||||
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
this.delete(key);
|
this.delete(key)
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
this.set(key, JSON.stringify(value));
|
this.set(key, JSON.stringify(value));
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
@@ -7,17 +7,17 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Number.prototype.bytesToReadable = function (decimals = 2) {
|
Number.prototype.bytesToReadable = function (decimals: number = 2) {
|
||||||
const bytes = this.valueOf();
|
const bytes = this.valueOf();
|
||||||
if (bytes === 0) return "0 Bytes";
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const dm = decimals < 0 ? 0 : decimals;
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||||
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
Number.prototype.secondsToMilliseconds = function () {
|
Number.prototype.secondsToMilliseconds = function () {
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String.prototype.toTitle = function () {
|
String.prototype.toTitle = function () {
|
||||||
return this.replaceAll("_", " ").replace(
|
return this
|
||||||
/\w\S*/g,
|
.replaceAll("_", " ")
|
||||||
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
.replace(
|
||||||
);
|
/\w\S*/g,
|
||||||
};
|
text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = (api) => {
|
module.exports = function (api) {
|
||||||
api.cache(true);
|
api.cache(true);
|
||||||
return {
|
return {
|
||||||
presets: ["babel-preset-expo"],
|
presets: ["babel-preset-expo"],
|
||||||
|
|||||||
59
biome.json
@@ -1,59 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://biomejs.dev/schemas/2.0.5/schema.json",
|
|
||||||
"files": {
|
|
||||||
"includes": [
|
|
||||||
"**/*",
|
|
||||||
"!node_modules/**",
|
|
||||||
"!ios/**",
|
|
||||||
"!android/**",
|
|
||||||
"!Streamyfin.app/**",
|
|
||||||
"!utils/jellyseerr/**",
|
|
||||||
"!.expo/**"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"linter": {
|
|
||||||
"enabled": true,
|
|
||||||
"rules": {
|
|
||||||
"style": {
|
|
||||||
"useImportType": "off",
|
|
||||||
"noNonNullAssertion": "off",
|
|
||||||
"noParameterAssign": "off",
|
|
||||||
"useLiteralEnumMembers": "off"
|
|
||||||
},
|
|
||||||
"complexity": {
|
|
||||||
"noForEach": "off"
|
|
||||||
},
|
|
||||||
"recommended": true,
|
|
||||||
"correctness": { "useExhaustiveDependencies": "off" },
|
|
||||||
"suspicious": {
|
|
||||||
"noExplicitAny": "off",
|
|
||||||
"noArrayIndexKey": "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"formatter": {
|
|
||||||
"enabled": true,
|
|
||||||
"formatWithErrors": true,
|
|
||||||
"attributePosition": "auto",
|
|
||||||
"indentStyle": "space",
|
|
||||||
"indentWidth": 2,
|
|
||||||
"lineEnding": "lf",
|
|
||||||
"lineWidth": 80
|
|
||||||
},
|
|
||||||
"javascript": {
|
|
||||||
"formatter": {
|
|
||||||
"arrowParentheses": "always",
|
|
||||||
"bracketSameLine": false,
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"jsxQuoteStyle": "single",
|
|
||||||
"quoteProperties": "asNeeded",
|
|
||||||
"semicolons": "always",
|
|
||||||
"lineWidth": 80
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"json": {
|
|
||||||
"formatter": {
|
|
||||||
"trailingCommas": "none"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import type { FC } from "react";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { View, type ViewProps } from "react-native";
|
|
||||||
import { RoundButton } from "@/components/RoundButton";
|
|
||||||
import { useFavorite } from "@/hooks/useFavorite";
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
export const AddToFavorites = ({ item, ...props }) => {
|
||||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
const { isFavorite, toggleFavorite, _} = useFavorite(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<RoundButton
|
<RoundButton
|
||||||
size='large'
|
size="large"
|
||||||
icon={isFavorite ? "heart" : "heart-outline"}
|
icon={isFavorite ? "heart" : "heart-outline"}
|
||||||
fillColor={isFavorite ? "primary" : undefined}
|
fillColor={isFavorite ? "primary" : undefined}
|
||||||
onPress={toggleFavorite}
|
onPress={toggleFavorite}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
const DropdownMenu = require("zeego/dropdown-menu");
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
@@ -19,33 +17,34 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
[source],
|
[source]
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedAudioSteam = useMemo(
|
const selectedAudioSteam = useMemo(
|
||||||
() => audioStreams?.find((x) => x.Index === selected),
|
() => audioStreams?.find((x) => x.Index === selected),
|
||||||
[audioStreams, selected],
|
[audioStreams, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className='flex shrink'
|
className="flex shrink"
|
||||||
style={{
|
style={{
|
||||||
minWidth: 50,
|
minWidth: 50,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className='flex flex-col' {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
<Text className="opacity-50 mb-1 text-xs">
|
||||||
{t("item_card.audio")}
|
{t("item_card.audio")}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<Text className='' numberOfLines={1}>
|
<Text className="" numberOfLines={1}>
|
||||||
{selectedAudioSteam?.DisplayTitle}
|
{selectedAudioSteam?.DisplayTitle}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -53,8 +52,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
loop={true}
|
loop={true}
|
||||||
side='bottom'
|
side="bottom"
|
||||||
align='start'
|
align="start"
|
||||||
alignOffset={0}
|
alignOffset={0}
|
||||||
avoidCollisions={true}
|
avoidCollisions={true}
|
||||||
collisionPadding={8}
|
collisionPadding={8}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { View, type ViewProps } from "react-native";
|
import { View, ViewProps } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -22,7 +22,7 @@ export const Badge: React.FC<Props> = ({
|
|||||||
${variant === "gray" && "bg-neutral-800"}
|
${variant === "gray" && "bg-neutral-800"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{iconLeft && <View className='mr-1'>{iconLeft}</View>}
|
{iconLeft && <View className="mr-1">{iconLeft}</View>}
|
||||||
<Text
|
<Text
|
||||||
className={`
|
className={`
|
||||||
text-xs
|
text-xs
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
const DropdownMenu = require("zeego/dropdown-menu");
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text } from "./common/Text";
|
|
||||||
|
|
||||||
export type Bitrate = {
|
export type Bitrate = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -42,11 +40,7 @@ export const BITRATES: Bitrate[] = [
|
|||||||
key: "250 Kb/s",
|
key: "250 Kb/s",
|
||||||
value: 250000,
|
value: 250000,
|
||||||
},
|
},
|
||||||
].sort(
|
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
|
||||||
(a, b) =>
|
|
||||||
(b.value || Number.POSITIVE_INFINITY) -
|
|
||||||
(a.value || Number.POSITIVE_INFINITY),
|
|
||||||
);
|
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
onChange: (value: Bitrate) => void;
|
onChange: (value: Bitrate) => void;
|
||||||
@@ -60,17 +54,14 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
inverted,
|
inverted,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (inverted)
|
if (inverted)
|
||||||
return BITRATES.sort(
|
return BITRATES.sort(
|
||||||
(a, b) =>
|
(a, b) => (a.value || Infinity) - (b.value || Infinity)
|
||||||
(a.value || Number.POSITIVE_INFINITY) -
|
|
||||||
(b.value || Number.POSITIVE_INFINITY),
|
|
||||||
);
|
);
|
||||||
return BITRATES.sort(
|
return BITRATES.sort(
|
||||||
(a, b) =>
|
(a, b) => (b.value || Infinity) - (a.value || Infinity)
|
||||||
(b.value || Number.POSITIVE_INFINITY) -
|
|
||||||
(a.value || Number.POSITIVE_INFINITY),
|
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -78,7 +69,7 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className='flex shrink'
|
className="flex shrink"
|
||||||
style={{
|
style={{
|
||||||
minWidth: 60,
|
minWidth: 60,
|
||||||
maxWidth: 200,
|
maxWidth: 200,
|
||||||
@@ -86,12 +77,12 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className='flex flex-col' {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
<Text className="opacity-50 mb-1 text-xs">
|
||||||
{t("item_card.quality")}
|
{t("item_card.quality")}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<Text style={{}} className='' numberOfLines={1}>
|
<Text style={{}} className="" numberOfLines={1}>
|
||||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -99,8 +90,8 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
loop={false}
|
loop={false}
|
||||||
side='bottom'
|
side="bottom"
|
||||||
align='center'
|
align="center"
|
||||||
alignOffset={0}
|
alignOffset={0}
|
||||||
avoidCollisions={true}
|
avoidCollisions={true}
|
||||||
collisionPadding={0}
|
collisionPadding={0}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type React from "react";
|
|
||||||
import { type PropsWithChildren, type ReactNode, useMemo } from "react";
|
|
||||||
import { Text, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
||||||
|
import { Platform, Text, TouchableOpacity, View } from "react-native";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
@@ -64,7 +63,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<View className='p-0.5'>
|
<View className="p-0.5">
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
@@ -73,7 +72,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
flex flex-row items-center justify-between w-full
|
flex flex-row items-center justify-between w-full
|
||||||
${justify === "between" ? "justify-between" : "justify-center"}`}
|
${justify === "between" ? "justify-between" : "justify-center"}`}
|
||||||
>
|
>
|
||||||
{iconLeft ? iconLeft : <View className='w-4' />}
|
{iconLeft ? iconLeft : <View className="w-4"></View>}
|
||||||
<Text
|
<Text
|
||||||
className={`
|
className={`
|
||||||
text-white font-bold text-base
|
text-white font-bold text-base
|
||||||
@@ -85,7 +84,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
{iconRight ? iconRight : <View className='w-4' />}
|
{iconRight ? iconRight : <View className="w-4"></View>}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { useCallback, useEffect } from "react";
|
import React, { useCallback, useEffect } from "react";
|
||||||
import { Platform, type ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
CastButton,
|
CastButton,
|
||||||
CastContext,
|
CastContext,
|
||||||
@@ -45,18 +45,18 @@ export function Chromecast({
|
|||||||
const AndroidCastButton = useCallback(
|
const AndroidCastButton = useCallback(
|
||||||
() =>
|
() =>
|
||||||
Platform.OS === "android" ? (
|
Platform.OS === "android" ? (
|
||||||
<CastButton tintColor='transparent' />
|
<CastButton tintColor="transparent" />
|
||||||
) : (
|
) : (
|
||||||
<></>
|
<></>
|
||||||
),
|
),
|
||||||
[Platform.OS],
|
[Platform.OS]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (background === "transparent")
|
if (background === "transparent")
|
||||||
return (
|
return (
|
||||||
<RoundButton
|
<RoundButton
|
||||||
size='large'
|
size="large"
|
||||||
className='mr-2'
|
className="mr-2"
|
||||||
background={false}
|
background={false}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
@@ -65,13 +65,13 @@ export function Chromecast({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<AndroidCastButton />
|
<AndroidCastButton />
|
||||||
<Feather name='cast' size={22} color={"white"} />
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
</RoundButton>
|
</RoundButton>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RoundButton
|
<RoundButton
|
||||||
size='large'
|
size="large"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
else CastContext.showCastDialog();
|
else CastContext.showCastDialog();
|
||||||
@@ -79,7 +79,7 @@ export function Chromecast({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<AndroidCastButton />
|
<AndroidCastButton />
|
||||||
<Feather name='cast' size={22} color={"white"} />
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
</RoundButton>
|
</RoundButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
0
components/Chromecast.tv.tsx
Normal file
0
components/ContextMenu.tv.ts
Normal file
@@ -1,12 +1,12 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { 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 type React from "react";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { WatchedIndicator } from "./WatchedIndicator";
|
import { WatchedIndicator } from "./WatchedIndicator";
|
||||||
|
import React from "react";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
type ContinueWatchingPosterProps = {
|
type ContinueWatchingPosterProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -27,39 +27,33 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
* Get horizontal poster for movie and episode, with failover to primary.
|
* Get horizontal poster for movie and episode, with failover to primary.
|
||||||
*/
|
*/
|
||||||
const url = useMemo(() => {
|
const url = useMemo(() => {
|
||||||
if (!api) {
|
if (!api) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (item.Type === "Episode" && useEpisodePoster) {
|
if (item.Type === "Episode" && useEpisodePoster) {
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}
|
}
|
||||||
if (item.Type === "Episode") {
|
if (item.Type === "Episode") {
|
||||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
if (item.ParentBackdropItemId && item.ParentThumbImageTag)
|
||||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
}
|
else
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
|
||||||
}
|
}
|
||||||
if (item.Type === "Movie") {
|
if (item.Type === "Movie") {
|
||||||
if (item.ImageTags?.Thumb) {
|
if (item.ImageTags?.["Thumb"])
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.Thumb}`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
|
||||||
}
|
else
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
|
||||||
}
|
}
|
||||||
if (item.Type === "Program") {
|
if (item.Type === "Program") {
|
||||||
if (item.ImageTags?.Thumb) {
|
if (item.ImageTags?.["Thumb"])
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.Thumb}`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
|
||||||
}
|
else
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.ImageTags?.["Thumb"])
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
|
||||||
|
else
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}
|
|
||||||
|
|
||||||
if (item.ImageTags?.Thumb) {
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.Thumb}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
const progress = useMemo(() => {
|
const progress = useMemo(() => {
|
||||||
@@ -70,12 +64,15 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
const total = endDate.getTime() - startDate.getTime();
|
const total = endDate.getTime() - startDate.getTime();
|
||||||
const elapsed = now.getTime() - startDate.getTime();
|
const elapsed = now.getTime() - startDate.getTime();
|
||||||
return (elapsed / total) * 100;
|
return (elapsed / total) * 100;
|
||||||
|
} else {
|
||||||
|
return item.UserData?.PlayedPercentage || 0;
|
||||||
}
|
}
|
||||||
return item.UserData?.PlayedPercentage || 0;
|
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
if (!url)
|
if (!url)
|
||||||
return <View className='aspect-video border border-neutral-800 w-44' />;
|
return (
|
||||||
|
<View className="aspect-video border border-neutral-800 w-44"></View>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -84,7 +81,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
${size === "small" ? "w-32" : "w-44"}
|
${size === "small" ? "w-32" : "w-44"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<View className='w-full h-full flex items-center justify-center'>
|
<View className="w-full h-full flex items-center justify-center">
|
||||||
<Image
|
<Image
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
id={item.Id}
|
id={item.Id}
|
||||||
@@ -92,12 +89,12 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
uri: url,
|
uri: url,
|
||||||
}}
|
}}
|
||||||
cachePolicy={"memory-disk"}
|
cachePolicy={"memory-disk"}
|
||||||
contentFit='cover'
|
contentFit="cover"
|
||||||
className='w-full h-full'
|
className="w-full h-full"
|
||||||
/>
|
/>
|
||||||
{showPlayButton && (
|
{showPlayButton && (
|
||||||
<View className='absolute inset-0 flex items-center justify-center'>
|
<View className="absolute inset-0 flex items-center justify-center">
|
||||||
<Ionicons name='play-circle' size={40} color='white' />
|
<Ionicons name="play-circle" size={40} color="white" />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -105,16 +102,14 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
{progress > 0 && (
|
{progress > 0 && (
|
||||||
<>
|
<>
|
||||||
<View
|
<View
|
||||||
className={
|
className={`absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full`}
|
||||||
"absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full"
|
></View>
|
||||||
}
|
|
||||||
/>
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: `${progress}%`,
|
width: `${progress}%`,
|
||||||
}}
|
}}
|
||||||
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
|
className={`absolute bottom-0 left-0 h-1 bg-purple-600 w-full`}
|
||||||
/>
|
></View>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,31 +1,30 @@
|
|||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import type {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { type Href, router, useFocusEffect } from "expo-router";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import type React from "react";
|
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
|
||||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
||||||
import download from "@/utils/profiles/download";
|
import download from "@/utils/profiles/download";
|
||||||
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Href, router, useFocusEffect } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { Alert, Platform, View, ViewProps } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||||
import { type Bitrate, BitrateSelector } from "./BitrateSelector";
|
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
@@ -33,6 +32,7 @@ import { MediaSourceSelector } from "./MediaSourceSelector";
|
|||||||
import ProgressCircle from "./ProgressCircle";
|
import ProgressCircle from "./ProgressCircle";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||||
|
import { t } from "i18next";
|
||||||
|
|
||||||
interface DownloadProps extends ViewProps {
|
interface DownloadProps extends ViewProps {
|
||||||
items: BaseItemDto[];
|
items: BaseItemDto[];
|
||||||
@@ -58,7 +58,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
||||||
//const { startRemuxing } = useRemuxHlsToMp4();
|
const { startRemuxing } = useRemuxHlsToMp4();
|
||||||
|
|
||||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||||
MediaSourceInfo | undefined | null
|
MediaSourceInfo | undefined | null
|
||||||
@@ -70,16 +70,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
settings?.defaultBitrate ?? {
|
settings?.defaultBitrate ?? {
|
||||||
key: "Max",
|
key: "Max",
|
||||||
value: undefined,
|
value: undefined,
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const userCanDownload = useMemo(
|
const userCanDownload = useMemo(
|
||||||
() => user?.Policy?.EnableContentDownloading,
|
() => user?.Policy?.EnableContentDownloading,
|
||||||
[user],
|
[user]
|
||||||
);
|
);
|
||||||
const usingOptimizedServer = useMemo(
|
const usingOptimizedServer = useMemo(
|
||||||
() => settings?.downloadMethod === DownloadMethod.Optimized,
|
() => settings?.downloadMethod === DownloadMethod.Optimized,
|
||||||
[settings],
|
[settings]
|
||||||
);
|
);
|
||||||
|
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
@@ -88,7 +88,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();
|
||||||
@@ -99,7 +99,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
const itemsNotDownloaded = useMemo(
|
const itemsNotDownloaded = useMemo(
|
||||||
() =>
|
() =>
|
||||||
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
|
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
|
||||||
[items, downloadedFiles],
|
[items, downloadedFiles]
|
||||||
);
|
);
|
||||||
|
|
||||||
const allItemsDownloaded = useMemo(() => {
|
const allItemsDownloaded = useMemo(() => {
|
||||||
@@ -108,11 +108,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
}, [items, itemsNotDownloaded]);
|
}, [items, itemsNotDownloaded]);
|
||||||
const itemsProcesses = useMemo(
|
const itemsProcesses = useMemo(
|
||||||
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
|
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
|
||||||
[processes, itemIds],
|
[processes, itemIds]
|
||||||
);
|
);
|
||||||
|
|
||||||
const progress = useMemo(() => {
|
const progress = useMemo(() => {
|
||||||
if (itemIds.length === 1)
|
if (itemIds.length == 1)
|
||||||
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
|
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
|
||||||
return (
|
return (
|
||||||
((itemIds.length -
|
((itemIds.length -
|
||||||
@@ -125,7 +125,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
const itemsQueued = useMemo(() => {
|
const itemsQueued = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
itemsNotDownloaded.length > 0 &&
|
itemsNotDownloaded.length > 0 &&
|
||||||
itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id))
|
itemsNotDownloaded.every((p) => queue.some((q) => p.Id == q.item.Id))
|
||||||
);
|
);
|
||||||
}, [queue, itemsNotDownloaded]);
|
}, [queue, itemsNotDownloaded]);
|
||||||
const navigateToDownloads = () => router.push("/downloads");
|
const navigateToDownloads = () => router.push("/downloads");
|
||||||
@@ -140,7 +140,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
params: {
|
params: {
|
||||||
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
||||||
},
|
},
|
||||||
} as Href),
|
} as Href)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -151,10 +151,21 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
}
|
}
|
||||||
closeModal();
|
closeModal();
|
||||||
|
|
||||||
initiateDownload(...itemsNotDownloaded);
|
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
|
||||||
|
else {
|
||||||
|
queueActions.enqueue(
|
||||||
|
queue,
|
||||||
|
setQueue,
|
||||||
|
...itemsNotDownloaded.map((item) => ({
|
||||||
|
id: item.Id!,
|
||||||
|
execute: async () => await initiateDownload(item),
|
||||||
|
item,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
t("home.downloads.toasts.you_are_not_allowed_to_download_files")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -178,7 +189,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
|
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"DownloadItem ~ initiateDownload: No api or user or item",
|
"DownloadItem ~ initiateDownload: No api or user or item"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let mediaSource = selectedMediaSource;
|
let mediaSource = selectedMediaSource;
|
||||||
@@ -191,6 +202,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
mediaSource = defaults.mediaSource;
|
mediaSource = defaults.mediaSource;
|
||||||
audioIndex = defaults.audioIndex;
|
audioIndex = defaults.audioIndex;
|
||||||
subtitleIndex = defaults.subtitleIndex;
|
subtitleIndex = defaults.subtitleIndex;
|
||||||
|
// Keep using the selected bitrate for consistency across all downloads
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
const res = await getStreamUrl({
|
||||||
@@ -203,14 +215,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
mediaSourceId: mediaSource?.Id,
|
mediaSourceId: mediaSource?.Id,
|
||||||
subtitleStreamIndex: subtitleIndex,
|
subtitleStreamIndex: subtitleIndex,
|
||||||
deviceProfile: download,
|
deviceProfile: download,
|
||||||
download: true,
|
|
||||||
// deviceId: mediaSource?.Id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res) {
|
if (!res) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.downloads.something_went_wrong"),
|
t("home.downloads.something_went_wrong"),
|
||||||
t("home.downloads.could_not_get_stream_url_from_jellyfin"),
|
t("home.downloads.could_not_get_stream_url_from_jellyfin")
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -220,7 +230,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
if (!url || !source) throw new Error("No url");
|
if (!url || !source) throw new Error("No url");
|
||||||
|
|
||||||
saveDownloadItemInfoToDiskTmp(item, source, url);
|
saveDownloadItemInfoToDiskTmp(item, source, url);
|
||||||
await startBackgroundDownload(url, item, source, maxBitrate);
|
|
||||||
|
if (usingOptimizedServer) {
|
||||||
|
await startBackgroundDownload(url, item, source);
|
||||||
|
} else {
|
||||||
|
await startRemuxing(item, url, source);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -234,7 +249,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
maxBitrate,
|
maxBitrate,
|
||||||
usingOptimizedServer,
|
usingOptimizedServer,
|
||||||
startBackgroundDownload,
|
startBackgroundDownload,
|
||||||
],
|
startRemuxing,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
@@ -245,7 +261,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
appearsOnIndex={0}
|
appearsOnIndex={0}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
[],
|
[]
|
||||||
);
|
);
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
@@ -258,35 +274,31 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
setSelectedAudioStream(audioIndex ?? 0);
|
setSelectedAudioStream(audioIndex ?? 0);
|
||||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||||
setMaxBitrate(bitrate);
|
setMaxBitrate(bitrate);
|
||||||
}, [items, itemsNotDownloaded, settings]),
|
}, [items, itemsNotDownloaded, settings])
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderButtonContent = () => {
|
const renderButtonContent = () => {
|
||||||
if (processes.length > 0 && itemsProcesses.length > 0) {
|
if (processes && itemsProcesses.length > 0) {
|
||||||
return progress === 0 ? (
|
return progress === 0 ? (
|
||||||
<Loader />
|
<Loader />
|
||||||
) : (
|
) : (
|
||||||
<View className='-rotate-45'>
|
<View className="-rotate-45">
|
||||||
<ProgressCircle
|
<ProgressCircle
|
||||||
size={24}
|
size={24}
|
||||||
fill={progress}
|
fill={progress}
|
||||||
width={4}
|
width={4}
|
||||||
tintColor='#9334E9'
|
tintColor="#9334E9"
|
||||||
backgroundColor='#bdc3c7'
|
backgroundColor="#bdc3c7"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
} else if (itemsQueued) {
|
||||||
|
return <Ionicons name="hourglass" size={24} color="white" />;
|
||||||
if (itemsQueued) {
|
} else if (allItemsDownloaded) {
|
||||||
return <Ionicons name='hourglass' size={24} color='white' />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allItemsDownloaded) {
|
|
||||||
return <DownloadedIconComponent />;
|
return <DownloadedIconComponent />;
|
||||||
|
} else {
|
||||||
|
return <MissingDownloadIconComponent />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <MissingDownloadIconComponent />;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onButtonPress = () => {
|
const onButtonPress = () => {
|
||||||
@@ -319,19 +331,19 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||||
<View>
|
<View>
|
||||||
<Text className='font-bold text-2xl text-neutral-100'>
|
<Text className="font-bold text-2xl text-neutral-100">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='text-neutral-300'>
|
<Text className="text-neutral-300">
|
||||||
{subtitle ||
|
{subtitle ||
|
||||||
t("item_card.download.download_x_item", {
|
t("item_card.download.download_x_item", {
|
||||||
item_count: itemsNotDownloaded.length,
|
item_count: itemsNotDownloaded.length,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col space-y-2 w-full items-start'>
|
<View className="flex flex-col space-y-2 w-full items-start">
|
||||||
<BitrateSelector
|
<BitrateSelector
|
||||||
inverted
|
inverted
|
||||||
onChange={setMaxBitrate}
|
onChange={setMaxBitrate}
|
||||||
@@ -345,7 +357,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
selected={selectedMediaSource}
|
selected={selectedMediaSource}
|
||||||
/>
|
/>
|
||||||
{selectedMediaSource && (
|
{selectedMediaSource && (
|
||||||
<View className='flex flex-col space-y-2'>
|
<View className="flex flex-col space-y-2">
|
||||||
<AudioTrackSelector
|
<AudioTrackSelector
|
||||||
source={selectedMediaSource}
|
source={selectedMediaSource}
|
||||||
onChange={setSelectedAudioStream}
|
onChange={setSelectedAudioStream}
|
||||||
@@ -362,14 +374,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
className='mt-auto'
|
className="mt-auto"
|
||||||
onPress={acceptDownloadOptions}
|
onPress={acceptDownloadOptions}
|
||||||
color='purple'
|
color="purple"
|
||||||
>
|
>
|
||||||
{t("item_card.download.download_button")}
|
{t("item_card.download.download_button")}
|
||||||
</Button>
|
</Button>
|
||||||
<View className='opacity-70 text-center w-full flex items-center'>
|
<View className="opacity-70 text-center w-full flex items-center">
|
||||||
<Text className='text-xs'>
|
<Text className="text-xs">
|
||||||
{usingOptimizedServer
|
{usingOptimizedServer
|
||||||
? t("item_card.download.using_optimized_server")
|
? t("item_card.download.using_optimized_server")
|
||||||
: t("item_card.download.using_default_method")}
|
: t("item_card.download.using_default_method")}
|
||||||
@@ -392,17 +404,17 @@ export const DownloadSingleItem: React.FC<{
|
|||||||
<DownloadItems
|
<DownloadItems
|
||||||
size={size}
|
size={size}
|
||||||
title={
|
title={
|
||||||
item.Type === "Episode"
|
item.Type == "Episode"
|
||||||
? t("item_card.download.download_episode")
|
? t("item_card.download.download_episode")
|
||||||
: t("item_card.download.download_movie")
|
: t("item_card.download.download_movie")
|
||||||
}
|
}
|
||||||
subtitle={item.Name!}
|
subtitle={item.Name!}
|
||||||
items={[item]}
|
items={[item]}
|
||||||
MissingDownloadIconComponent={() => (
|
MissingDownloadIconComponent={() => (
|
||||||
<Ionicons name='cloud-download-outline' size={24} color='white' />
|
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||||
)}
|
)}
|
||||||
DownloadedIconComponent={() => (
|
DownloadedIconComponent={() => (
|
||||||
<Ionicons name='cloud-download' size={26} color='#9333ea' />
|
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,57 +1,44 @@
|
|||||||
// GenreTags.tsx
|
// GenreTags.tsx
|
||||||
import type React from "react";
|
import React from "react";
|
||||||
import {
|
import {StyleProp, TextStyle, View, ViewProps} from "react-native";
|
||||||
type StyleProp,
|
|
||||||
type TextStyle,
|
|
||||||
View,
|
|
||||||
type ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface TagProps {
|
interface TagProps {
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
textClass?: ViewProps["className"];
|
textClass?: ViewProps["className"]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Tag: React.FC<
|
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"], textStyle?: StyleProp<TextStyle>} & ViewProps> = ({
|
||||||
{
|
text,
|
||||||
text: string;
|
textClass,
|
||||||
textClass?: ViewProps["className"];
|
textStyle,
|
||||||
textStyle?: StyleProp<TextStyle>;
|
...props
|
||||||
} & ViewProps
|
}) => {
|
||||||
> = ({ text, textClass, textStyle, ...props }) => {
|
|
||||||
return (
|
return (
|
||||||
<View className='bg-neutral-800 rounded-full px-2 py-1' {...props}>
|
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
|
||||||
<Text className={textClass} style={textStyle}>
|
<Text className={textClass} style={textStyle}>{text}</Text>
|
||||||
{text}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Tags: React.FC<
|
export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-xs", ...props }) => {
|
||||||
TagProps & { tagProps?: ViewProps } & ViewProps
|
|
||||||
> = ({ tags, textClass = "text-xs", tagProps, ...props }) => {
|
|
||||||
if (!tags || tags.length === 0) return null;
|
if (!tags || tags.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
|
||||||
className={`flex flex-row flex-wrap gap-1 ${props.className}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{tags.map((tag, idx) => (
|
{tags.map((tag, idx) => (
|
||||||
<View key={idx}>
|
<View key={idx}>
|
||||||
<Tag key={idx} textClass={textClass} text={tag} {...tagProps} />
|
<Tag key={idx} textClass={textClass} text={tag}/>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GenreTags: React.FC<{ genres?: string[] }> = ({ genres }) => {
|
export const GenreTags: React.FC<{ genres?: string[]}> = ({ genres }) => {
|
||||||
return (
|
return (
|
||||||
<View className='mt-2'>
|
<View className="mt-2">
|
||||||
<Tags tags={genres} />
|
<Tags tags={genres}/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import React from "react";
|
||||||
import type React from "react";
|
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { tc } from "@/utils/textTools";
|
||||||
|
|
||||||
type ItemCardProps = {
|
type ItemCardProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -9,13 +10,13 @@ type ItemCardProps = {
|
|||||||
|
|
||||||
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
||||||
return (
|
return (
|
||||||
<View className='mt-2 flex flex-col'>
|
<View className="mt-2 flex flex-col">
|
||||||
{item.Type === "Episode" ? (
|
{item.Type === "Episode" ? (
|
||||||
<>
|
<>
|
||||||
<Text numberOfLines={1} ellipsizeMode='tail' className=''>
|
<Text numberOfLines={1} className="">
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text numberOfLines={1} className='text-xs opacity-50'>
|
<Text numberOfLines={1} className="text-xs opacity-50">
|
||||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||||
{" - "}
|
{" - "}
|
||||||
{item.SeriesName}
|
{item.SeriesName}
|
||||||
@@ -23,10 +24,8 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text numberOfLines={1} ellipsizeMode='tail'>
|
<Text numberOfLines={2}>{item.Name}</Text>
|
||||||
{item.Name}
|
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
|
||||||
</Text>
|
|
||||||
<Text className='text-xs opacity-50'>{item.ProductionYear}</Text>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
import type {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||||
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
|
||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
@@ -19,6 +8,7 @@ import { PlayButton } from "@/components/PlayButton";
|
|||||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||||
import { SimilarItems } from "@/components/SimilarItems";
|
import { SimilarItems } from "@/components/SimilarItems";
|
||||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||||
@@ -26,17 +16,25 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
|||||||
import { useImageColors } from "@/hooks/useImageColors";
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Platform, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
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";
|
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||||
|
|
||||||
const Chromecast = require("./Chromecast");
|
|
||||||
|
|
||||||
export type SelectedOptions = {
|
export type SelectedOptions = {
|
||||||
bitrate: Bitrate;
|
bitrate: Bitrate;
|
||||||
@@ -52,8 +50,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
useImageColors({ item });
|
useImageColors({ item });
|
||||||
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||||
@@ -85,27 +81,31 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
if (!Platform.isTV) {
|
||||||
navigation.setOptions({
|
useEffect(() => {
|
||||||
headerRight: () =>
|
navigation.setOptions({
|
||||||
item && (
|
headerRight: () =>
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
item && (
|
||||||
<Chromecast.Chromecast background='blur' width={22} height={22} />
|
<View className="flex flex-row items-center space-x-2">
|
||||||
{item.Type !== "Program" && (
|
<Chromecast.Chromecast
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
background="blur"
|
||||||
<DownloadSingleItem item={item} size='large' />
|
width={22}
|
||||||
{user?.Policy?.IsAdministrator && (
|
height={22}
|
||||||
<PlayInRemoteSessionButton item={item} size='large' />
|
/>
|
||||||
)}
|
{item.Type !== "Program" && (
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<PlayedStatus items={[item]} size='large' />
|
{!Platform.isTV && (
|
||||||
<AddToFavorites item={item} />
|
<DownloadSingleItem item={item} size="large" />
|
||||||
</View>
|
)}
|
||||||
)}
|
<PlayedStatus items={[item]} size="large" />
|
||||||
</View>
|
<AddToFavorites item={item} />
|
||||||
),
|
</View>
|
||||||
});
|
)}
|
||||||
}, [item]);
|
</View>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [item]);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||||
@@ -123,7 +123,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className='flex-1 relative'
|
className="flex-1 relative"
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
@@ -147,38 +147,40 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
logo={
|
logo={
|
||||||
logoUrl ? (
|
<>
|
||||||
<Image
|
{logoUrl ? (
|
||||||
source={{
|
<Image
|
||||||
uri: logoUrl,
|
source={{
|
||||||
}}
|
uri: logoUrl,
|
||||||
style={{
|
}}
|
||||||
height: 130,
|
style={{
|
||||||
width: "100%",
|
height: 130,
|
||||||
resizeMode: "contain",
|
width: "100%",
|
||||||
}}
|
resizeMode: "contain",
|
||||||
onLoad={() => setLoadingLogo(false)}
|
}}
|
||||||
onError={() => setLoadingLogo(false)}
|
onLoad={() => setLoadingLogo(false)}
|
||||||
/>
|
onError={() => setLoadingLogo(false)}
|
||||||
) : null
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<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-4" />
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && !Platform.isTV && (
|
||||||
<View className='flex flex-row items-center justify-start w-full h-16'>
|
<View className="flex flex-row items-center justify-start w-full h-16">
|
||||||
<BitrateSelector
|
<BitrateSelector
|
||||||
className='mr-1'
|
className="mr-1"
|
||||||
onChange={(val) =>
|
onChange={(val) =>
|
||||||
setSelectedOptions(
|
setSelectedOptions(
|
||||||
(prev) => prev && { ...prev, bitrate: val },
|
(prev) => prev && { ...prev, bitrate: val }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
selected={selectedOptions.bitrate}
|
selected={selectedOptions.bitrate}
|
||||||
/>
|
/>
|
||||||
<MediaSourceSelector
|
<MediaSourceSelector
|
||||||
className='mr-1'
|
className="mr-1"
|
||||||
item={item}
|
item={item}
|
||||||
onChange={(val) =>
|
onChange={(val) =>
|
||||||
setSelectedOptions(
|
setSelectedOptions(
|
||||||
@@ -186,13 +188,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
prev && {
|
prev && {
|
||||||
...prev,
|
...prev,
|
||||||
mediaSource: val,
|
mediaSource: val,
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
selected={selectedOptions.mediaSource}
|
selected={selectedOptions.mediaSource}
|
||||||
/>
|
/>
|
||||||
<AudioTrackSelector
|
<AudioTrackSelector
|
||||||
className='mr-1'
|
className="mr-1"
|
||||||
source={selectedOptions.mediaSource}
|
source={selectedOptions.mediaSource}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
setSelectedOptions(
|
setSelectedOptions(
|
||||||
@@ -200,7 +202,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
prev && {
|
prev && {
|
||||||
...prev,
|
...prev,
|
||||||
audioIndex: val,
|
audioIndex: val,
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
selected={selectedOptions.audioIndex}
|
selected={selectedOptions.audioIndex}
|
||||||
@@ -213,7 +215,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
prev && {
|
prev && {
|
||||||
...prev,
|
...prev,
|
||||||
subtitleIndex: val,
|
subtitleIndex: val,
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
selected={selectedOptions.subtitleIndex}
|
selected={selectedOptions.subtitleIndex}
|
||||||
@@ -222,7 +224,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<PlayButton
|
<PlayButton
|
||||||
className='grow'
|
className="grow"
|
||||||
selectedOptions={selectedOptions}
|
selectedOptions={selectedOptions}
|
||||||
item={item}
|
item={item}
|
||||||
/>
|
/>
|
||||||
@@ -233,24 +235,24 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
<OverviewText text={item.Overview} className="px-4 mb-4" />
|
||||||
|
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<>
|
<>
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
<CurrentSeries item={item} className='mb-4' />
|
<CurrentSeries item={item} className="mb-4" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
||||||
|
|
||||||
{item.People && item.People.length > 0 && (
|
{item.People && item.People.length > 0 && (
|
||||||
<View className='mb-4'>
|
<View className="mb-4">
|
||||||
{item.People.slice(0, 3).map((person, idx) => (
|
{item.People.slice(0, 3).map((person, idx) => (
|
||||||
<MoreMoviesWithActor
|
<MoreMoviesWithActor
|
||||||
currentItem={item}
|
currentItem={item}
|
||||||
key={idx}
|
key={idx}
|
||||||
actorId={person.Id!}
|
actorId={person.Id!}
|
||||||
className='mb-4'
|
className="mb-4"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -263,5 +265,5 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
</ParallaxScrollView>
|
</ParallaxScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import type React from "react";
|
import React from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, ViewProps } from "react-native";
|
||||||
import { GenreTags } from "./GenreTags";
|
import { GenreTags } from "./GenreTags";
|
||||||
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||||
import { Ratings } from "./Ratings";
|
import { Ratings } from "./Ratings";
|
||||||
@@ -15,21 +15,21 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
if (!item)
|
if (!item)
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className='flex flex-col space-y-1.5 w-full items-start h-32'
|
className="flex flex-col space-y-1.5 w-full items-start h-32"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<View className='w-1/3 h-6 bg-neutral-900 rounded' />
|
<View className="w-1/3 h-6 bg-neutral-900 rounded" />
|
||||||
<View className='w-2/3 h-8 bg-neutral-900 rounded' />
|
<View className="w-2/3 h-8 bg-neutral-900 rounded" />
|
||||||
<View className='w-2/3 h-4 bg-neutral-900 rounded' />
|
<View className="w-2/3 h-4 bg-neutral-900 rounded" />
|
||||||
<View className='w-1/4 h-4 bg-neutral-900 rounded' />
|
<View className="w-1/4 h-4 bg-neutral-900 rounded" />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col' {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<View className='flex flex-col' {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<View className='flex flex-row items-center justify-between'>
|
<View className="flex flex-row items-center justify-between">
|
||||||
<Ratings item={item} className='mb-2' />
|
<Ratings item={item} className="mb-2" />
|
||||||
<ItemActions item={item} />
|
<ItemActions item={item} />
|
||||||
</View>
|
</View>
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetScrollView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import type {
|
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
MediaStream,
|
type MediaStream,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import type React from "react";
|
import React, { useMemo, useRef } from "react";
|
||||||
import { useMemo, useRef } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { formatBitrate } from "@/utils/bitrate";
|
|
||||||
import { Badge } from "./Badge";
|
import { Badge } from "./Badge";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import {
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetBackdropProps,
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
BottomSheetView,
|
||||||
|
BottomSheetScrollView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
@@ -26,13 +27,13 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='px-4 mt-2 mb-4'>
|
<View className="px-4 mt-2 mb-4">
|
||||||
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
|
<Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
|
||||||
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
|
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
|
||||||
<View className='flex flex-row space-x-2'>
|
<View className="flex flex-row space-x-2">
|
||||||
<VideoStreamInfo source={source} />
|
<VideoStreamInfo source={source} />
|
||||||
</View>
|
</View>
|
||||||
<Text className='text-purple-600'>{t("item_card.more_details")}</Text>
|
<Text className="text-purple-600">{t("item_card.more_details")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={bottomSheetModalRef}
|
ref={bottomSheetModalRef}
|
||||||
@@ -52,37 +53,37 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<BottomSheetScrollView>
|
<BottomSheetScrollView>
|
||||||
<View className='flex flex-col space-y-2 p-4 mb-4'>
|
<View className="flex flex-col space-y-2 p-4 mb-4">
|
||||||
<View className=''>
|
<View className="">
|
||||||
<Text className='text-lg font-bold mb-4'>
|
<Text className="text-lg font-bold mb-4">
|
||||||
{t("item_card.video")}
|
{t("item_card.video")}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='flex flex-row space-x-2'>
|
<View className="flex flex-row space-x-2">
|
||||||
<VideoStreamInfo source={source} />
|
<VideoStreamInfo source={source} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className=''>
|
<View className="">
|
||||||
<Text className='text-lg font-bold mb-2'>
|
<Text className="text-lg font-bold mb-2">
|
||||||
{t("item_card.audio")}
|
{t("item_card.audio")}
|
||||||
</Text>
|
</Text>
|
||||||
<AudioStreamInfo
|
<AudioStreamInfo
|
||||||
audioStreams={
|
audioStreams={
|
||||||
source?.MediaStreams?.filter(
|
source?.MediaStreams?.filter(
|
||||||
(stream) => stream.Type === "Audio",
|
(stream) => stream.Type === "Audio"
|
||||||
) || []
|
) || []
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className=''>
|
<View className="">
|
||||||
<Text className='text-lg font-bold mb-2'>
|
<Text className="text-lg font-bold mb-2">
|
||||||
{t("item_card.subtitles")}
|
{t("item_card.subtitles")}
|
||||||
</Text>
|
</Text>
|
||||||
<SubtitleStreamInfo
|
<SubtitleStreamInfo
|
||||||
subtitleStreams={
|
subtitleStreams={
|
||||||
source?.MediaStreams?.filter(
|
source?.MediaStreams?.filter(
|
||||||
(stream) => stream.Type === "Subtitle",
|
(stream) => stream.Type === "Subtitle"
|
||||||
) || []
|
) || []
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -100,25 +101,25 @@ const SubtitleStreamInfo = ({
|
|||||||
subtitleStreams: MediaStream[];
|
subtitleStreams: MediaStream[];
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col'>
|
<View className="flex flex-col">
|
||||||
{subtitleStreams.map((stream, _index) => (
|
{subtitleStreams.map((stream, index) => (
|
||||||
<View key={stream.Index} className='flex flex-col'>
|
<View key={stream.Index} className="flex flex-col">
|
||||||
<Text className='text-xs mb-3 text-neutral-400'>
|
<Text className="text-xs mb-3 text-neutral-400">
|
||||||
{stream.DisplayTitle}
|
{stream.DisplayTitle}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='flex flex-row flex-wrap gap-2'>
|
<View className="flex flex-row flex-wrap gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
variant='gray'
|
variant="gray"
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<Ionicons name='language-outline' size={16} color='white' />
|
<Ionicons name="language-outline" size={16} color="white" />
|
||||||
}
|
}
|
||||||
text={stream.Language}
|
text={stream.Language}
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
variant='gray'
|
variant="gray"
|
||||||
text={stream.Codec}
|
text={stream.Codec}
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<Ionicons name='layers-outline' size={16} color='white' />
|
<Ionicons name="layers-outline" size={16} color="white" />
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -130,40 +131,40 @@ const SubtitleStreamInfo = ({
|
|||||||
|
|
||||||
const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
|
const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col'>
|
<View className="flex flex-col">
|
||||||
{audioStreams.map((audioStreams, index) => (
|
{audioStreams.map((audioStreams, index) => (
|
||||||
<View key={index} className='flex flex-col'>
|
<View key={index} className="flex flex-col">
|
||||||
<Text className='mb-3 text-neutral-400 text-xs'>
|
<Text className="mb-3 text-neutral-400 text-xs">
|
||||||
{audioStreams.DisplayTitle}
|
{audioStreams.DisplayTitle}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='flex-row flex-wrap gap-2'>
|
<View className="flex-row flex-wrap gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
variant='gray'
|
variant="gray"
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<Ionicons name='language-outline' size={16} color='white' />
|
<Ionicons name="language-outline" size={16} color="white" />
|
||||||
}
|
}
|
||||||
text={audioStreams.Language}
|
text={audioStreams.Language}
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
variant='gray'
|
variant="gray"
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='musical-notes-outline'
|
name="musical-notes-outline"
|
||||||
size={16}
|
size={16}
|
||||||
color='white'
|
color="white"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
text={audioStreams.Codec}
|
text={audioStreams.Codec}
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
variant='gray'
|
variant="gray"
|
||||||
iconLeft={<Ionicons name='mic-outline' size={16} color='white' />}
|
iconLeft={<Ionicons name="mic-outline" size={16} color="white" />}
|
||||||
text={audioStreams.ChannelLayout}
|
text={audioStreams.ChannelLayout}
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
variant='gray'
|
variant="gray"
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<Ionicons name='speedometer-outline' size={16} color='white' />
|
<Ionicons name="speedometer-outline" size={16} color="white" />
|
||||||
}
|
}
|
||||||
text={formatBitrate(audioStreams.BitRate)}
|
text={formatBitrate(audioStreams.BitRate)}
|
||||||
/>
|
/>
|
||||||
@@ -179,48 +180,48 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
|||||||
|
|
||||||
const videoStream = useMemo(() => {
|
const videoStream = useMemo(() => {
|
||||||
return source.MediaStreams?.find(
|
return source.MediaStreams?.find(
|
||||||
(stream) => stream.Type === "Video",
|
(stream) => stream.Type === "Video"
|
||||||
) as MediaStream;
|
) as MediaStream;
|
||||||
}, [source.MediaStreams]);
|
}, [source.MediaStreams]);
|
||||||
|
|
||||||
if (!videoStream) return null;
|
if (!videoStream) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex-row flex-wrap gap-2'>
|
<View className="flex-row flex-wrap gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
variant='gray'
|
variant="gray"
|
||||||
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
|
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
|
||||||
text={formatFileSize(source.Size)}
|
text={formatFileSize(source.Size)}
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
variant='gray'
|
variant="gray"
|
||||||
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
|
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
|
||||||
text={`${videoStream.Width}x${videoStream.Height}`}
|
text={`${videoStream.Width}x${videoStream.Height}`}
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
variant='gray'
|
variant="gray"
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<Ionicons name='color-palette-outline' size={16} color='white' />
|
<Ionicons name="color-palette-outline" size={16} color="white" />
|
||||||
}
|
}
|
||||||
text={videoStream.VideoRange}
|
text={videoStream.VideoRange}
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
variant='gray'
|
variant="gray"
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<Ionicons name='code-working-outline' size={16} color='white' />
|
<Ionicons name="code-working-outline" size={16} color="white" />
|
||||||
}
|
}
|
||||||
text={videoStream.Codec}
|
text={videoStream.Codec}
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
variant='gray'
|
variant="gray"
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<Ionicons name='speedometer-outline' size={16} color='white' />
|
<Ionicons name="speedometer-outline" size={16} color="white" />
|
||||||
}
|
}
|
||||||
text={formatBitrate(videoStream.BitRate)}
|
text={formatBitrate(videoStream.BitRate)}
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge
|
||||||
variant='gray'
|
variant="gray"
|
||||||
iconLeft={<Ionicons name='play-outline' size={16} color='white' />}
|
iconLeft={<Ionicons name="play-outline" size={16} color="white" />}
|
||||||
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
|
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -232,8 +233,6 @@ const formatFileSize = (bytes?: number | null) => {
|
|||||||
|
|
||||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||||
if (bytes === 0) return "0 Byte";
|
if (bytes === 0) return "0 Byte";
|
||||||
const i = Number.parseInt(
|
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
|
||||||
Math.floor(Math.log(bytes) / Math.log(1024)).toString(),
|
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
|
||||||
);
|
|
||||||
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { View, Text, TouchableOpacity } from "react-native";
|
||||||
import { Text, View } from "react-native";
|
|
||||||
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
|
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";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onServerSelect?: (server: { address: string; serverName?: string }) => void;
|
onServerSelect?: (server: { address: string; serverName?: string }) => void;
|
||||||
@@ -15,17 +15,15 @@ const JellyfinServerDiscovery: React.FC<Props> = ({ onServerSelect }) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='mt-2'>
|
<View className="mt-2">
|
||||||
<Button onPress={startDiscovery} color='black'>
|
<Button onPress={startDiscovery} color="black">
|
||||||
<Text className='text-white text-center'>
|
<Text className="text-white text-center">
|
||||||
{isSearching
|
{isSearching ? t("server.searching") : t("server.search_for_local_servers")}
|
||||||
? t("server.searching")
|
|
||||||
: t("server.search_for_local_servers")}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{servers.length ? (
|
{servers.length ? (
|
||||||
<ListGroup title={t("server.servers")} className='mt-4'>
|
<ListGroup title={t("server.servers")} className="mt-4">
|
||||||
{servers.map((server) => (
|
{servers.map((server) => (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={server.address}
|
key={server.address}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
type ActivityIndicatorProps,
|
ActivityIndicatorProps,
|
||||||
Platform,
|
Platform,
|
||||||
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
|
||||||
interface Props extends ActivityIndicatorProps {}
|
interface Props extends ActivityIndicatorProps {}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import type {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -26,9 +24,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
const selectedName = useMemo(
|
const selectedName = useMemo(
|
||||||
() =>
|
() =>
|
||||||
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
||||||
(x) => x.Type === "Video",
|
(x) => x.Type === "Video"
|
||||||
)?.DisplayTitle || "",
|
)?.DisplayTitle || "",
|
||||||
[item, selected],
|
[item, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -56,26 +54,26 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className='flex shrink'
|
className="flex shrink"
|
||||||
style={{
|
style={{
|
||||||
minWidth: 50,
|
minWidth: 50,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className='flex flex-col' {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
<Text className="opacity-50 mb-1 text-xs">
|
||||||
{t("item_card.video")}
|
{t("item_card.video")}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'>
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
|
||||||
<Text numberOfLines={1}>{selectedName}</Text>
|
<Text numberOfLines={1}>{selectedName}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
loop={true}
|
loop={true}
|
||||||
side='bottom'
|
side="bottom"
|
||||||
align='start'
|
align="start"
|
||||||
alignOffset={0}
|
alignOffset={0}
|
||||||
avoidCollisions={true}
|
avoidCollisions={true}
|
||||||
collisionPadding={8}
|
collisionPadding={8}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import React from "react";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { View, ViewProps } from "react-native";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import type React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { View, type ViewProps } from "react-native";
|
|
||||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
actorId: string;
|
actorId: string;
|
||||||
@@ -63,8 +63,9 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
|||||||
const x = acc.find((item) => item.Id === current.Id);
|
const x = acc.find((item) => item.Id === current.Id);
|
||||||
if (!x) {
|
if (!x) {
|
||||||
return acc.concat([current]);
|
return acc.concat([current]);
|
||||||
|
} else {
|
||||||
|
return acc;
|
||||||
}
|
}
|
||||||
return acc;
|
|
||||||
}, [] as BaseItemDto[]) || [];
|
}, [] as BaseItemDto[]) || [];
|
||||||
|
|
||||||
return uniqueItems;
|
return uniqueItems;
|
||||||
@@ -76,8 +77,8 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className='text-lg font-bold mb-2 px-4'>
|
<Text className="text-lg font-bold mb-2 px-4">
|
||||||
{t("item_card.more_with", { name: actor?.Name })}
|
{t("item_card.more_with", {name: actor?.Name})}
|
||||||
</Text>
|
</Text>
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={items}
|
data={items}
|
||||||
@@ -87,7 +88,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
|||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
key={idx}
|
key={idx}
|
||||||
item={item}
|
item={item}
|
||||||
className='flex flex-col w-28'
|
className="flex flex-col w-28"
|
||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
<MoviePoster item={item} />
|
<MoviePoster item={item} />
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { tc } from "@/utils/textTools";
|
import { tc } from "@/utils/textTools";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
text?: string | null;
|
text?: string | null;
|
||||||
@@ -20,22 +20,20 @@ export const OverviewText: React.FC<Props> = ({
|
|||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col' {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className='text-lg font-bold mb-2'>{t("item_card.overview")}</Text>
|
<Text className="text-lg font-bold mb-2">{t("item_card.overview")}</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
setLimit((prev) =>
|
setLimit((prev) =>
|
||||||
prev === characterLimit ? text.length : characterLimit,
|
prev === characterLimit ? text.length : characterLimit
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
<Text>{tc(text, limit)}</Text>
|
<Text>{tc(text, limit)}</Text>
|
||||||
{text.length > characterLimit && (
|
{text.length > characterLimit && (
|
||||||
<Text className='text-purple-600 mt-1'>
|
<Text className="text-purple-600 mt-1">
|
||||||
{limit === characterLimit
|
{limit === characterLimit ? t("item_card.show_more") : t("item_card.show_less")}
|
||||||
? t("item_card.show_more")
|
|
||||||
: t("item_card.show_less")}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import type { PropsWithChildren, ReactElement } from "react";
|
import { type PropsWithChildren, type ReactElement } from "react";
|
||||||
import { type NativeScrollEvent, View, type ViewProps } from "react-native";
|
import {NativeScrollEvent, NativeSyntheticEvent, View, ViewProps} from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
interpolate,
|
interpolate,
|
||||||
useAnimatedRef,
|
useAnimatedRef,
|
||||||
@@ -35,40 +35,36 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
translateY: interpolate(
|
translateY: interpolate(
|
||||||
scrollOffset.value,
|
scrollOffset.value,
|
||||||
[-headerHeight, 0, headerHeight],
|
[-headerHeight, 0, headerHeight],
|
||||||
[-headerHeight / 2, 0, headerHeight * 0.75],
|
[-headerHeight / 2, 0, headerHeight * 0.75]
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scale: interpolate(
|
scale: interpolate(
|
||||||
scrollOffset.value,
|
scrollOffset.value,
|
||||||
[-headerHeight, 0, headerHeight],
|
[-headerHeight, 0, headerHeight],
|
||||||
[2, 1, 1],
|
[2, 1, 1]
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function isCloseToBottom({
|
|
||||||
layoutMeasurement,
|
function isCloseToBottom({layoutMeasurement, contentOffset, contentSize}: NativeScrollEvent) {
|
||||||
contentOffset,
|
return layoutMeasurement.height + contentOffset.y >= contentSize.height - 20;
|
||||||
contentSize,
|
|
||||||
}: NativeScrollEvent) {
|
|
||||||
return (
|
|
||||||
layoutMeasurement.height + contentOffset.y >= contentSize.height - 20
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex-1' {...props}>
|
<View className="flex-1" {...props}>
|
||||||
<Animated.ScrollView
|
<Animated.ScrollView
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
onScroll={(e) => {
|
onScroll={e => {
|
||||||
if (isCloseToBottom(e.nativeEvent)) onEndReached?.();
|
if (isCloseToBottom(e.nativeEvent))
|
||||||
|
onEndReached?.()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{logo && (
|
{logo && (
|
||||||
@@ -77,7 +73,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
top: headerHeight - 200,
|
top: headerHeight - 200,
|
||||||
height: 130,
|
height: 130,
|
||||||
}}
|
}}
|
||||||
className='absolute left-0 w-full z-40 px-4 flex justify-center items-center'
|
className="absolute left-0 w-full z-40 px-4 flex justify-center items-center"
|
||||||
>
|
>
|
||||||
{logo}
|
{logo}
|
||||||
</View>
|
</View>
|
||||||
@@ -99,7 +95,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
style={{
|
style={{
|
||||||
top: -50,
|
top: -50,
|
||||||
}}
|
}}
|
||||||
className='relative flex-1 bg-transparent pb-24'
|
className="relative flex-1 bg-transparent pb-24"
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
// Background Linear Gradient
|
// Background Linear Gradient
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import type React from "react";
|
import React from "react";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, 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,10 +1,18 @@
|
|||||||
|
import { Platform, Pressable } from "react-native";
|
||||||
|
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 ios from "@/utils/profiles/ios";
|
||||||
|
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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Alert, TouchableOpacity, View } from "react-native";
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
CastButton,
|
||||||
@@ -22,18 +30,12 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { Button } from "./Button";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { SelectedOptions } from "./ItemContent";
|
||||||
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 { chromecast } from "@/utils/profiles/chromecast";
|
||||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Button } from "./Button";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -65,17 +67,14 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const startColor = useSharedValue(colorAtom);
|
const startColor = useSharedValue(colorAtom);
|
||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
(q: string) => {
|
(q: string) => {
|
||||||
if (settings.maxAutoPlayEpisodeCount.value !== -1) {
|
|
||||||
updateSettings({ autoPlayEpisodeCount: 0 });
|
|
||||||
}
|
|
||||||
router.push(`/player/direct-player?${q}`);
|
router.push(`/player/direct-player?${q}`);
|
||||||
},
|
},
|
||||||
[router],
|
[router]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
@@ -141,7 +140,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
console.warn("No URL returned from getStreamUrl", data);
|
console.warn("No URL returned from getStreamUrl", data);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.client_error"),
|
t("player.client_error"),
|
||||||
t("player.could_not_create_stream_for_chromecast"),
|
t("player.could_not_create_stream_for_chromecast")
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -171,36 +170,36 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
: item.Type === "Movie"
|
: item.Type === "Movie"
|
||||||
? {
|
? {
|
||||||
type: "movie",
|
type: "movie",
|
||||||
title: item.Name || "",
|
title: item.Name || "",
|
||||||
subtitle: item.Overview || "",
|
subtitle: item.Overview || "",
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: getPrimaryImageUrl({
|
url: getPrimaryImageUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
quality: 90,
|
quality: 90,
|
||||||
width: 2000,
|
width: 2000,
|
||||||
})!,
|
})!,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
type: "generic",
|
type: "generic",
|
||||||
title: item.Name || "",
|
title: item.Name || "",
|
||||||
subtitle: item.Overview || "",
|
subtitle: item.Overview || "",
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: getPrimaryImageUrl({
|
url: getPrimaryImageUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
quality: 90,
|
quality: 90,
|
||||||
width: 2000,
|
width: 2000,
|
||||||
})!,
|
})!,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
startTime: 0,
|
startTime: 0,
|
||||||
})
|
})
|
||||||
@@ -223,7 +222,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
case cancelButtonIndex:
|
case cancelButtonIndex:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
item,
|
item,
|
||||||
@@ -240,11 +239,11 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
if (!item || !item.RunTimeTicks) return 0;
|
if (!item || !item.RunTimeTicks) return 0;
|
||||||
const userData = item.UserData;
|
const userData = item.UserData;
|
||||||
if (userData?.PlaybackPositionTicks) {
|
if (userData && userData.PlaybackPositionTicks) {
|
||||||
return userData.PlaybackPositionTicks > 0
|
return userData.PlaybackPositionTicks > 0
|
||||||
? Math.max(
|
? Math.max(
|
||||||
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
||||||
MIN_PLAYBACK_WIDTH,
|
MIN_PLAYBACK_WIDTH
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
}
|
}
|
||||||
@@ -261,7 +260,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
@@ -274,7 +273,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[colorAtom],
|
[colorAtom]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -295,7 +294,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
backgroundColor: interpolateColor(
|
backgroundColor: interpolateColor(
|
||||||
colorChangeProgress.value,
|
colorChangeProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startColor.value.primary, endColor.value.primary],
|
[startColor.value.primary, endColor.value.primary]
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -303,7 +302,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
backgroundColor: interpolateColor(
|
backgroundColor: interpolateColor(
|
||||||
colorChangeProgress.value,
|
colorChangeProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startColor.value.primary, endColor.value.primary],
|
[startColor.value.primary, endColor.value.primary]
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -311,7 +310,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
width: `${interpolate(
|
width: `${interpolate(
|
||||||
widthProgress.value,
|
widthProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startWidth.value, targetWidth.value],
|
[startWidth.value, targetWidth.value]
|
||||||
)}%`,
|
)}%`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -319,7 +318,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
color: interpolateColor(
|
color: interpolateColor(
|
||||||
colorChangeProgress.value,
|
colorChangeProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startColor.value.text, endColor.value.text],
|
[startColor.value.text, endColor.value.text]
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
/**
|
/**
|
||||||
@@ -329,13 +328,13 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={!item}
|
disabled={!item}
|
||||||
accessibilityLabel='Play button'
|
accessibilityLabel="Play button"
|
||||||
accessibilityHint='Tap to play the media'
|
accessibilityHint="Tap to play the media"
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative"}
|
className={`relative`}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<View className='absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden'>
|
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
animatedPrimaryStyle,
|
animatedPrimaryStyle,
|
||||||
@@ -349,7 +348,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||||
className='absolute w-full h-full top-0 left-0 rounded-xl'
|
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -357,25 +356,25 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
borderColor: colorAtom.primary,
|
borderColor: colorAtom.primary,
|
||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
}}
|
}}
|
||||||
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
|
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
||||||
>
|
>
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
<Animated.Text style={animatedTextStyle}>
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<Ionicons name='play-circle' size={24} />
|
<Ionicons name="play-circle" size={24} />
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
{client && (
|
{client && (
|
||||||
<Animated.Text style={animatedTextStyle}>
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<Feather name='cast' size={22} />
|
<Feather name="cast" size={22} />
|
||||||
<CastButton tintColor='transparent' />
|
<CastButton tintColor="transparent" />
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
)}
|
)}
|
||||||
{!client && settings?.openInVLC && (
|
{!client && settings?.openInVLC && (
|
||||||
<Animated.Text style={animatedTextStyle}>
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name='vlc'
|
name="vlc"
|
||||||
size={18}
|
size={18}
|
||||||
color={animatedTextStyle.color}
|
color={animatedTextStyle.color}
|
||||||
/>
|
/>
|
||||||
|
|||||||
226
components/PlayButton.tv.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { Platform } from "react-native";
|
||||||
|
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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
interpolate,
|
||||||
|
interpolateColor,
|
||||||
|
useAnimatedReaction,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useDerivedValue,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { SelectedOptions } from "./ItemContent";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
|
item: BaseItemDto;
|
||||||
|
selectedOptions: SelectedOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMATION_DURATION = 500;
|
||||||
|
const MIN_PLAYBACK_WIDTH = 15;
|
||||||
|
|
||||||
|
export const PlayButton: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
selectedOptions,
|
||||||
|
...props
|
||||||
|
}: Props) => {
|
||||||
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const startWidth = useSharedValue(0);
|
||||||
|
const targetWidth = useSharedValue(0);
|
||||||
|
const endColor = useSharedValue(colorAtom);
|
||||||
|
const startColor = useSharedValue(colorAtom);
|
||||||
|
const widthProgress = useSharedValue(0);
|
||||||
|
const colorChangeProgress = useSharedValue(0);
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
|
const goToPlayer = useCallback(
|
||||||
|
(q: string) => {
|
||||||
|
router.push(`/player/direct-player?${q}`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPress = () => {
|
||||||
|
console.log("onpress");
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
lightHapticFeedback();
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id!,
|
||||||
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||||
|
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||||
|
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||||
|
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryString = queryParams.toString();
|
||||||
|
goToPlayer(queryString);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
|
if (!item || !item.RunTimeTicks) return 0;
|
||||||
|
const userData = item.UserData;
|
||||||
|
if (userData && userData.PlaybackPositionTicks) {
|
||||||
|
return userData.PlaybackPositionTicks > 0
|
||||||
|
? Math.max(
|
||||||
|
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
||||||
|
MIN_PLAYBACK_WIDTH
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => derivedTargetWidth.value,
|
||||||
|
(newWidth) => {
|
||||||
|
targetWidth.value = newWidth;
|
||||||
|
widthProgress.value = 0;
|
||||||
|
widthProgress.value = withTiming(1, {
|
||||||
|
duration: ANIMATION_DURATION,
|
||||||
|
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[item]
|
||||||
|
);
|
||||||
|
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => colorAtom,
|
||||||
|
(newColor) => {
|
||||||
|
endColor.value = newColor;
|
||||||
|
colorChangeProgress.value = 0;
|
||||||
|
colorChangeProgress.value = withTiming(1, {
|
||||||
|
duration: ANIMATION_DURATION,
|
||||||
|
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[colorAtom]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout_2 = setTimeout(() => {
|
||||||
|
startColor.value = colorAtom;
|
||||||
|
startWidth.value = targetWidth.value;
|
||||||
|
}, ANIMATION_DURATION);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout_2);
|
||||||
|
};
|
||||||
|
}, [colorAtom, item]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ANIMATED STYLES
|
||||||
|
*/
|
||||||
|
const animatedAverageStyle = useAnimatedStyle(() => ({
|
||||||
|
backgroundColor: interpolateColor(
|
||||||
|
colorChangeProgress.value,
|
||||||
|
[0, 1],
|
||||||
|
[startColor.value.primary, endColor.value.primary]
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const animatedPrimaryStyle = useAnimatedStyle(() => ({
|
||||||
|
backgroundColor: interpolateColor(
|
||||||
|
colorChangeProgress.value,
|
||||||
|
[0, 1],
|
||||||
|
[startColor.value.primary, endColor.value.primary]
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const animatedWidthStyle = useAnimatedStyle(() => ({
|
||||||
|
width: `${interpolate(
|
||||||
|
widthProgress.value,
|
||||||
|
[0, 1],
|
||||||
|
[startWidth.value, targetWidth.value]
|
||||||
|
)}%`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const animatedTextStyle = useAnimatedStyle(() => ({
|
||||||
|
color: interpolateColor(
|
||||||
|
colorChangeProgress.value,
|
||||||
|
[0, 1],
|
||||||
|
[startColor.value.text, endColor.value.text]
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
/**
|
||||||
|
* *********************
|
||||||
|
*/
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
accessibilityLabel="Play button"
|
||||||
|
accessibilityHint="Tap to play the media"
|
||||||
|
onPress={onPress}
|
||||||
|
className={`relative`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedPrimaryStyle,
|
||||||
|
animatedWidthStyle,
|
||||||
|
{
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||||
|
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colorAtom.primary,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
||||||
|
>
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||||
|
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||||
|
</Animated.Text>
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<Ionicons name="play-circle" size={24} />
|
||||||
|
</Animated.Text>
|
||||||
|
{settings?.openInVLC && (
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="vlc"
|
||||||
|
size={18}
|
||||||
|
color={animatedTextStyle.color}
|
||||||
|
/>
|
||||||
|
</Animated.Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||