Compare commits
123 Commits
feature/mp
...
refactor-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a41802f30e | ||
|
|
6686da2bea | ||
|
|
9597b40726 | ||
|
|
1e6408d5be | ||
|
|
c2f6897f47 | ||
|
|
eaf3682384 | ||
|
|
f3c7b636a8 | ||
|
|
64d34a9354 | ||
|
|
2a2ecf0526 | ||
|
|
a77c7e8e3c | ||
|
|
88791eccf9 | ||
|
|
515f7ea26d | ||
|
|
e83bbf3121 | ||
|
|
89b34eddc1 | ||
|
|
89fd7f0e34 | ||
|
|
ab9ae5b620 | ||
|
|
a9c519971e | ||
|
|
e51b7351f8 | ||
|
|
e0f9d6ea1c | ||
|
|
1817c5dbd2 | ||
|
|
0619c8c9c4 | ||
|
|
d6ed318eb8 | ||
|
|
5f39622ad6 | ||
|
|
3b2a6bd40a | ||
|
|
8d3e165edf | ||
|
|
f3a9fc9d1c | ||
|
|
820af06419 | ||
|
|
80192e65c4 | ||
|
|
ff930e2ad2 | ||
|
|
fafc2e65ac | ||
|
|
61c783fb55 | ||
|
|
59df18621b | ||
|
|
0021b94e00 | ||
|
|
53b43edc2a | ||
|
|
64e8514985 | ||
|
|
a3d9207bca | ||
|
|
ef0880695e | ||
|
|
073110fac9 | ||
|
|
2d58157cf7 | ||
|
|
571be9840f | ||
|
|
a2cbc722c7 | ||
|
|
bc7c612cca | ||
|
|
fe8f07336a | ||
|
|
305b06f781 | ||
|
|
7d57cf1a69 | ||
|
|
3c56544a24 | ||
|
|
d6696cc84e | ||
|
|
bf97e419ae | ||
|
|
1e8fe46f17 | ||
|
|
73317e9781 | ||
|
|
eba0bbc9cf | ||
|
|
c69ec61656 | ||
|
|
de12e2b0a2 | ||
|
|
87dc57a576 | ||
|
|
52c8b99dd5 | ||
|
|
7beabe4702 | ||
|
|
415d7d6e9a | ||
|
|
51b47971e2 | ||
|
|
90b0d413bc | ||
|
|
a18bcae0fb | ||
|
|
4ccffad3e7 | ||
|
|
46b08007a4 | ||
|
|
7b05fe43cf | ||
|
|
8f7749160e | ||
|
|
d4c51697d4 | ||
|
|
7091502667 | ||
|
|
d6c7246cd1 | ||
|
|
973d226c49 | ||
|
|
dd849b532b | ||
|
|
1a58df27d2 | ||
|
|
68b5fe3599 | ||
|
|
67f73bfa39 | ||
|
|
5de7cab285 | ||
|
|
67d39c39ea | ||
|
|
9d8e227609 | ||
|
|
962323a75c | ||
|
|
fc23201b4f | ||
|
|
f0519ea88d | ||
|
|
f9f21606ff | ||
|
|
c4d026f4d8 | ||
|
|
577827303e | ||
|
|
3e0a1af9fa | ||
|
|
63bc806a06 | ||
|
|
f05496a458 | ||
|
|
d3660b45b1 | ||
|
|
1b812ebed5 | ||
|
|
6703299da9 | ||
|
|
80d63c0219 | ||
|
|
c2f8145e74 | ||
|
|
ce00aeb5f1 | ||
|
|
5899cc8625 | ||
|
|
90217bb495 | ||
|
|
16e88cca8c | ||
|
|
e8e62061ae | ||
|
|
3adc4d2a21 | ||
|
|
185524c06c | ||
|
|
6a208ee201 | ||
|
|
99938ddf5a | ||
|
|
963a54a36c | ||
|
|
e939c9b933 | ||
|
|
2ffd569bba | ||
|
|
c8ea494d6f | ||
|
|
577a61a452 | ||
|
|
a731c4eebd | ||
|
|
8a664757b8 | ||
|
|
655a78900d | ||
|
|
87a33af8d1 | ||
|
|
36b1c48fdd | ||
|
|
0454ba9f29 | ||
|
|
b55ed6349c | ||
|
|
0c34add45a | ||
|
|
1c1345a3b7 | ||
|
|
9f706a348e | ||
|
|
f4750e781d | ||
|
|
0b574cc047 | ||
|
|
4a816470d1 | ||
|
|
0d43b57f55 | ||
|
|
31f662a582 | ||
|
|
23e0ec9774 | ||
|
|
d6ac8569a8 | ||
|
|
205715ae29 | ||
|
|
ffbaaa81a8 | ||
|
|
7201be6f02 |
14
.claude/settings.local.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(bun install:*)",
|
||||||
|
"Bash(bunx expo prebuild:*)",
|
||||||
|
"Bash(bunx expo run:*)",
|
||||||
|
"Bash(npx expo prebuild:*)",
|
||||||
|
"Bash(npx expo run:*)",
|
||||||
|
"Bash(xcodebuild:*)"
|
||||||
|
],
|
||||||
|
"deny": []
|
||||||
|
}
|
||||||
|
}
|
||||||
7
.cursor/rules/no-custom-ios-folder-logic.mdc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
description: Don't write code directly in the ios folder.
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
We never write code directly in the ios folder. This code is generated by expo plugins.
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["next/core-web-vitals"]
|
|
||||||
}
|
|
||||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -43,6 +43,7 @@ body:
|
|||||||
label: Version
|
label: Version
|
||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
|
- 0.29.0
|
||||||
- 0.28.0
|
- 0.28.0
|
||||||
- 0.27.0
|
- 0.27.0
|
||||||
- 0.26.1
|
- 0.26.1
|
||||||
|
|||||||
85
.github/workflows/build-android.yml
vendored
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
name: 🤖 Android APK Build (Phone + TV)
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
branches: [develop, master]
|
||||||
|
push:
|
||||||
|
branches: [develop, master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-android:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
name: 🏗️ Build Android APK
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
target: [phone, tv]
|
||||||
|
|
||||||
|
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: latest
|
||||||
|
|
||||||
|
- name: 💾 Cache Bun dependencies
|
||||||
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-cache-
|
||||||
|
|
||||||
|
- name: 📦 Install dependencies and reload submodules
|
||||||
|
run: |
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
bun run submodule-reload
|
||||||
|
|
||||||
|
- name: 💾 Cache Android dependencies
|
||||||
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
|
with:
|
||||||
|
path: android/.gradle
|
||||||
|
key: ${{ runner.os }}-android-deps-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-android-deps-
|
||||||
|
|
||||||
|
- name: 🛠️ Generate project files
|
||||||
|
run: |
|
||||||
|
if [ "${{ matrix.target }}" = "tv" ]; then
|
||||||
|
bun run prebuild:tv
|
||||||
|
else
|
||||||
|
bun run prebuild
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 🚀 Build APK
|
||||||
|
env:
|
||||||
|
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
|
||||||
|
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-android-${{ matrix.target }}-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
@@ -1,49 +0,0 @@
|
|||||||
name: Automatic Build and Deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: macos-15
|
|
||||||
name: Build IOS
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
name: Check out repository
|
|
||||||
- uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
- run: |
|
|
||||||
bun i && bun run submodule-reload
|
|
||||||
npx expo prebuild
|
|
||||||
- uses: sparkfabrik/ios-build-action@v2.3.0
|
|
||||||
with:
|
|
||||||
upload-to-testflight: false
|
|
||||||
increment-build-number: false
|
|
||||||
build-pods: true
|
|
||||||
pods-path: "ios/Podfile"
|
|
||||||
configuration: Release
|
|
||||||
# Change later to app-store if wanted
|
|
||||||
export-method: appstore
|
|
||||||
#export-method: ad-hoc
|
|
||||||
workspace-path: "ios/Streamyfin.xcodeproj/project.xcworkspace/"
|
|
||||||
project-path: "ios/Streamyfin.xcodeproj"
|
|
||||||
scheme: Streamyfin
|
|
||||||
apple-key-id: ${{ secrets.APPLE_KEY_ID }}
|
|
||||||
apple-key-issuer-id: ${{ secrets.APPLE_KEY_ISSUER_ID }}
|
|
||||||
apple-key-content: ${{ secrets.APPLE_KEY_CONTENT }}
|
|
||||||
team-id: ${{ secrets.TEAM_ID }}
|
|
||||||
team-name: ${{ secrets.TEAM_NAME }}
|
|
||||||
#match-password: ${{ secrets.MATCH_PASSWORD }}
|
|
||||||
#match-git-url: ${{ secrets.MATCH_GIT_URL }}
|
|
||||||
#match-git-basic-authorization: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
|
|
||||||
#match-build-type: "appstore"
|
|
||||||
#browserstack-upload: true
|
|
||||||
#browserstack-username: ${{ secrets.BROWSERSTACK_USERNAME }}
|
|
||||||
#browserstack-access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
|
|
||||||
#fastlane-env: stage
|
|
||||||
ios-app-id: com.stetsed.teststreamyfin
|
|
||||||
output-path: build-${{ github.sha }}.ipa
|
|
||||||
82
.github/workflows/build-ios.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
name: 🤖 iOS IPA Build (Phone + TV)
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
branches: [develop, master]
|
||||||
|
push:
|
||||||
|
branches: [develop, master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-ios:
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'
|
||||||
|
runs-on: macos-15
|
||||||
|
name: 🏗️ Build iOS IPA
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
target: [phone, tv]
|
||||||
|
|
||||||
|
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: latest
|
||||||
|
|
||||||
|
- name: 💾 Cache Bun dependencies
|
||||||
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-cache-
|
||||||
|
|
||||||
|
- name: 📦 Install dependencies and reload submodules
|
||||||
|
run: |
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
bun run submodule-reload
|
||||||
|
|
||||||
|
- name: 🛠️ Generate project files
|
||||||
|
run: |
|
||||||
|
if [ "${{ matrix.target }}" = "tv" ]; then
|
||||||
|
bun run prebuild:tv
|
||||||
|
else
|
||||||
|
bun run prebuild
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 🏗 Setup EAS
|
||||||
|
uses: expo/expo-github-action@main
|
||||||
|
with:
|
||||||
|
eas-version: 16.17.4
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
|
- name: 🏗️ Build iOS app
|
||||||
|
env:
|
||||||
|
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
|
||||||
|
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-ios-${{ matrix.target }}-ipa-${{ env.DATE_TAG }}
|
||||||
|
path: build-*.ipa
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
46
.github/workflows/check-lockfile.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
name: 🔒 Lockfile Consistency Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [develop, master]
|
||||||
|
push:
|
||||||
|
branches: [develop, master]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-lockfile:
|
||||||
|
name: 🔍 Check bun.lock and package.json consistency
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
show-progress: false
|
||||||
|
submodules: recursive
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: 🍞 Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: 💾 Cache Bun dependencies
|
||||||
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
|
|
||||||
|
- name: 🛡️ Verify lockfile consistency
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
echo "➡️ Checking for discrepancies between bun.lock and package.json..."
|
||||||
|
bun install --frozen-lockfile --dry-run --ignore-scripts
|
||||||
|
echo "✅ Lockfile is consistent with package.json!"
|
||||||
43
.github/workflows/ci-codeql.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: 🛡️ CodeQL Analysis
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [master, develop]
|
||||||
|
schedule:
|
||||||
|
- cron: '24 2 * * *'
|
||||||
|
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: 🔎 Analyze with CodeQL
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [ 'javascript-typescript' ]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
show-progress: false
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: 🏁 Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.8
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
|
- name: 🛠️ Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.8
|
||||||
|
|
||||||
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.8
|
||||||
24
.github/workflows/conflict.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: 🏷️🔀Merge Conflict Labeler
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [develop]
|
||||||
|
pull_request_target:
|
||||||
|
branches: [develop]
|
||||||
|
types: [synchronize]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label:
|
||||||
|
name: 🏷️ Labeling Merge Conflicts
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
if: ${{ github.repository == 'streamyfin/streamyfin' }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: 🚩 Apply merge conflict label
|
||||||
|
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||||
|
with:
|
||||||
|
dirtyLabel: 'merge-conflict'
|
||||||
|
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||||
|
repoToken: '${{ secrets.GITHUB_TOKEN }}'
|
||||||
41
.github/workflows/lint-pr.yaml
vendored
@@ -1,41 +0,0 @@
|
|||||||
name: "Lint PR"
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- edited
|
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
main:
|
|
||||||
name: Validate PR title
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: amannn/action-semantic-pull-request@v5
|
|
||||||
id: lint_pr_title
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- uses: marocchino/sticky-pull-request-comment@v2
|
|
||||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
|
||||||
with:
|
|
||||||
header: pr-title-lint-error
|
|
||||||
message: |
|
|
||||||
Hey there and thank you for opening this pull request! 👋🏼
|
|
||||||
|
|
||||||
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
|
|
||||||
|
|
||||||
Details:
|
|
||||||
|
|
||||||
```
|
|
||||||
${{ steps.lint_pr_title.outputs.error_message }}
|
|
||||||
```
|
|
||||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
|
||||||
uses: marocchino/sticky-pull-request-comment@v2
|
|
||||||
with:
|
|
||||||
header: pr-title-lint-error
|
|
||||||
delete: true
|
|
||||||
28
.github/workflows/lint.yaml
vendored
@@ -1,28 +0,0 @@
|
|||||||
name: Lint
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [ develop, master ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: '20.x'
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v1
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
- name: Run linting checks
|
|
||||||
run: bun run check
|
|
||||||
121
.github/workflows/linting.yml
vendored
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
name: 🚦 Security & Quality Gate
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, edited, synchronize, reopened]
|
||||||
|
branches: [develop, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [develop]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate_pr_title:
|
||||||
|
name: "📝 Validate PR Title"
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
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@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||||
|
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@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||||
|
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 }}
|
||||||
|
|
||||||
|
expo-doctor:
|
||||||
|
name: 🚑 Expo Doctor Check
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: 🛒 Checkout repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
submodules: recursive
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: 🍞 Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: 📦 Install dependencies (bun)
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: 🚑 Run Expo Doctor
|
||||||
|
run: bun expo-doctor
|
||||||
|
|
||||||
|
code_quality:
|
||||||
|
name: "🔍 Lint & Test (${{ matrix.command }})"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
command:
|
||||||
|
- "lint"
|
||||||
|
- "check"
|
||||||
|
- "format"
|
||||||
|
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: latest
|
||||||
|
|
||||||
|
- name: "📦 Install dependencies"
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: "🚨 Run ${{ matrix.command }}"
|
||||||
|
run: bun run ${{ matrix.command }}
|
||||||
39
.github/workflows/main.yml
vendored
@@ -1,39 +0,0 @@
|
|||||||
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
@@ -1,18 +0,0 @@
|
|||||||
name: Discord Pull Request Notification
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, reopened]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
notify:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: joelwmale/webhook-action@master
|
|
||||||
with:
|
|
||||||
url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
|
||||||
body: |
|
|
||||||
{
|
|
||||||
"content": "New Pull Request: ${{ github.event.pull_request.title }}\nBy: ${{ github.event.pull_request.user.login }}\n\n${{ github.event.pull_request.html_url }}",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/193271640"
|
|
||||||
}
|
|
||||||
23
.github/workflows/notification.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: 🛎️ Discord Pull Request Notification
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened]
|
||||||
|
branches: [develop]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notify:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: 🛎️ Notify Discord
|
||||||
|
uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
|
||||||
|
with:
|
||||||
|
args: |
|
||||||
|
📢 New Pull Request in **${{ github.repository }}**
|
||||||
|
**Title:** ${{ github.event.pull_request.title }}
|
||||||
|
**By:** ${{ github.event.pull_request.user.login }}
|
||||||
|
**Branch:** ${{ github.event.pull_request.head.ref }}
|
||||||
|
🔗 ${{ github.event.pull_request.html_url }}
|
||||||
49
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
name: 🕒 Handle Stale Issues
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Runs daily at 1:30 AM UTC (3:30 AM CEST - France time)
|
||||||
|
- cron: "30 1 * * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
stale-issues:
|
||||||
|
name: 🗑️ Cleanup Stale Issues
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 🔄 Mark/Close Stale Issues
|
||||||
|
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||||
|
with:
|
||||||
|
# Global settings
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
operations-per-run: 500 # Increase if you have >1000 issues
|
||||||
|
log-level: debug
|
||||||
|
|
||||||
|
# Issue configuration
|
||||||
|
days-before-issue-stale: 90
|
||||||
|
days-before-issue-close: 7
|
||||||
|
stale-issue-label: "stale"
|
||||||
|
exempt-issue-labels: "Roadmap v1,help needed,enhancement"
|
||||||
|
|
||||||
|
# Notifications messages
|
||||||
|
stale-issue-message: |
|
||||||
|
⏳ This issue has been automatically marked as **stale** because it has had no activity for 90 days.
|
||||||
|
|
||||||
|
**Next steps:**
|
||||||
|
- If this is still relevant, add a comment to keep it open
|
||||||
|
- Otherwise, it will be closed in 7 days
|
||||||
|
|
||||||
|
Thank you for your contributions! 🙌
|
||||||
|
|
||||||
|
close-issue-message: |
|
||||||
|
🚮 This issue has been automatically closed due to inactivity (7 days since being marked stale).
|
||||||
|
|
||||||
|
**Need to reopen?**
|
||||||
|
Click "Reopen" and add a comment explaining why this should stay open.
|
||||||
|
|
||||||
|
# Disable PR handling
|
||||||
|
days-before-pr-stale: -1
|
||||||
|
days-before-pr-close: -1
|
||||||
8
.gitignore
vendored
@@ -10,8 +10,6 @@ npm-debug.*
|
|||||||
*.orig.*
|
*.orig.*
|
||||||
web-build/
|
web-build/
|
||||||
modules/vlc-player/android/build
|
modules/vlc-player/android/build
|
||||||
modules/vlc-player/android/.gradle
|
|
||||||
bun.lockb
|
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -20,9 +18,7 @@ expo-env.d.ts
|
|||||||
|
|
||||||
Streamyfin.app
|
Streamyfin.app
|
||||||
|
|
||||||
build-*
|
|
||||||
*.mp4
|
*.mp4
|
||||||
build-*
|
|
||||||
Streamyfin.app
|
Streamyfin.app
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
@@ -47,4 +43,6 @@ credentials.json
|
|||||||
modules/hls-downloader/android/build
|
modules/hls-downloader/android/build
|
||||||
streamyfin-4fec1-firebase-adminsdk.json
|
streamyfin-4fec1-firebase-adminsdk.json
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
*.aab
|
||||||
|
/version-backup-*
|
||||||
|
|||||||
117
README.md
@@ -1,61 +1,70 @@
|
|||||||
# 📺 Streamyfin
|
|
||||||
|
|
||||||
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||||
|
|
||||||
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">
|
<p align="center">
|
||||||
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
|
||||||
<img width=150 src="./assets/images/screenshots/screenshot3.png" />
|
</p>
|
||||||
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
|
|
||||||
<img width=159 src="./assets/images/jellyseerr.PNG"/>
|
**Streamyfin is a simple, user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
|
||||||
</div>
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="./assets/images/screenshots/screenshot1.png" width="22%">
|
||||||
|
|
||||||
|
<img src="./assets/images/screenshots/screenshot3.png" width="22%">
|
||||||
|
|
||||||
|
<img src="./assets/images/screenshots/screenshot2.png" width="22%">
|
||||||
|
|
||||||
|
<img src="./assets/images/jellyseerr.PNG" width="23%">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
|
|
||||||
- 🚀 **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 are still in development, and we appreciate your patience and feedback as we work to improve them.
|
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.
|
||||||
|
|
||||||
### 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 and audio, but we're working on adding support for subtitles and other features.
|
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos, but we're working on adding support for subtitles and other features.
|
||||||
|
|
||||||
### Streamyfin Plugin
|
### 🧩 Streamyfin Plugin
|
||||||
|
|
||||||
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that hold all settings for the client Streamyfin. This allows you to syncronize settings accross all your users, like:
|
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
|
||||||
|
|
||||||
- Auto log in to Jellyseerr without the user having to do anythin
|
- Auto log in to Jellyseerr without the user having to do anything
|
||||||
- Choose the default languages
|
- Choose the default languages
|
||||||
- Set download method and search provider
|
- Set download method and search provider
|
||||||
- Customize homescreen
|
- Customize home screen
|
||||||
- And more...
|
- And much 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 for feedback and suggestions, so 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 to feedback and suggestions. 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 +73,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,7 +90,7 @@ 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`
|
||||||
@@ -107,17 +116,24 @@ Key points of the MPL-2.0:
|
|||||||
- You must disclose your source code for any modifications to the covered files
|
- You must disclose your source code for any modifications to the covered files
|
||||||
- Larger works may combine MPL code with code under other licenses
|
- Larger works may combine MPL code with code under other licenses
|
||||||
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
|
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
|
||||||
- For the full text of the license, please see the LICENSE file in this repository.
|
- For the full text of the license, please see the LICENSE file in this repository
|
||||||
|
|
||||||
## 🌐 Connect with Us
|
## 🌐 Connect with Us
|
||||||
|
|
||||||
Join our Discord: [https://discord.gg/aJvAYeycyY](https://discord.gg/aJvAYeycyY)
|
Join our Discord: [https://discord.gg/aJvAYeycyY](https://discord.gg/aJvAYeycyY)
|
||||||
|
|
||||||
If you have questions or need support, feel free to reach out:
|
Need support or have questions:
|
||||||
|
|
||||||
- GitHub Issues: Report bugs or request features here.
|
- GitHub Issues: Report bugs or request features here.
|
||||||
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
||||||
|
|
||||||
|
## ❓ 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.
|
||||||
@@ -128,81 +144,78 @@ We would like to thank the Jellyfin team for their great software and awesome su
|
|||||||
|
|
||||||
Special shoutout to the JF official clients for being an inspiration to ours.
|
Special shoutout to the JF official clients for being an inspiration to ours.
|
||||||
|
|
||||||
### Core Developers
|
### 🏆 Core Developers
|
||||||
|
|
||||||
Thanks to the following contributors for their significant contributions:
|
Thanks to the following contributors for their significant contributions:
|
||||||
|
|
||||||
|
<div align="left">
|
||||||
<table>
|
<table>
|
||||||
<tr
|
<tr>
|
||||||
style="
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-around;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/Alexk2309">
|
<a href="https://github.com/Alexk2309">
|
||||||
<img src="https://github.com/Alexk2309.png?size=80" width="80" style="border-radius: 50%;" />
|
<img src="https://github.com/Alexk2309.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
<br /><sub><b>@Alexk2309</b></sub>
|
<br /><sub><b>@Alexk2309</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/herrrta">
|
<a href="https://github.com/herrrta">
|
||||||
<img src="https://github.com/herrrta.png?size=80" width="80" style="border-radius: 50%;" />
|
<img src="https://github.com/herrrta.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
<br /><sub><b>@herrrta</b></sub>
|
<br /><sub><b>@herrrta</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/lostb1t">
|
<a href="https://github.com/lostb1t">
|
||||||
<img src="https://github.com/lostb1t.png?size=80" width="80" style="border-radius: 50%;" />
|
<img src="https://github.com/lostb1t.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
<br /><sub><b>@lostb1t</b></sub>
|
<br /><sub><b>@lostb1t</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/Simon-Eklundh">
|
<a href="https://github.com/Simon-Eklundh">
|
||||||
<img src="https://github.com/Simon-Eklundh.png?size=80" width="80" style="border-radius: 50%;" />
|
<img src="https://github.com/Simon-Eklundh.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
<br /><sub><b>@Simon-Eklundh</b></sub>
|
<br /><sub><b>@Simon-Eklundh</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/topiga">
|
<a href="https://github.com/topiga">
|
||||||
<img src="https://github.com/topiga.png?size=80" width="80" style="border-radius: 50%;" />
|
<img src="https://github.com/topiga.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
<br /><sub><b>@topiga</b></sub>
|
<br /><sub><b>@topiga</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/simoncaron">
|
<a href="https://github.com/simoncaron">
|
||||||
<img src="https://github.com/simoncaron.png?size=80" width="80" style="border-radius: 50%;" />
|
<img src="https://github.com/simoncaron.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
<br /><sub><b>@simoncaron</b></sub>
|
<br /><sub><b>@simoncaron</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/jakequade">
|
<a href="https://github.com/jakequade">
|
||||||
<img src="https://github.com/jakequade.png?size=80" width="80" style="border-radius: 50%;" />
|
<img src="https://github.com/jakequade.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
<br /><sub><b>@jakequade</b></sub>
|
<br /><sub><b>@jakequade</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/Ryan0204">
|
<a href="https://github.com/Ryan0204">
|
||||||
<img src="https://github.com/Ryan0204.png?size=80" width="80" style="border-radius: 50%;" />
|
<img src="https://github.com/Ryan0204.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
<br /><sub><b>@Ryan0204</b></sub>
|
<br /><sub><b>@Ryan0204</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/retardgerman">
|
<a href="https://github.com/retardgerman">
|
||||||
<img src="https://github.com/retardgerman.png?size=80" width="80" style="border-radius: 50%;" />
|
<img src="https://github.com/retardgerman.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
<br /><sub><b>@retardgerman</b></sub>
|
<br /><sub><b>@retardgerman</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/whoopsi-daisy">
|
<a href="https://github.com/whoopsi-daisy">
|
||||||
<img src="https://github.com/whoopsi-daisy.png?size=80" width="80" style="border-radius: 50%;" />
|
<img src="https://github.com/whoopsi-daisy.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
<br /><sub><b>@whoopsi-daisy</b></sub>
|
<br /><sub><b>@whoopsi-daisy</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
And all other developers who have contributed to Streamyfin, thank you for your contributions.
|
And all other developers who have contributed to Streamyfin, thank you for your contributions.
|
||||||
|
|
||||||
@@ -213,6 +226,12 @@ 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) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ module.exports = ({ config }) => {
|
|||||||
"react-native-google-cast",
|
"react-native-google-cast",
|
||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Add the background downloader plugin only for non-TV builds
|
||||||
|
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
android: {
|
android: {
|
||||||
|
|||||||
25
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.28.0",
|
"version": "0.29.13",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -27,14 +27,21 @@
|
|||||||
"usesNonExemptEncryption": false
|
"usesNonExemptEncryption": false
|
||||||
},
|
},
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.fredrikburmester.streamyfin"
|
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||||
|
"icon": {
|
||||||
|
"dark": "./assets/images/icon-ios-plain.png",
|
||||||
|
"light": "./assets/images/icon-ios-light.png",
|
||||||
|
"tinted": "./assets/images/icon-ios-tinted.png"
|
||||||
|
},
|
||||||
|
"appleTeamId": "MWD5K362T8"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 54,
|
"versionCode": 57,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive_icon.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"backgroundColor": "#464646"
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
|
"backgroundColor": "#2E2E2E"
|
||||||
},
|
},
|
||||||
"package": "com.fredrikburmester.streamyfin",
|
"package": "com.fredrikburmester.streamyfin",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
@@ -48,7 +55,6 @@
|
|||||||
"@react-native-tvos/config-tv",
|
"@react-native-tvos/config-tv",
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"@config-plugins/ffmpeg-kit-react-native",
|
|
||||||
[
|
[
|
||||||
"react-native-video",
|
"react-native-video",
|
||||||
{
|
{
|
||||||
@@ -108,7 +114,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
["react-native-bottom-tabs"],
|
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||||
["./plugins/withAndroidManifest.js"],
|
["./plugins/withAndroidManifest.js"],
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
@@ -117,7 +122,7 @@
|
|||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
"backgroundColor": "#2e2e2e",
|
"backgroundColor": "#2e2e2e",
|
||||||
"image": "./assets/images/StreamyFinFinal.png",
|
"image": "./assets/images/icon-ios-plain.png",
|
||||||
"imageWidth": 100
|
"imageWidth": 100
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -127,7 +132,9 @@
|
|||||||
"icon": "./assets/images/notification.png",
|
"icon": "./assets/images/notification.png",
|
||||||
"color": "#9333EA"
|
"color": "#9333EA"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"./plugins/with-runtime-framework-headers.js",
|
||||||
|
"react-native-bottom-tabs"
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
|
import { useAtom } from "jotai/index";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FlatList, Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
|
||||||
import { useAtom } from "jotai/index";
|
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { FlatList, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
|
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
|
||||||
export default function SearchLayout() {
|
export default function SearchLayout() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -10,7 +10,7 @@ export default function SearchLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.favorites"),
|
headerTitle: t("tabs.favorites"),
|
||||||
headerLargeStyle: {
|
headerLargeStyle: {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Favorites } from "@/components/home/Favorites";
|
import { useCallback, useState } from "react";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
|
||||||
import React, { useCallback, useState } from "react";
|
|
||||||
import { RefreshControl, ScrollView, View } from "react-native";
|
import { RefreshControl, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Favorites } from "@/components/home/Favorites";
|
||||||
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
|
||||||
export default function favorites() {
|
export default function favorites() {
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
|
||||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||||
|
|
||||||
|
import { useAtom } from "jotai";
|
||||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const router = useRouter();
|
const _router = useRouter();
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@ export default function IndexLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.home"),
|
headerTitle: t("tabs.home"),
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
||||||
import {
|
import {
|
||||||
@@ -6,11 +11,6 @@ import {
|
|||||||
} from "@/components/series/SeasonDropdown";
|
} from "@/components/series/SeasonDropdown";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect, useMemo, useRef, useState } 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 { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||||
@@ -8,36 +22,47 @@ import { type DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
|||||||
import { queueAtom } from "@/utils/atoms/queue";
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, { useEffect, useMemo, useRef } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
|
const { removeProcess, downloadedFiles, deleteFileByType, deleteAllFiles } =
|
||||||
|
useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
|
const [showMigration, setShowMigration] = useState(false);
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const migration_20241124 = () => {
|
||||||
|
Alert.alert(
|
||||||
|
t("home.downloads.new_app_version_requires_re_download"),
|
||||||
|
t("home.downloads.new_app_version_requires_re_download_description"),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: t("home.downloads.back"),
|
||||||
|
onPress: () => setShowMigration(false) || router.back(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("home.downloads.delete"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
await deleteAllFiles();
|
||||||
|
setShowMigration(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const movies = useMemo(() => {
|
const movies = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
||||||
} catch {
|
} catch {
|
||||||
migration_20241124();
|
setShowMigration(true);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [downloadedFiles]);
|
}, [downloadedFiles]);
|
||||||
@@ -54,13 +79,11 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
return Object.values(series);
|
return Object.values(series);
|
||||||
} catch {
|
} catch {
|
||||||
migration_20241124();
|
setShowMigration(true);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [downloadedFiles]);
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
@@ -71,6 +94,12 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
}, [downloadedFiles]);
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showMigration) {
|
||||||
|
migration_20241124();
|
||||||
|
}
|
||||||
|
}, [showMigration]);
|
||||||
|
|
||||||
const deleteMovies = () =>
|
const deleteMovies = () =>
|
||||||
deleteFileByType("Movie")
|
deleteFileByType("Movie")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
@@ -249,23 +278,3 @@ export default function page() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function migration_20241124() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { deleteAllFiles } = useDownload();
|
|
||||||
Alert.alert(
|
|
||||||
t("home.downloads.new_app_version_requires_re_download"),
|
|
||||||
t("home.downloads.new_app_version_requires_re_download_description"),
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: t("home.downloads.back"),
|
|
||||||
onPress: () => router.back(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("home.downloads.delete"),
|
|
||||||
style: "destructive",
|
|
||||||
onPress: async () => await deleteAllFiles(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useFocusEffect, useRouter } from "expo-router";
|
import { useFocusEffect, useRouter } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Linking, TouchableOpacity, View } from "react-native";
|
import { Linking, Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -19,7 +19,9 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='bg-neutral-900 h-full py-16 px-4 space-y-8'>
|
<View
|
||||||
|
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 space-y-4" : "py-16 space-y-8"} px-4`}
|
||||||
|
>
|
||||||
<View>
|
<View>
|
||||||
<Text className='text-3xl font-bold text-center mb-2'>
|
<Text className='text-3xl font-bold text-center mb-2'>
|
||||||
{t("home.intro.welcome_to_streamyfin")}
|
{t("home.intro.welcome_to_streamyfin")}
|
||||||
@@ -49,42 +51,50 @@ export default function page() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row items-center mt-4'>
|
{!Platform.isTV && (
|
||||||
<View
|
<>
|
||||||
style={{
|
<View className='flex flex-row items-center mt-4'>
|
||||||
width: 50,
|
<View
|
||||||
height: 50,
|
style={{
|
||||||
}}
|
width: 50,
|
||||||
className='flex items-center justify-center'
|
height: 50,
|
||||||
>
|
}}
|
||||||
<Ionicons name='cloud-download-outline' size={32} color='white' />
|
className='flex items-center justify-center'
|
||||||
</View>
|
>
|
||||||
<View className='shrink ml-2'>
|
<Ionicons
|
||||||
<Text className='font-bold mb-1'>
|
name='cloud-download-outline'
|
||||||
{t("home.intro.downloads_feature_title")}
|
size={32}
|
||||||
</Text>
|
color='white'
|
||||||
<Text className='shrink text-xs'>
|
/>
|
||||||
{t("home.intro.downloads_feature_description")}
|
</View>
|
||||||
</Text>
|
<View className='shrink ml-2'>
|
||||||
</View>
|
<Text className='font-bold mb-1'>
|
||||||
</View>
|
{t("home.intro.downloads_feature_title")}
|
||||||
<View className='flex flex-row items-center mt-4'>
|
</Text>
|
||||||
<View
|
<Text className='shrink text-xs'>
|
||||||
style={{
|
{t("home.intro.downloads_feature_description")}
|
||||||
width: 50,
|
</Text>
|
||||||
height: 50,
|
</View>
|
||||||
}}
|
</View>
|
||||||
className='flex items-center justify-center'
|
<View className='flex flex-row items-center mt-4'>
|
||||||
>
|
<View
|
||||||
<Feather name='cast' size={28} color={"white"} />
|
style={{
|
||||||
</View>
|
width: 50,
|
||||||
<View className='shrink ml-2'>
|
height: 50,
|
||||||
<Text className='font-bold mb-1'>Chromecast</Text>
|
}}
|
||||||
<Text className='shrink text-xs'>
|
className='flex items-center justify-center'
|
||||||
{t("home.intro.chromecast_feature_description")}
|
>
|
||||||
</Text>
|
<Feather name='cast' size={28} color={"white"} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
<View className='shrink ml-2'>
|
||||||
|
<Text className='font-bold mb-1'>Chromecast</Text>
|
||||||
|
<Text className='shrink text-xs'>
|
||||||
|
{t("home.intro.chromecast_feature_description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<View className='flex flex-row items-center mt-4'>
|
<View className='flex flex-row items-center mt-4'>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -99,19 +109,22 @@ export default function page() {
|
|||||||
<Text className='font-bold mb-1'>
|
<Text className='font-bold mb-1'>
|
||||||
{t("home.intro.centralised_settings_plugin_title")}
|
{t("home.intro.centralised_settings_plugin_title")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='shrink text-xs'>
|
<View className='flex-row flex-wrap items-baseline'>
|
||||||
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
<Text className='shrink text-xs'>
|
||||||
<Text
|
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
||||||
className='text-purple-600'
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
Linking.openURL(
|
Linking.openURL(
|
||||||
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
|
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("home.intro.read_more")}
|
<Text className='text-xs text-purple-600 underline'>
|
||||||
</Text>
|
{t("home.intro.read_more")}
|
||||||
</Text>
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
|
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 { Badge } from "@/components/Badge";
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
import Poster from "@/components/posters/Poster";
|
import Poster from "@/components/posters/Poster";
|
||||||
import { useInterval } from "@/hooks/useInterval";
|
import { useInterval } from "@/hooks/useInterval";
|
||||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
@@ -8,22 +24,6 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { formatBitrate } from "@/utils/bitrate";
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { formatTimeString } from "@/utils/time";
|
import { formatTimeString } from "@/utils/time";
|
||||||
import {
|
|
||||||
AntDesign,
|
|
||||||
Entypo,
|
|
||||||
Ionicons,
|
|
||||||
MaterialCommunityIcons,
|
|
||||||
} from "@expo/vector-icons";
|
|
||||||
import {
|
|
||||||
HardwareAccelerationType,
|
|
||||||
type SessionInfoDto,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { View } from "react-native";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
||||||
@@ -110,6 +110,77 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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 (
|
||||||
@@ -181,6 +252,107 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</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>
|
||||||
@@ -262,8 +434,6 @@ const TranscodingStreamView = ({
|
|||||||
isTranscoding,
|
isTranscoding,
|
||||||
properties,
|
properties,
|
||||||
transcodeProperties,
|
transcodeProperties,
|
||||||
value,
|
|
||||||
transcodeValue,
|
|
||||||
}: TranscodingStreamViewProps) => {
|
}: TranscodingStreamViewProps) => {
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col pt-2 first:pt-0'>
|
<View className='flex flex-col pt-2 first:pt-0'>
|
||||||
@@ -276,20 +446,18 @@ const TranscodingStreamView = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{isTranscoding && transcodeProperties ? (
|
{isTranscoding && transcodeProperties ? (
|
||||||
<>
|
<View className='flex flex-row'>
|
||||||
<View className='flex flex-row'>
|
<Text className='-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4'>
|
||||||
<Text className='-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4'>
|
<MaterialCommunityIcons
|
||||||
<MaterialCommunityIcons
|
name='arrow-right-bottom'
|
||||||
name='arrow-right-bottom'
|
size={14}
|
||||||
size={14}
|
color='white'
|
||||||
color='white'
|
/>
|
||||||
/>
|
</Text>
|
||||||
</Text>
|
<Text className='flex-1 text-sm mt-1'>
|
||||||
<Text className='flex-1 text-sm mt-1'>
|
<TranscodingBadges properties={transcodeProperties} />
|
||||||
<TranscodingBadges properties={transcodeProperties} />
|
</Text>
|
||||||
</Text>
|
</View>
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
@@ -14,21 +20,14 @@ import { StorageSettings } from "@/components/settings/StorageSettings";
|
|||||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||||
import { UserInfo } from "@/components/settings/UserInfo";
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useJellyfin } from "@/providers/JellyfinProvider";
|
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { clearLogs } from "@/utils/log";
|
import { clearLogs } from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import { ScrollView, Switch, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [user] = useAtom(userAtom);
|
const [_user] = useAtom(userAtom);
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch, View } from "react-native";
|
import { Switch, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
const [_settings, _updateSettings, pluginSettings] = useSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
|
||||||
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import * as Sharing from "expo-sharing";
|
import * as Sharing from "expo-sharing";
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import Collapsible from "react-native-collapsible";
|
import Collapsible from "react-native-collapsible";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -122,7 +122,7 @@ export default function page() {
|
|||||||
{new Date(log.timestamp).toLocaleString()}
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text uiTextView selectable className='text-xs'>
|
<Text selectable className='text-xs'>
|
||||||
{log.message}
|
{log.message}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -16,6 +10,11 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
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 DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
||||||
@@ -5,13 +12,6 @@ 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 { useTranslation } from "react-i18next";
|
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|||||||
@@ -1,15 +1,3 @@
|
|||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
import type { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -19,6 +7,18 @@ import { useAtom } from "jotai";
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||||
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
|
|||||||
@@ -1,22 +1,3 @@
|
|||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
SortByOption,
|
|
||||||
SortOrderOption,
|
|
||||||
genreFilterAtom,
|
|
||||||
sortByAtom,
|
|
||||||
sortOptions,
|
|
||||||
sortOrderAtom,
|
|
||||||
sortOrderOptions,
|
|
||||||
tagsFilterAtom,
|
|
||||||
yearFilterAtom,
|
|
||||||
} from "@/utils/atoms/filters";
|
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -35,6 +16,25 @@ import type React from "react";
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FlatList, View } from "react-native";
|
import { FlatList, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
genreFilterAtom,
|
||||||
|
SortByOption,
|
||||||
|
SortOrderOption,
|
||||||
|
sortByAtom,
|
||||||
|
sortOptions,
|
||||||
|
sortOrderAtom,
|
||||||
|
sortOrderOptions,
|
||||||
|
tagsFilterAtom,
|
||||||
|
yearFilterAtom,
|
||||||
|
} from "@/utils/atoms/filters";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -43,7 +43,7 @@ const page: React.FC = () => {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const [orientation, setOrientation] = useState(
|
const [orientation, _setOrientation] = useState(
|
||||||
ScreenOrientation.Orientation.PORTRAIT_UP,
|
ScreenOrientation.Orientation.PORTRAIT_UP,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import { ItemContent } from "@/components/ItemContent";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { 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";
|
||||||
@@ -15,6 +12,9 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ItemContent } from "@/components/ItemContent";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { uniqBy } from "lodash";
|
||||||
|
import { useMemo } from "react";
|
||||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
import {
|
import {
|
||||||
type MovieResult,
|
type MovieResult,
|
||||||
Results,
|
|
||||||
type TvResult,
|
type TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useLocalSearchParams } from "expo-router";
|
|
||||||
import { uniqBy } from "lodash";
|
|
||||||
import React, { useMemo } from "react";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
const { companyId, name, image, type } = local as unknown as {
|
const { companyId, image, type } = local as unknown as {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
name: string;
|
name: string;
|
||||||
image: string;
|
image: string;
|
||||||
@@ -99,7 +98,7 @@ export default function page() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
renderItem={(item, index) => (
|
renderItem={(item, _index) => (
|
||||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { uniqBy } from "lodash";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
|
||||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
|
||||||
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||||
|
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
import Poster from "@/components/posters/Poster";
|
|
||||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
import {
|
import {
|
||||||
type MovieResult,
|
type MovieResult,
|
||||||
Results,
|
|
||||||
type TvResult,
|
type TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import { router, useLocalSearchParams, useSegments } from "expo-router";
|
|
||||||
import { uniqBy } from "lodash";
|
|
||||||
import React, { useMemo } from "react";
|
|
||||||
import { TouchableOpacity } from "react-native";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
@@ -96,7 +92,7 @@ export default function page() {
|
|||||||
{name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
renderItem={(item, index) => (
|
renderItem={(item, _index) => (
|
||||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
|
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 { GenreTags } from "@/components/GenreTags";
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
|
import Cast from "@/components/jellyseerr/Cast";
|
||||||
|
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { JellyserrRatings } from "@/components/Ratings";
|
import { JellyserrRatings } from "@/components/Ratings";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import Cast from "@/components/jellyseerr/Cast";
|
|
||||||
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
|
||||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||||
import { ItemActions } from "@/components/series/SeriesActions";
|
import { ItemActions } from "@/components/series/SeriesActions";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
@@ -20,23 +36,9 @@ import type {
|
|||||||
TvResult,
|
TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
type 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 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";
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
@@ -46,6 +48,7 @@ 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, mediaType, ...result } =
|
||||||
params as unknown as {
|
params as unknown as {
|
||||||
@@ -218,11 +221,7 @@ const Page: React.FC = () => {
|
|||||||
| TvDetails
|
| TvDetails
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text selectable className='font-bold text-2xl mb-1'>
|
||||||
uiTextView
|
|
||||||
selectable
|
|
||||||
className='font-bold text-2xl mb-1'
|
|
||||||
>
|
|
||||||
{mediaTitle}
|
{mediaTitle}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='opacity-50'>{releaseYear}</Text>
|
<Text className='opacity-50'>{releaseYear}</Text>
|
||||||
@@ -236,30 +235,67 @@ const Page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className='mb-4'>
|
<View>
|
||||||
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
||||||
</View>
|
</View>
|
||||||
{isLoading || isFetching ? (
|
{isLoading || isFetching ? (
|
||||||
<Button loading={true} disabled={true} color='purple' />
|
<Button
|
||||||
|
loading={true}
|
||||||
|
disabled={true}
|
||||||
|
color='purple'
|
||||||
|
className='mt-4'
|
||||||
|
/>
|
||||||
) : canRequest ? (
|
) : canRequest ? (
|
||||||
<Button color='purple' onPress={request}>
|
<Button color='purple' onPress={request} className='mt-4'>
|
||||||
{t("jellyseerr.request_button")}
|
{t("jellyseerr.request_button")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
details?.mediaInfo?.jellyfinMediaId && (
|
||||||
className='bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
<View className='flex flex-row space-x-2 mt-4'>
|
||||||
color='transparent'
|
{!Platform.isTV && (
|
||||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
<Button
|
||||||
iconLeft={
|
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||||
<Ionicons name='warning-outline' size={24} color='white' />
|
color='transparent'
|
||||||
}
|
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||||
style={{
|
iconLeft={
|
||||||
borderWidth: 1,
|
<Ionicons
|
||||||
borderStyle: "solid",
|
name='warning-outline'
|
||||||
}}
|
size={20}
|
||||||
>
|
color='white'
|
||||||
{t("jellyseerr.report_issue_button")}
|
/>
|
||||||
</Button>
|
}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-sm'>
|
||||||
|
{t("jellyseerr.report_issue_button")}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
|
||||||
|
onPress={() => {
|
||||||
|
const url =
|
||||||
|
mediaType === MediaType.MOVIE
|
||||||
|
? `/(auth)/(tabs)/(search)/items/page?id=${details?.mediaInfo.jellyfinMediaId}`
|
||||||
|
: `/(auth)/(tabs)/(search)/series/${details?.mediaInfo.jellyfinMediaId}`;
|
||||||
|
// @ts-expect-error
|
||||||
|
router.push(url);
|
||||||
|
}}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name='play-outline' size={20} color='white' />
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-sm'>Play</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
<OverviewText text={result.overview} className='mt-4' />
|
<OverviewText text={result.overview} className='mt-4' />
|
||||||
</View>
|
</View>
|
||||||
@@ -295,92 +331,95 @@ const Page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
onDismiss={() => _setRequestBody(undefined)}
|
onDismiss={() => _setRequestBody(undefined)}
|
||||||
/>
|
/>
|
||||||
<BottomSheetModal
|
{!Platform.isTV && (
|
||||||
ref={bottomSheetModalRef}
|
// This is till it's fixed because the menu isn't selectable on TV
|
||||||
enableDynamicSizing
|
<BottomSheetModal
|
||||||
handleIndicatorStyle={{
|
ref={bottomSheetModalRef}
|
||||||
backgroundColor: "white",
|
enableDynamicSizing
|
||||||
}}
|
handleIndicatorStyle={{
|
||||||
backgroundStyle={{
|
backgroundColor: "white",
|
||||||
backgroundColor: "#171717",
|
}}
|
||||||
}}
|
backgroundStyle={{
|
||||||
backdropComponent={renderBackdrop}
|
backgroundColor: "#171717",
|
||||||
>
|
}}
|
||||||
<BottomSheetView>
|
backdropComponent={renderBackdrop}
|
||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
>
|
||||||
<View>
|
<BottomSheetView>
|
||||||
<Text className='font-bold text-2xl text-neutral-100'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
{t("jellyseerr.whats_wrong")}
|
<View>
|
||||||
</Text>
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
</View>
|
{t("jellyseerr.whats_wrong")}
|
||||||
<View className='flex flex-col space-y-2 items-start'>
|
</Text>
|
||||||
<View className='flex flex-col'>
|
</View>
|
||||||
<DropdownMenu.Root>
|
<View className='flex flex-col space-y-2 items-start'>
|
||||||
<DropdownMenu.Trigger>
|
<View className='flex flex-col'>
|
||||||
<View className='flex flex-col'>
|
<DropdownMenu.Root>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
<DropdownMenu.Trigger>
|
||||||
{t("jellyseerr.issue_type")}
|
<View className='flex flex-col'>
|
||||||
</Text>
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
{t("jellyseerr.issue_type")}
|
||||||
<Text style={{}} className='' numberOfLines={1}>
|
|
||||||
{issueType
|
|
||||||
? IssueTypeName[issueType]
|
|
||||||
: t("jellyseerr.select_an_issue")}
|
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
</View>
|
<Text style={{}} className='' numberOfLines={1}>
|
||||||
</DropdownMenu.Trigger>
|
{issueType
|
||||||
<DropdownMenu.Content
|
? IssueTypeName[issueType]
|
||||||
loop={false}
|
: t("jellyseerr.select_an_issue")}
|
||||||
side='bottom'
|
</Text>
|
||||||
align='center'
|
</TouchableOpacity>
|
||||||
alignOffset={0}
|
</View>
|
||||||
avoidCollisions={true}
|
</DropdownMenu.Trigger>
|
||||||
collisionPadding={0}
|
<DropdownMenu.Content
|
||||||
sideOffset={0}
|
loop={false}
|
||||||
>
|
side='bottom'
|
||||||
<DropdownMenu.Label>
|
align='center'
|
||||||
{t("jellyseerr.types")}
|
alignOffset={0}
|
||||||
</DropdownMenu.Label>
|
avoidCollisions={true}
|
||||||
{Object.entries(IssueTypeName)
|
collisionPadding={0}
|
||||||
.reverse()
|
sideOffset={0}
|
||||||
.map(([key, value], idx) => (
|
>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Label>
|
||||||
key={value}
|
{t("jellyseerr.types")}
|
||||||
onSelect={() =>
|
</DropdownMenu.Label>
|
||||||
setIssueType(key as unknown as IssueType)
|
{Object.entries(IssueTypeName)
|
||||||
}
|
.reverse()
|
||||||
>
|
.map(([key, value], _idx) => (
|
||||||
<DropdownMenu.ItemTitle>
|
<DropdownMenu.Item
|
||||||
{value}
|
key={value}
|
||||||
</DropdownMenu.ItemTitle>
|
onSelect={() =>
|
||||||
</DropdownMenu.Item>
|
setIssueType(key as unknown as IssueType)
|
||||||
))}
|
}
|
||||||
</DropdownMenu.Content>
|
>
|
||||||
</DropdownMenu.Root>
|
<DropdownMenu.ItemTitle>
|
||||||
</View>
|
{value}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
||||||
<BottomSheetTextInput
|
<BottomSheetTextInput
|
||||||
multiline
|
multiline
|
||||||
maxLength={254}
|
maxLength={254}
|
||||||
style={{ color: "white" }}
|
style={{ color: "white" }}
|
||||||
clearButtonMode='always'
|
clearButtonMode='always'
|
||||||
placeholder={t("jellyseerr.describe_the_issue")}
|
placeholder={t("jellyseerr.describe_the_issue")}
|
||||||
placeholderTextColor='#9CA3AF'
|
placeholderTextColor='#9CA3AF'
|
||||||
// Issue with multiline + Textinput inside a portal
|
// Issue with multiline + Textinput inside a portal
|
||||||
// https://github.com/callstack/react-native-paper/issues/1668
|
// https://github.com/callstack/react-native-paper/issues/1668
|
||||||
defaultValue={issueMessage}
|
defaultValue={issueMessage}
|
||||||
onChangeText={setIssueMessage}
|
onChangeText={setIssueMessage}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
||||||
|
{t("jellyseerr.submit_button")}
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
</BottomSheetView>
|
||||||
{t("jellyseerr.submit_button")}
|
</BottomSheetModal>
|
||||||
</Button>
|
)}
|
||||||
</View>
|
|
||||||
</BottomSheetView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { OverviewText } from "@/components/OverviewText";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { orderBy, uniqBy } from "lodash";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||||
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||||
@@ -8,12 +14,6 @@ import type {
|
|||||||
MovieResult,
|
MovieResult,
|
||||||
TvResult,
|
TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useLocalSearchParams, useSegments } from "expo-router";
|
|
||||||
import { orderBy, uniqBy } from "lodash";
|
|
||||||
import React, { useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
@@ -21,14 +21,13 @@ export default function page() {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
jellyseerrApi,
|
jellyseerrApi,
|
||||||
jellyseerrUser,
|
|
||||||
jellyseerrRegion: region,
|
jellyseerrRegion: region,
|
||||||
jellyseerrLocale: locale,
|
jellyseerrLocale: locale,
|
||||||
} = useJellyseerr();
|
} = useJellyseerr();
|
||||||
|
|
||||||
const { personId } = local as { personId: string };
|
const { personId } = local as { personId: string };
|
||||||
|
|
||||||
const { data, isLoading, isFetching } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ["jellyseerr", "person", personId],
|
queryKey: ["jellyseerr", "person", personId],
|
||||||
queryFn: async () => ({
|
queryFn: async () => ({
|
||||||
details: await jellyseerrApi?.personDetails(personId),
|
details: await jellyseerrApi?.personDetails(personId),
|
||||||
@@ -107,7 +106,7 @@ export default function page() {
|
|||||||
MainContent={() => (
|
MainContent={() => (
|
||||||
<OverviewText text={data?.details?.biography} className='mt-4' />
|
<OverviewText text={data?.details?.biography} className='mt-4' />
|
||||||
)}
|
)}
|
||||||
renderItem={(item, index) => (
|
renderItem={(item, _index) => (
|
||||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import type {
|
import {
|
||||||
|
createMaterialTopTabNavigator,
|
||||||
MaterialTopTabNavigationEventMap,
|
MaterialTopTabNavigationEventMap,
|
||||||
MaterialTopTabNavigationOptions,
|
MaterialTopTabNavigationOptions,
|
||||||
} from "@react-navigation/material-top-tabs";
|
} from "@react-navigation/material-top-tabs";
|
||||||
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
|
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
import { Stack, withLayoutContext } from "expo-router";
|
import { Stack, withLayoutContext } from "expo-router";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
const { Navigator } = createMaterialTopTabNavigator();
|
const { Navigator } = createMaterialTopTabNavigator();
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import { ItemImage } from "@/components/common/ItemImage";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React from "react";
|
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const _insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { data: channels } = useQuery({
|
const { data: channels } = useQuery({
|
||||||
queryKey: ["livetv", "channels"],
|
queryKey: ["livetv", "channels"],
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
import { ItemImage } from "@/components/common/ItemImage";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { HourHeader } from "@/components/livetv/HourHeader";
|
|
||||||
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
|
||||||
import { TAB_HEIGHT } from "@/constants/Values";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useMemo, useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { Dimensions, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
Button,
|
|
||||||
Dimensions,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { HourHeader } from "@/components/livetv/HourHeader";
|
||||||
|
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
const HOUR_HEIGHT = 30;
|
const HOUR_HEIGHT = 30;
|
||||||
const ITEMS_PER_PAGE = 20;
|
const ITEMS_PER_PAGE = 20;
|
||||||
@@ -28,17 +21,9 @@ export default function page() {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [date, setDate] = useState<Date>(new Date());
|
const [date, _setDate] = useState<Date>(new Date());
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
const { data: guideInfo } = useQuery({
|
|
||||||
queryKey: ["livetv", "guideInfo"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await getLiveTvApi(api!).getGuideInfo();
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: channels } = useQuery({
|
const { data: channels } = useQuery({
|
||||||
queryKey: ["livetv", "channels", currentPage],
|
queryKey: ["livetv", "channels", currentPage],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -150,7 +135,7 @@ export default function page() {
|
|||||||
>
|
>
|
||||||
<View className='flex flex-col'>
|
<View className='flex flex-col'>
|
||||||
<HourHeader height={HOUR_HEIGHT} />
|
<HourHeader height={HOUR_HEIGHT} />
|
||||||
{channels?.Items?.map((c, i) => (
|
{channels?.Items?.map((c, _i) => (
|
||||||
<MemoizedLiveTVGuideRow
|
<MemoizedLiveTVGuideRow
|
||||||
channel={c}
|
channel={c}
|
||||||
programs={programs?.Items}
|
programs={programs?.Items}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
|
||||||
import { TAB_HEIGHT } from "@/constants/Values";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -1,13 +1,3 @@
|
|||||||
import { AddToFavorites } from "@/components/AddToFavorites";
|
|
||||||
import { DownloadItems } from "@/components/DownloadItem";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import { NextUp } from "@/components/series/NextUp";
|
|
||||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
|
||||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -18,6 +8,16 @@ import type React from "react";
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
|
import { AddToFavorites } from "@/components/AddToFavorites";
|
||||||
|
import { DownloadItems } from "@/components/DownloadItem";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { NextUp } from "@/components/series/NextUp";
|
||||||
|
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||||
|
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|||||||
@@ -1,34 +1,3 @@
|
|||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, { useCallback, useEffect, useMemo } from "react";
|
|
||||||
import { FlatList, View, useWindowDimensions } from "react-native";
|
|
||||||
|
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
SortByOption,
|
|
||||||
SortOrderOption,
|
|
||||||
genreFilterAtom,
|
|
||||||
getSortByPreference,
|
|
||||||
getSortOrderPreference,
|
|
||||||
sortByAtom,
|
|
||||||
sortByPreferenceAtom,
|
|
||||||
sortOptions,
|
|
||||||
sortOrderAtom,
|
|
||||||
sortOrderOptions,
|
|
||||||
sortOrderPreferenceAtom,
|
|
||||||
tagsFilterAtom,
|
|
||||||
yearFilterAtom,
|
|
||||||
} from "@/utils/atoms/filters";
|
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -40,8 +9,38 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
genreFilterAtom,
|
||||||
|
getSortByPreference,
|
||||||
|
getSortOrderPreference,
|
||||||
|
SortByOption,
|
||||||
|
SortOrderOption,
|
||||||
|
sortByAtom,
|
||||||
|
sortByPreferenceAtom,
|
||||||
|
sortOptions,
|
||||||
|
sortOrderAtom,
|
||||||
|
sortOrderOptions,
|
||||||
|
sortOrderPreferenceAtom,
|
||||||
|
tagsFilterAtom,
|
||||||
|
yearFilterAtom,
|
||||||
|
} from "@/utils/atoms/filters";
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -433,15 +432,6 @@ const Page = () => {
|
|||||||
</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}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
@@ -18,7 +20,7 @@ export default function IndexLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.library"),
|
headerTitle: t("tabs.library"),
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
@@ -198,7 +200,7 @@ export default function IndexLayout() {
|
|||||||
name='[libraryId]'
|
name='[libraryId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -211,7 +213,7 @@ export default function IndexLayout() {
|
|||||||
name='collections/[collectionId]'
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import {
|
import {
|
||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
getUserViewsApi,
|
getUserViewsApi,
|
||||||
@@ -14,6 +9,11 @@ import { useEffect, useMemo } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { StyleSheet, View } from "react-native";
|
import { StyleSheet, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function index() {
|
export default function index() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import {
|
import {
|
||||||
commonScreenOptions,
|
commonScreenOptions,
|
||||||
nestedTabPageScreenOptions,
|
nestedTabPageScreenOptions,
|
||||||
} from "@/components/stacks/NestedTabPageStack";
|
} from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Stack } from "expo-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
|
|
||||||
export default function SearchLayout() {
|
export default function SearchLayout() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -13,7 +13,7 @@ export default function SearchLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.search"),
|
headerTitle: t("tabs.search"),
|
||||||
headerLargeStyle: {
|
headerLargeStyle: {
|
||||||
@@ -31,7 +31,7 @@ export default function SearchLayout() {
|
|||||||
name='collections/[collectionId]'
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
|
|||||||
@@ -1,9 +1,31 @@
|
|||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemKind,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import axios from "axios";
|
||||||
|
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import { Tag } from "@/components/GenreTags";
|
import { Input } from "@/components/common/Input";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { Tag } from "@/components/GenreTags";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import {
|
import {
|
||||||
JellyseerrSearchSort,
|
JellyseerrSearchSort,
|
||||||
JellyserrIndexPage,
|
JellyserrIndexPage,
|
||||||
@@ -16,27 +38,6 @@ import { useJellyseerr } from "@/hooks/useJellyseerr";
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
import type {
|
|
||||||
BaseItemDto,
|
|
||||||
BaseItemKind,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import axios from "axios";
|
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { useDebounce } from "use-debounce";
|
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
@@ -249,202 +250,223 @@ export default function search() {
|
|||||||
}, [l1, l2, l3, l7, l8]);
|
}, [l1, l2, l3, l7, l8]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ScrollView
|
||||||
<ScrollView
|
keyboardDismissMode='on-drag'
|
||||||
keyboardDismissMode='on-drag'
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentContainerStyle={{
|
||||||
contentContainerStyle={{
|
paddingLeft: insets.left,
|
||||||
paddingLeft: insets.left,
|
paddingRight: insets.right,
|
||||||
paddingRight: insets.right,
|
}}
|
||||||
|
>
|
||||||
|
{/* <View
|
||||||
|
className='flex flex-col'
|
||||||
|
style={{
|
||||||
|
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||||
|
}}
|
||||||
|
> */}
|
||||||
|
{Platform.isTV && (
|
||||||
|
<Input
|
||||||
|
placeholder={t("search.search")}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
router.setParams({ q: "" });
|
||||||
|
setSearch(text);
|
||||||
|
}}
|
||||||
|
keyboardType='default'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
clearButtonMode='while-editing'
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
className='flex flex-col'
|
||||||
|
style={{
|
||||||
|
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
{jellyseerrApi && (
|
||||||
className='flex flex-col'
|
<ScrollView
|
||||||
style={{
|
horizontal
|
||||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
||||||
}}
|
>
|
||||||
>
|
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||||
{jellyseerrApi && (
|
<Tag
|
||||||
<ScrollView
|
text={t("search.library")}
|
||||||
horizontal
|
textClass='p-1'
|
||||||
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
className={
|
||||||
>
|
searchType === "Library" ? "bg-purple-600" : undefined
|
||||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
}
|
||||||
<Tag
|
/>
|
||||||
text={t("search.library")}
|
</TouchableOpacity>
|
||||||
textClass='p-1'
|
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||||
className={
|
<Tag
|
||||||
searchType === "Library" ? "bg-purple-600" : undefined
|
text={t("search.discover")}
|
||||||
}
|
textClass='p-1'
|
||||||
/>
|
className={
|
||||||
</TouchableOpacity>
|
searchType === "Discover" ? "bg-purple-600" : undefined
|
||||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
}
|
||||||
<Tag
|
/>
|
||||||
text={t("search.discover")}
|
</TouchableOpacity>
|
||||||
textClass='p-1'
|
{searchType === "Discover" &&
|
||||||
className={
|
!loading &&
|
||||||
searchType === "Discover" ? "bg-purple-600" : undefined
|
noResults &&
|
||||||
}
|
debouncedSearch.length > 0 && (
|
||||||
/>
|
<View className='flex flex-row justify-end items-center space-x-1'>
|
||||||
</TouchableOpacity>
|
<FilterButton
|
||||||
{searchType === "Discover" &&
|
id='search'
|
||||||
!loading &&
|
queryKey='jellyseerr_search'
|
||||||
noResults &&
|
queryFn={async () =>
|
||||||
debouncedSearch.length > 0 && (
|
Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||||
<View className='flex flex-row justify-end items-center space-x-1'>
|
Number.isNaN(Number(v)),
|
||||||
<FilterButton
|
)
|
||||||
id='search'
|
}
|
||||||
queryKey='jellyseerr_search'
|
set={(value) => setJellyseerrOrderBy(value[0])}
|
||||||
queryFn={async () =>
|
values={[jellyseerrOrderBy]}
|
||||||
Object.keys(JellyseerrSearchSort).filter((v) =>
|
title={t("library.filters.sort_by")}
|
||||||
Number.isNaN(Number(v)),
|
renderItemLabel={(item) =>
|
||||||
)
|
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
||||||
}
|
}
|
||||||
set={(value) => setJellyseerrOrderBy(value[0])}
|
showSearch={false}
|
||||||
values={[jellyseerrOrderBy]}
|
/>
|
||||||
title={t("library.filters.sort_by")}
|
<FilterButton
|
||||||
renderItemLabel={(item) =>
|
id='order'
|
||||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
queryKey='jellysearr_search'
|
||||||
}
|
queryFn={async () => ["asc", "desc"]}
|
||||||
showSearch={false}
|
set={(value) => setJellyseerrSortOrder(value[0])}
|
||||||
/>
|
values={[jellyseerrSortOrder]}
|
||||||
<FilterButton
|
title={t("library.filters.sort_order")}
|
||||||
id='order'
|
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
||||||
queryKey='jellysearr_search'
|
showSearch={false}
|
||||||
queryFn={async () => ["asc", "desc"]}
|
/>
|
||||||
set={(value) => setJellyseerrSortOrder(value[0])}
|
</View>
|
||||||
values={[jellyseerrSortOrder]}
|
)}
|
||||||
title={t("library.filters.sort_order")}
|
</ScrollView>
|
||||||
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
)}
|
||||||
showSearch={false}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View className='mt-2'>
|
<View className='mt-2'>
|
||||||
<LoadingSkeleton isLoading={loading} />
|
<LoadingSkeleton isLoading={loading} />
|
||||||
</View>
|
|
||||||
|
|
||||||
{searchType === "Library" ? (
|
|
||||||
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
|
||||||
<SearchItemWrapper
|
|
||||||
header={t("search.movies")}
|
|
||||||
ids={movies?.map((m) => m.Id!)}
|
|
||||||
renderItem={(item: BaseItemDto) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
key={item.Id}
|
|
||||||
className='flex flex-col w-28 mr-2'
|
|
||||||
item={item}
|
|
||||||
>
|
|
||||||
<MoviePoster item={item} key={item.Id} />
|
|
||||||
<Text numberOfLines={2} className='mt-2'>
|
|
||||||
{item.Name}
|
|
||||||
</Text>
|
|
||||||
<Text className='opacity-50 text-xs'>
|
|
||||||
{item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SearchItemWrapper
|
|
||||||
ids={series?.map((m) => m.Id!)}
|
|
||||||
header={t("search.series")}
|
|
||||||
renderItem={(item: BaseItemDto) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
key={item.Id}
|
|
||||||
item={item}
|
|
||||||
className='flex flex-col w-28 mr-2'
|
|
||||||
>
|
|
||||||
<SeriesPoster item={item} key={item.Id} />
|
|
||||||
<Text numberOfLines={2} className='mt-2'>
|
|
||||||
{item.Name}
|
|
||||||
</Text>
|
|
||||||
<Text className='opacity-50 text-xs'>
|
|
||||||
{item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SearchItemWrapper
|
|
||||||
ids={episodes?.map((m) => m.Id!)}
|
|
||||||
header={t("search.episodes")}
|
|
||||||
renderItem={(item: BaseItemDto) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item}
|
|
||||||
key={item.Id}
|
|
||||||
className='flex flex-col w-44 mr-2'
|
|
||||||
>
|
|
||||||
<ContinueWatchingPoster item={item} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SearchItemWrapper
|
|
||||||
ids={collections?.map((m) => m.Id!)}
|
|
||||||
header={t("search.collections")}
|
|
||||||
renderItem={(item: BaseItemDto) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
key={item.Id}
|
|
||||||
item={item}
|
|
||||||
className='flex flex-col w-28 mr-2'
|
|
||||||
>
|
|
||||||
<MoviePoster item={item} key={item.Id} />
|
|
||||||
<Text numberOfLines={2} className='mt-2'>
|
|
||||||
{item.Name}
|
|
||||||
</Text>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SearchItemWrapper
|
|
||||||
ids={actors?.map((m) => m.Id!)}
|
|
||||||
header={t("search.actors")}
|
|
||||||
renderItem={(item: BaseItemDto) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item}
|
|
||||||
key={item.Id}
|
|
||||||
className='flex flex-col w-28 mr-2'
|
|
||||||
>
|
|
||||||
<MoviePoster item={item} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<JellyserrIndexPage
|
|
||||||
searchQuery={debouncedSearch}
|
|
||||||
sortType={jellyseerrOrderBy}
|
|
||||||
order={jellyseerrSortOrder}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{searchType === "Library" &&
|
|
||||||
(!loading && noResults && debouncedSearch.length > 0 ? (
|
|
||||||
<View>
|
|
||||||
<Text className='text-center text-lg font-bold mt-4'>
|
|
||||||
{t("search.no_results_found_for")}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-xs text-purple-600 text-center'>
|
|
||||||
"{debouncedSearch}"
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : debouncedSearch.length === 0 ? (
|
|
||||||
<View className='mt-4 flex flex-col items-center space-y-2'>
|
|
||||||
{exampleSearches.map((e) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setSearch(e)}
|
|
||||||
key={e}
|
|
||||||
className='mb-2'
|
|
||||||
>
|
|
||||||
<Text className='text-purple-600'>{e}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : null)}
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
|
||||||
</>
|
{searchType === "Library" ? (
|
||||||
|
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||||
|
<SearchItemWrapper
|
||||||
|
header={t("search.movies")}
|
||||||
|
items={movies}
|
||||||
|
renderItem={(item: BaseItemDto) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={item.Id}
|
||||||
|
className='flex flex-col w-28 mr-2'
|
||||||
|
item={item}
|
||||||
|
>
|
||||||
|
<MoviePoster item={item} key={item.Id} />
|
||||||
|
<Text numberOfLines={2} className='mt-2'>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='opacity-50 text-xs'>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
items={series}
|
||||||
|
header={t("search.series")}
|
||||||
|
renderItem={(item: BaseItemDto) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={item.Id}
|
||||||
|
item={item}
|
||||||
|
className='flex flex-col w-28 mr-2'
|
||||||
|
>
|
||||||
|
<SeriesPoster item={item} key={item.Id} />
|
||||||
|
<Text numberOfLines={2} className='mt-2'>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='opacity-50 text-xs'>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
items={episodes}
|
||||||
|
header={t("search.episodes")}
|
||||||
|
renderItem={(item: BaseItemDto) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
key={item.Id}
|
||||||
|
className='flex flex-col w-44 mr-2'
|
||||||
|
>
|
||||||
|
<ContinueWatchingPoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
items={collections}
|
||||||
|
header={t("search.collections")}
|
||||||
|
renderItem={(item: BaseItemDto) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={item.Id}
|
||||||
|
item={item}
|
||||||
|
className='flex flex-col w-28 mr-2'
|
||||||
|
>
|
||||||
|
<MoviePoster item={item} key={item.Id} />
|
||||||
|
<Text numberOfLines={2} className='mt-2'>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
items={actors}
|
||||||
|
header={t("search.actors")}
|
||||||
|
renderItem={(item: BaseItemDto) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
key={item.Id}
|
||||||
|
className='flex flex-col w-28 mr-2'
|
||||||
|
>
|
||||||
|
<MoviePoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<JellyserrIndexPage
|
||||||
|
searchQuery={debouncedSearch}
|
||||||
|
sortType={jellyseerrOrderBy}
|
||||||
|
order={jellyseerrSortOrder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchType === "Library" &&
|
||||||
|
(!loading && noResults && debouncedSearch.length > 0 ? (
|
||||||
|
<View>
|
||||||
|
<Text className='text-center text-lg font-bold mt-4'>
|
||||||
|
{t("search.no_results_found_for")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-xs text-purple-600 text-center'>
|
||||||
|
"{debouncedSearch}"
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : debouncedSearch.length === 0 ? (
|
||||||
|
<View className='mt-4 flex flex-col items-center space-y-2'>
|
||||||
|
{exampleSearches.map((e) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
setSearch(e);
|
||||||
|
searchBarRef.current?.setText(e);
|
||||||
|
}}
|
||||||
|
key={e}
|
||||||
|
className='mb-2'
|
||||||
|
>
|
||||||
|
<Text className='text-purple-600'>{e}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,26 @@
|
|||||||
import React, { useCallback, useRef } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
|
|
||||||
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type NativeBottomTabNavigationEventMap,
|
|
||||||
createNativeBottomTabNavigator,
|
createNativeBottomTabNavigator,
|
||||||
|
type NativeBottomTabNavigationEventMap,
|
||||||
|
type NativeBottomTabNavigationOptions,
|
||||||
} from "@bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
|
|
||||||
const { Navigator } = createNativeBottomTabNavigator();
|
|
||||||
import type { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
|
||||||
|
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { eventBus } from "@/utils/eventBus";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
|
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
|
const { Navigator } = createNativeBottomTabNavigator();
|
||||||
|
|
||||||
export const NativeTabs = withLayoutContext<
|
export const NativeTabs = withLayoutContext<
|
||||||
BottomTabNavigationOptions,
|
NativeBottomTabNavigationOptions,
|
||||||
typeof Navigator,
|
typeof Navigator,
|
||||||
TabNavigationState<ParamListBase>,
|
TabNavigationState<ParamListBase>,
|
||||||
NativeBottomTabNavigationEventMap
|
NativeBottomTabNavigationEventMap
|
||||||
@@ -54,7 +51,6 @@ export default function TabLayout() {
|
|||||||
<SystemBars hidden={false} style='light' />
|
<SystemBars hidden={false} style='light' />
|
||||||
<NativeTabs
|
<NativeTabs
|
||||||
sidebarAdaptable={false}
|
sidebarAdaptable={false}
|
||||||
ignoresTopSafeArea
|
|
||||||
tabBarStyle={{
|
tabBarStyle={{
|
||||||
backgroundColor: "#121212",
|
backgroundColor: "#121212",
|
||||||
}}
|
}}
|
||||||
@@ -63,8 +59,8 @@ export default function TabLayout() {
|
|||||||
>
|
>
|
||||||
<NativeTabs.Screen redirect name='index' />
|
<NativeTabs.Screen redirect name='index' />
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
listeners={({ navigation }) => ({
|
listeners={(_e) => ({
|
||||||
tabPress: (e) => {
|
tabPress: (_e) => {
|
||||||
eventBus.emit("scrollToTop");
|
eventBus.emit("scrollToTop");
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
@@ -73,8 +69,7 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.home"),
|
title: t("tabs.home"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ color, focused, size }) =>
|
? (_e) => require("@/assets/icons/house.fill.png")
|
||||||
require("@/assets/icons/house.fill.png")
|
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
focused
|
focused
|
||||||
? { sfSymbol: "house.fill" }
|
? { sfSymbol: "house.fill" }
|
||||||
@@ -82,8 +77,8 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
listeners={({ navigation }) => ({
|
listeners={(_e) => ({
|
||||||
tabPress: (e) => {
|
tabPress: (_e) => {
|
||||||
eventBus.emit("searchTabPressed");
|
eventBus.emit("searchTabPressed");
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
@@ -92,8 +87,7 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.search"),
|
title: t("tabs.search"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ color, focused, size }) =>
|
? (_e) => require("@/assets/icons/magnifyingglass.png")
|
||||||
require("@/assets/icons/magnifyingglass.png")
|
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
focused
|
focused
|
||||||
? { sfSymbol: "magnifyingglass" }
|
? { sfSymbol: "magnifyingglass" }
|
||||||
@@ -106,7 +100,7 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.favorites"),
|
title: t("tabs.favorites"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ focused }) =>
|
||||||
focused
|
focused
|
||||||
? require("@/assets/icons/heart.fill.png")
|
? require("@/assets/icons/heart.fill.png")
|
||||||
: require("@/assets/icons/heart.png")
|
: require("@/assets/icons/heart.png")
|
||||||
@@ -122,8 +116,7 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.library"),
|
title: t("tabs.library"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ color, focused, size }) =>
|
? (_e) => require("@/assets/icons/server.rack.png")
|
||||||
require("@/assets/icons/server.rack.png")
|
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
focused
|
focused
|
||||||
? { sfSymbol: "rectangle.stack.fill" }
|
? { sfSymbol: "rectangle.stack.fill" }
|
||||||
@@ -134,11 +127,10 @@ export default function TabLayout() {
|
|||||||
name='(custom-links)'
|
name='(custom-links)'
|
||||||
options={{
|
options={{
|
||||||
title: t("tabs.custom_links"),
|
title: t("tabs.custom_links"),
|
||||||
// @ts-expect-error
|
|
||||||
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ focused }) => require("@/assets/icons/list.png")
|
? (_e) => require("@/assets/icons/list.png")
|
||||||
: ({ focused }) =>
|
: ({ focused }) =>
|
||||||
focused
|
focused
|
||||||
? { sfSymbol: "list.dash.fill" }
|
? { sfSymbol: "list.dash.fill" }
|
||||||
|
|||||||
@@ -1,33 +1,7 @@
|
|||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import React, { useLayoutEffect } from "react";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (Platform.isTV) return;
|
|
||||||
|
|
||||||
if (!settings.followDeviceOrientation && settings.defaultVideoOrientation) {
|
|
||||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (Platform.isTV) return;
|
|
||||||
|
|
||||||
if (settings.followDeviceOrientation === true) {
|
|
||||||
ScreenOrientation.unlockAsync();
|
|
||||||
} else {
|
|
||||||
ScreenOrientation.lockAsync(
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden />
|
<SystemBars hidden />
|
||||||
|
|||||||
@@ -1,6 +1,34 @@
|
|||||||
|
/* eslint-disable react-native/no-inline-styles */
|
||||||
|
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,
|
||||||
|
useReducer,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Alert, Platform, View } from "react-native";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
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 { useHaptic } from "@/hooks/useHaptic";
|
||||||
@@ -17,69 +45,83 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import native from "@/utils/profiles/native";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import generateDeviceProfile from "@/utils/profiles/native";
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
import {
|
|
||||||
type BaseItemDto,
|
|
||||||
type MediaSourceInfo,
|
|
||||||
PlaybackOrder,
|
|
||||||
type PlaybackProgressInfo,
|
|
||||||
PlaybackStartInfo,
|
|
||||||
RepeatMode,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
|
||||||
getPlaystateApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
|
||||||
import { useGlobalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Alert, Platform, View } from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
const downloadProvider = !Platform.isTV
|
const downloadProvider = !Platform.isTV
|
||||||
? require("@/providers/DownloadProvider")
|
? require("@/providers/DownloadProvider")
|
||||||
: null;
|
: { useDownload: () => null };
|
||||||
|
|
||||||
export default function page() {
|
const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas";
|
||||||
|
|
||||||
|
/* Playback state reducer to consolidate related state */
|
||||||
|
interface VideoState {
|
||||||
|
isPlaying: boolean;
|
||||||
|
isMuted: boolean;
|
||||||
|
isBuffering: boolean;
|
||||||
|
isVideoLoaded: boolean;
|
||||||
|
isPipStarted: boolean;
|
||||||
|
}
|
||||||
|
type VideoAction =
|
||||||
|
| { type: "PLAYING_CHANGED"; value: boolean }
|
||||||
|
| { type: "BUFFERING_CHANGED"; value: boolean }
|
||||||
|
| { type: "VIDEO_LOADED" }
|
||||||
|
| { type: "MUTED_CHANGED"; value: boolean }
|
||||||
|
| { type: "PIP_CHANGED"; value: boolean };
|
||||||
|
|
||||||
|
const videoReducer = (state: VideoState, action: VideoAction): VideoState => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "PLAYING_CHANGED":
|
||||||
|
return { ...state, isPlaying: action.value };
|
||||||
|
case "BUFFERING_CHANGED":
|
||||||
|
return { ...state, isBuffering: action.value };
|
||||||
|
case "VIDEO_LOADED":
|
||||||
|
// Mark video as loaded and buffering false here
|
||||||
|
return { ...state, isVideoLoaded: true, isBuffering: false };
|
||||||
|
case "MUTED_CHANGED":
|
||||||
|
return { ...state, isMuted: action.value };
|
||||||
|
case "PIP_CHANGED":
|
||||||
|
return { ...state, isPipStarted: action.value };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialVideoState: VideoState = {
|
||||||
|
isPlaying: false,
|
||||||
|
isMuted: false,
|
||||||
|
isBuffering: true,
|
||||||
|
isVideoLoaded: false,
|
||||||
|
isPipStarted: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DirectPlayerPage() {
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
/* Consolidated video playback state */
|
||||||
|
const [videoState, dispatch] = useReducer(videoReducer, initialVideoState);
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(() => {
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
return storage.getBoolean(IGNORE_SAFE_AREAS_KEY) ?? false;
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
});
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [isPipStarted, setIsPipStarted] = useState(false);
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
const cacheProgress = useSharedValue(0);
|
const cacheProgress = useSharedValue(0);
|
||||||
let getDownloadedItem = null;
|
|
||||||
if (!Platform.isTV) {
|
|
||||||
getDownloadedItem = downloadProvider.useDownload();
|
|
||||||
}
|
|
||||||
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
|
||||||
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
const VolumeManager = Platform.isTV
|
||||||
const setShowControls = useCallback((show: boolean) => {
|
? null
|
||||||
_setShowControls(show);
|
: require("react-native-volume-manager");
|
||||||
lightHapticFeedback();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
itemId,
|
itemId,
|
||||||
@@ -88,6 +130,7 @@ 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;
|
||||||
@@ -95,62 +138,82 @@ export default function page() {
|
|||||||
mediaSourceId: string;
|
mediaSourceId: string;
|
||||||
bitrateValue: string;
|
bitrateValue: string;
|
||||||
offline: string;
|
offline: string;
|
||||||
|
playbackPosition?: string;
|
||||||
}>();
|
}>();
|
||||||
const [settings] = useSettings();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const offline = offlineStr === "true";
|
|
||||||
|
|
||||||
const audioIndex = audioIndexStr
|
const offline = offlineStr === "true";
|
||||||
? Number.parseInt(audioIndexStr, 10)
|
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||||
: undefined;
|
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
||||||
const subtitleIndex = subtitleIndexStr
|
|
||||||
? Number.parseInt(subtitleIndexStr, 10)
|
|
||||||
: -1;
|
|
||||||
const bitrateValue = bitrateValueStr
|
const bitrateValue = bitrateValueStr
|
||||||
? Number.parseInt(bitrateValueStr, 10)
|
? parseInt(bitrateValueStr, 10)
|
||||||
: BITRATES[0].value;
|
: BITRATES[0].value;
|
||||||
|
|
||||||
|
const setShowControls = useCallback(
|
||||||
|
(show: boolean) => {
|
||||||
|
_setShowControls(show);
|
||||||
|
lightHapticFeedback();
|
||||||
|
},
|
||||||
|
[lightHapticFeedback],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
storage.set(IGNORE_SAFE_AREAS_KEY, ignoreSafeAreas);
|
||||||
|
}, [ignoreSafeAreas]);
|
||||||
|
|
||||||
|
/* Fetch the item info */
|
||||||
const [item, setItem] = useState<BaseItemDto | null>(null);
|
const [item, setItem] = useState<BaseItemDto | null>(null);
|
||||||
const [itemStatus, setItemStatus] = useState({
|
const [itemStatus, setItemStatus] = useState({
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isError: false,
|
isError: false,
|
||||||
});
|
});
|
||||||
|
const getDownloadedItem = downloadProvider.useDownload();
|
||||||
|
|
||||||
|
const getInitialPlaybackTicks = useCallback((): number => {
|
||||||
|
if (playbackPositionFromUrl) {
|
||||||
|
return parseInt(playbackPositionFromUrl, 10);
|
||||||
|
}
|
||||||
|
return item?.UserData?.PlaybackPositionTicks ?? 0;
|
||||||
|
}, [playbackPositionFromUrl, item]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchItemData = async () => {
|
if (!itemId) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
(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 && !Platform.isTV) {
|
if (offline && !Platform.isTV) {
|
||||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||||
if (data) fetchedItem = data.item as BaseItemDto;
|
fetchedItem = data?.item as BaseItemDto | null;
|
||||||
} else {
|
} else {
|
||||||
const res = await getUserLibraryApi(api!).getItem({
|
const res = await getUserLibraryApi(api!).getItem(
|
||||||
itemId,
|
{ itemId, userId: user?.Id },
|
||||||
userId: user?.Id,
|
{ signal: controller.signal },
|
||||||
});
|
);
|
||||||
fetchedItem = res.data;
|
fetchedItem = res.data;
|
||||||
}
|
}
|
||||||
setItem(fetchedItem);
|
if (!controller.signal.aborted) {
|
||||||
|
setItem(fetchedItem);
|
||||||
|
setItemStatus({ isLoading: false, isError: false });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch item:", error);
|
if (!controller.signal.aborted) {
|
||||||
setItemStatus({ isLoading: false, isError: true });
|
console.error("Failed to fetch item:", error);
|
||||||
} finally {
|
setItemStatus({ isLoading: false, isError: true });
|
||||||
setItemStatus({ isLoading: false, isError: false });
|
}
|
||||||
}
|
}
|
||||||
};
|
})();
|
||||||
|
|
||||||
if (itemId) {
|
return () => controller.abort();
|
||||||
fetchItemData();
|
}, [itemId, offline, api, user?.Id, getDownloadedItem]);
|
||||||
}
|
|
||||||
}, [itemId, offline, api, user?.Id]);
|
|
||||||
|
|
||||||
|
/* Fetch stream info */
|
||||||
interface Stream {
|
interface Stream {
|
||||||
mediaSource: MediaSourceInfo;
|
mediaSource: MediaSourceInfo;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [stream, setStream] = useState<Stream | null>(null);
|
const [stream, setStream] = useState<Stream | null>(null);
|
||||||
const [streamStatus, setStreamStatus] = useState({
|
const [streamStatus, setStreamStatus] = useState({
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
@@ -159,24 +222,25 @@ export default function page() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStreamData = async () => {
|
const fetchStreamData = async () => {
|
||||||
|
setStreamStatus({ isLoading: true, isError: false });
|
||||||
try {
|
try {
|
||||||
|
const native = await generateDeviceProfile();
|
||||||
let result: Stream | null = null;
|
let result: Stream | null = null;
|
||||||
|
|
||||||
if (offline && !Platform.isTV) {
|
if (offline && !Platform.isTV) {
|
||||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||||
if (!data?.mediaSource) return;
|
if (!data?.mediaSource) return;
|
||||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||||
if (item) {
|
result = { mediaSource: data.mediaSource, sessionId: "", url };
|
||||||
result = { mediaSource: data.mediaSource, sessionId: "", url };
|
} else if (item) {
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const res = await getStreamUrl({
|
const res = await getStreamUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
startTimeTicks: getInitialPlaybackTicks(),
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
audioStreamIndex: audioIndex,
|
audioStreamIndex: audioIndex,
|
||||||
maxStreamingBitrate: bitrateValue,
|
maxStreamingBitrate: bitrateValue,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId,
|
||||||
subtitleStreamIndex: subtitleIndex,
|
subtitleStreamIndex: subtitleIndex,
|
||||||
deviceProfile: native,
|
deviceProfile: native,
|
||||||
});
|
});
|
||||||
@@ -191,254 +255,364 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
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,
|
||||||
|
offline,
|
||||||
|
getInitialPlaybackTicks,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
if (!stream) return;
|
|
||||||
|
|
||||||
const reportPlaybackStart = async () => {
|
/* Memoized playback state info for reporting */
|
||||||
await getPlaystateApi(api!).reportPlaybackStart({
|
const currentPlayStateInfo = useMemo(() => {
|
||||||
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
if (!stream) return null;
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
reportPlaybackStart();
|
|
||||||
}, [stream]);
|
|
||||||
|
|
||||||
const togglePlay = async () => {
|
|
||||||
lightHapticFeedback();
|
|
||||||
setIsPlaying(!isPlaying);
|
|
||||||
if (isPlaying) {
|
|
||||||
await videoRef.current?.pause();
|
|
||||||
reportPlaybackStopped();
|
|
||||||
} else {
|
|
||||||
videoRef.current?.play();
|
|
||||||
await getPlaystateApi(api!).reportPlaybackStart({
|
|
||||||
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const reportPlaybackStopped = useCallback(async () => {
|
|
||||||
if (offline) return;
|
|
||||||
const currentTimeInTicks = msToTicks(progress.get());
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: currentTimeInTicks,
|
|
||||||
playSessionId: stream?.sessionId!,
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidateProgressCache();
|
|
||||||
}, [api, item, mediaSourceId, stream]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
reportPlaybackStopped();
|
|
||||||
setIsPlaybackStopped(true);
|
|
||||||
videoRef.current?.stop();
|
|
||||||
}, [videoRef, reportPlaybackStopped]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
|
||||||
return () => {
|
|
||||||
beforeRemoveListener();
|
|
||||||
};
|
|
||||||
}, [navigation, stop]);
|
|
||||||
|
|
||||||
const currentPlayStateInfo = () => {
|
|
||||||
if (!stream) return;
|
|
||||||
return {
|
return {
|
||||||
itemId: item?.Id!,
|
itemId: item?.Id!,
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
audioStreamIndex: audioIndex,
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
subtitleStreamIndex: subtitleIndex,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId,
|
||||||
positionTicks: msToTicks(progress.get()),
|
positionTicks: msToTicks(progress.get()),
|
||||||
isPaused: !isPlaying,
|
isPaused: !videoState.isPlaying,
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
playMethod: stream.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
playSessionId: stream.sessionId,
|
playSessionId: stream.sessionId,
|
||||||
isMuted: false,
|
isMuted: videoState.isMuted,
|
||||||
canSeek: true,
|
canSeek: true,
|
||||||
repeatMode: RepeatMode.RepeatNone,
|
repeatMode: RepeatMode.RepeatNone,
|
||||||
playbackOrder: PlaybackOrder.Default,
|
playbackOrder: PlaybackOrder.Default,
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
|
||||||
async (data: ProgressUpdatePayload) => {
|
|
||||||
if (isSeeking.get() || isPlaybackStopped) return;
|
|
||||||
|
|
||||||
const { currentTime } = data.nativeEvent;
|
|
||||||
if (isBuffering) {
|
|
||||||
setIsBuffering(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.set(currentTime);
|
|
||||||
|
|
||||||
if (offline) return;
|
|
||||||
|
|
||||||
if (!item?.Id || !stream) return;
|
|
||||||
|
|
||||||
reportPlaybackProgress();
|
|
||||||
},
|
|
||||||
[
|
|
||||||
item?.Id,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
isPlaying,
|
|
||||||
stream,
|
|
||||||
isSeeking,
|
|
||||||
isPlaybackStopped,
|
|
||||||
isBuffering,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPipStarted = useCallback((e: PipStartedPayload) => {
|
|
||||||
const { pipStarted } = e.nativeEvent;
|
|
||||||
setIsPipStarted(pipStarted);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const reportPlaybackProgress = useCallback(async () => {
|
|
||||||
if (!api || offline || !stream) return;
|
|
||||||
await getPlaystateApi(api).reportPlaybackProgress({
|
|
||||||
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
|
|
||||||
});
|
|
||||||
}, [
|
}, [
|
||||||
api,
|
|
||||||
isPlaying,
|
|
||||||
offline,
|
|
||||||
stream,
|
|
||||||
item?.Id,
|
item?.Id,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
progress,
|
progress,
|
||||||
|
videoState.isPlaying,
|
||||||
|
videoState.isMuted,
|
||||||
|
stream,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const startPosition = useMemo(() => {
|
/* Playback progress reporting */
|
||||||
if (offline) return 0;
|
const reportPlaybackProgress = useCallback(async () => {
|
||||||
return item?.UserData?.PlaybackPositionTicks
|
if (!api || offline || !stream || !currentPlayStateInfo) return;
|
||||||
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
await getPlaystateApi(api).reportPlaybackProgress({
|
||||||
: 0;
|
playbackProgressInfo: currentPlayStateInfo as PlaybackProgressInfo,
|
||||||
}, [item]);
|
});
|
||||||
|
}, [api, offline, stream, currentPlayStateInfo]);
|
||||||
|
|
||||||
useWebSocket({
|
/* Report playback stopped */
|
||||||
isPlaying: isPlaying,
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
togglePlay: togglePlay,
|
if (offline || !stream) return;
|
||||||
stopPlayback: stop,
|
await getPlaystateApi(api!).onPlaybackStopped({
|
||||||
|
itemId: item?.Id!,
|
||||||
|
mediaSourceId,
|
||||||
|
positionTicks: msToTicks(progress.get()),
|
||||||
|
playSessionId: stream.sessionId,
|
||||||
|
});
|
||||||
|
revalidateProgressCache();
|
||||||
|
}, [
|
||||||
|
api,
|
||||||
|
item?.Id,
|
||||||
|
mediaSourceId,
|
||||||
|
progress,
|
||||||
|
stream,
|
||||||
offline,
|
offline,
|
||||||
});
|
revalidateProgressCache,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/* Toggle play/pause */
|
||||||
|
const togglePlay = useCallback(async () => {
|
||||||
|
lightHapticFeedback();
|
||||||
|
const playing = videoState.isPlaying;
|
||||||
|
dispatch({ type: "PLAYING_CHANGED", value: !playing });
|
||||||
|
|
||||||
|
if (playing) {
|
||||||
|
await videoRef.current?.pause();
|
||||||
|
reportPlaybackProgress();
|
||||||
|
} else {
|
||||||
|
await videoRef.current?.play();
|
||||||
|
if (currentPlayStateInfo) {
|
||||||
|
await getPlaystateApi(api!).reportPlaybackStart({
|
||||||
|
playbackStartInfo: currentPlayStateInfo as PlaybackStartInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
videoState.isPlaying,
|
||||||
|
lightHapticFeedback,
|
||||||
|
reportPlaybackProgress,
|
||||||
|
api,
|
||||||
|
currentPlayStateInfo,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/* Stop playback and clean up */
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
reportPlaybackStopped();
|
||||||
|
setIsPlaybackStopped(true);
|
||||||
|
videoRef.current?.stop();
|
||||||
|
}, [reportPlaybackStopped]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = navigation.addListener("beforeRemove", stop);
|
||||||
|
return unsubscribe;
|
||||||
|
}, [navigation, stop]);
|
||||||
|
|
||||||
|
/* VLC init options optimized for performance */
|
||||||
|
const optimizedInitOptions = useMemo(() => {
|
||||||
|
const opts = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||||
|
// Reduce buffering memory usage
|
||||||
|
opts.push("--network-caching=300", "--file-caching=300");
|
||||||
|
if (Platform.OS === "android") opts.push("--aout=opensles");
|
||||||
|
if (Platform.OS === "ios") opts.push("--ios-hw-decoding");
|
||||||
|
|
||||||
|
// Pre-selection of audio & subtitle tracks handled here
|
||||||
|
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||||
|
const allAudio =
|
||||||
|
stream?.mediaSource.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
|
||||||
|
const allSubs =
|
||||||
|
stream?.mediaSource.MediaStreams?.filter(
|
||||||
|
(s) => s.Type === "Subtitle",
|
||||||
|
)?.sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) ?? [];
|
||||||
|
|
||||||
|
if (subtitleIndex >= 0) {
|
||||||
|
const chosenSubtitleTrack = allSubs.find(
|
||||||
|
(s) => s.Index === subtitleIndex,
|
||||||
|
);
|
||||||
|
const textSubs = allSubs.filter((s) => s.IsTextSubtitleStream);
|
||||||
|
if (
|
||||||
|
chosenSubtitleTrack &&
|
||||||
|
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||||
|
) {
|
||||||
|
const finalIdx = notTranscoding
|
||||||
|
? allSubs.indexOf(chosenSubtitleTrack)
|
||||||
|
: textSubs.indexOf(chosenSubtitleTrack);
|
||||||
|
opts.push(`--sub-track=${finalIdx}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (notTranscoding && audioIndex !== undefined) {
|
||||||
|
const chosenAudioTrack = allAudio.find((a) => a.Index === audioIndex);
|
||||||
|
if (chosenAudioTrack)
|
||||||
|
opts.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts;
|
||||||
|
}, [settings.subtitleSize, stream?.mediaSource, subtitleIndex, audioIndex]);
|
||||||
|
|
||||||
|
/* On Picture-In-Picture started or stopped */
|
||||||
|
const onPipStarted = useCallback((e: PipStartedPayload) => {
|
||||||
|
dispatch({ type: "PIP_CHANGED", value: e.nativeEvent.pipStarted });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* Progress event handler */
|
||||||
|
const onProgress = useCallback(
|
||||||
|
(data: ProgressUpdatePayload) => {
|
||||||
|
if (isSeeking.get() || isPlaybackStopped) return;
|
||||||
|
if (videoState.isBuffering)
|
||||||
|
dispatch({ type: "BUFFERING_CHANGED", value: false });
|
||||||
|
const { currentTime } = data.nativeEvent;
|
||||||
|
progress.set(currentTime);
|
||||||
|
router.setParams({ playbackPosition: msToTicks(currentTime).toString() });
|
||||||
|
if (!offline) reportPlaybackProgress();
|
||||||
|
},
|
||||||
|
[
|
||||||
|
isSeeking,
|
||||||
|
isPlaybackStopped,
|
||||||
|
progress,
|
||||||
|
offline,
|
||||||
|
reportPlaybackProgress,
|
||||||
|
videoState.isBuffering,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Playback state changes */
|
||||||
const onPlaybackStateChanged = useCallback(
|
const onPlaybackStateChanged = useCallback(
|
||||||
async (e: PlaybackStatePayload) => {
|
async (e: PlaybackStatePayload) => {
|
||||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||||
if (state === "Playing") {
|
switch (state) {
|
||||||
setIsPlaying(true);
|
case "Playing":
|
||||||
reportPlaybackProgress();
|
dispatch({ type: "PLAYING_CHANGED", value: true });
|
||||||
if (!Platform.isTV) await activateKeepAwakeAsync();
|
await activateKeepAwakeAsync();
|
||||||
return;
|
reportPlaybackProgress();
|
||||||
}
|
break;
|
||||||
|
case "Paused":
|
||||||
if (state === "Paused") {
|
dispatch({ type: "PLAYING_CHANGED", value: false });
|
||||||
setIsPlaying(false);
|
await deactivateKeepAwake();
|
||||||
reportPlaybackProgress();
|
reportPlaybackProgress();
|
||||||
if (!Platform.isTV) await deactivateKeepAwake();
|
break;
|
||||||
return;
|
default:
|
||||||
}
|
dispatch({ type: "BUFFERING_CHANGED", value: !!isBuffering });
|
||||||
|
dispatch({ type: "PLAYING_CHANGED", value: !!isPlaying });
|
||||||
if (isPlaying) {
|
|
||||||
setIsPlaying(true);
|
|
||||||
setIsBuffering(false);
|
|
||||||
} else if (isBuffering) {
|
|
||||||
setIsBuffering(true);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[reportPlaybackProgress],
|
[reportPlaybackProgress],
|
||||||
);
|
);
|
||||||
|
|
||||||
const allAudio =
|
/* Safe wrapper for player methods that skips calls if video not loaded */
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
const safeMethod =
|
||||||
(audio) => audio.Type === "Audio",
|
<T extends unknown[]>(
|
||||||
) || [];
|
fn: ((...args: T) => any) | undefined,
|
||||||
|
name: string,
|
||||||
|
) =>
|
||||||
|
async (...args: T) => {
|
||||||
|
// New safeguard: skip calling if video not loaded yet
|
||||||
|
if (!videoState.isVideoLoaded) {
|
||||||
|
writeToLog("WARN", `${name} skipped - video not loaded yet`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fn) {
|
||||||
|
writeToLog("ERROR", `${name} fn missing`, {
|
||||||
|
isVideoLoaded: videoState.isVideoLoaded,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await fn(...args);
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", `Error in ${name}`, {
|
||||||
|
error,
|
||||||
|
isVideoLoaded: videoState.isVideoLoaded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Move all the external subtitles last, because vlc places them last.
|
const play = useCallback(
|
||||||
const allSubs =
|
() => safeMethod(videoRef.current?.play, "play")(),
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
[videoRef],
|
||||||
(sub) => sub.Type === "Subtitle",
|
);
|
||||||
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
|
const pause = useCallback(
|
||||||
|
() => safeMethod(videoRef.current?.pause, "pause")(),
|
||||||
const externalSubtitles = allSubs
|
[videoRef],
|
||||||
.filter((sub: any) => sub.DeliveryMethod === "External")
|
);
|
||||||
.map((sub: any) => ({
|
const startPictureInPicture = useCallback(
|
||||||
name: sub.DisplayTitle,
|
() => safeMethod(videoRef.current?.startPictureInPicture, "PiP")(),
|
||||||
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
|
[videoRef],
|
||||||
}));
|
);
|
||||||
|
const seek = useCallback(
|
||||||
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
(t: number) => safeMethod(videoRef.current?.seekTo, "seek")(t),
|
||||||
|
[videoRef],
|
||||||
const chosenSubtitleTrack = allSubs.find(
|
);
|
||||||
(sub) => sub.Index === subtitleIndex,
|
const getAudioTracks = useCallback(
|
||||||
|
() => safeMethod(videoRef.current?.getAudioTracks, "getAudioTracks")(),
|
||||||
|
[videoRef],
|
||||||
|
);
|
||||||
|
const getSubtitleTracks = useCallback(
|
||||||
|
() =>
|
||||||
|
safeMethod(videoRef.current?.getSubtitleTracks, "getSubtitleTracks")(),
|
||||||
|
[videoRef],
|
||||||
|
);
|
||||||
|
const setAudioTrack = useCallback(
|
||||||
|
(i: number) =>
|
||||||
|
safeMethod(videoRef.current?.setAudioTrack, "setAudioTrack")(i),
|
||||||
|
[videoRef],
|
||||||
|
);
|
||||||
|
const setSubtitleTrack = useCallback(
|
||||||
|
(i: number) =>
|
||||||
|
safeMethod(videoRef.current?.setSubtitleTrack, "setSubtitleTrack")(i),
|
||||||
|
[videoRef],
|
||||||
|
);
|
||||||
|
const setSubtitleURL = useCallback(
|
||||||
|
(url: string, n: string) =>
|
||||||
|
safeMethod(videoRef.current?.setSubtitleURL, "setSubtitleURL")(url, n),
|
||||||
|
[videoRef],
|
||||||
);
|
);
|
||||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
|
||||||
|
|
||||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
/* Volume handlers */
|
||||||
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
const [previousVolume, setPreviousVolume] = useState<number | null>(null);
|
||||||
if (
|
const volumeUpCb = useCallback(async () => {
|
||||||
chosenSubtitleTrack &&
|
if (Platform.isTV) return;
|
||||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
const { volume } = await VolumeManager.getVolume();
|
||||||
) {
|
await VolumeManager.setVolume(Math.min(volume + 0.1, 1));
|
||||||
const finalIndex = notTranscoding
|
|
||||||
? allSubs.indexOf(chosenSubtitleTrack)
|
|
||||||
: textSubs.indexOf(chosenSubtitleTrack);
|
|
||||||
initOptions.push(`--sub-track=${finalIndex}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notTranscoding && chosenAudioTrack) {
|
|
||||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
|
||||||
|
|
||||||
// Add useEffect to handle mounting
|
|
||||||
useEffect(() => {
|
|
||||||
setIsMounted(true);
|
|
||||||
return () => setIsMounted(false);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
const volumeDownCb = useCallback(async () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
const { volume } = await VolumeManager.getVolume();
|
||||||
|
await VolumeManager.setVolume(Math.max(volume - 0.1, 0));
|
||||||
|
}, []);
|
||||||
|
const setVolumeCb = useCallback(async (v: number) => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
await VolumeManager.setVolume(Math.max(0, Math.min(v, 100)) / 100);
|
||||||
|
}, []);
|
||||||
|
const toggleMuteCb = useCallback(async () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
const { volume } = await VolumeManager.getVolume();
|
||||||
|
const percent = volume * 100;
|
||||||
|
if (percent > 0) {
|
||||||
|
setPreviousVolume(percent);
|
||||||
|
await VolumeManager.setVolume(0);
|
||||||
|
dispatch({ type: "MUTED_CHANGED", value: true });
|
||||||
|
} else {
|
||||||
|
const restore = previousVolume || 50;
|
||||||
|
await VolumeManager.setVolume(restore / 100);
|
||||||
|
setPreviousVolume(null);
|
||||||
|
dispatch({ type: "MUTED_CHANGED", value: false });
|
||||||
|
}
|
||||||
|
}, [previousVolume]);
|
||||||
|
|
||||||
if (itemStatus.isLoading || streamStatus.isLoading) {
|
useWebSocket({
|
||||||
|
isPlaying: videoState.isPlaying,
|
||||||
|
togglePlay,
|
||||||
|
stopPlayback: stop,
|
||||||
|
offline,
|
||||||
|
toggleMute: toggleMuteCb,
|
||||||
|
volumeUp: volumeUpCb,
|
||||||
|
volumeDown: volumeDownCb,
|
||||||
|
setVolume: setVolumeCb,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Calculate start position in seconds */
|
||||||
|
const startPosition = useMemo(
|
||||||
|
() => (offline ? 0 : ticksToSeconds(getInitialPlaybackTicks())),
|
||||||
|
[offline, getInitialPlaybackTicks],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Conditionally render based on loading and error state */
|
||||||
|
if (itemStatus.isError || streamStatus.isError) {
|
||||||
return (
|
return (
|
||||||
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
<View className='w-screen h-screen items-center justify-center bg-black'>
|
||||||
|
<Text className='text-white'>{t("player.error")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
|
||||||
|
return (
|
||||||
|
<View className='w-screen h-screen items-center justify-center bg-black'>
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!item || !stream || itemStatus.isError || streamStatus.isError)
|
const allSubs =
|
||||||
return (
|
stream?.mediaSource.MediaStreams?.filter((s) => s.Type === "Subtitle") ||
|
||||||
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
[];
|
||||||
<Text className='text-white'>{t("player.error")}</Text>
|
const externalSubtitles = allSubs
|
||||||
</View>
|
.filter((s) => s.DeliveryMethod === "External")
|
||||||
);
|
.map((s) => ({
|
||||||
|
name: s.DisplayTitle,
|
||||||
|
DeliveryUrl: api?.basePath + s.DeliveryUrl,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
<View style={{ flex: 1, backgroundColor: "black" }}>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
position: "relative",
|
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
|
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
|
||||||
paddingRight: ignoreSafeAreas ? 0 : insets.right,
|
paddingRight: ignoreSafeAreas ? 0 : insets.right,
|
||||||
}}
|
}}
|
||||||
@@ -446,21 +620,20 @@ export default function page() {
|
|||||||
<VlcPlayerView
|
<VlcPlayerView
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
source={{
|
source={{
|
||||||
uri: stream?.url || "",
|
uri: stream.url,
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
isNetwork: true,
|
isNetwork: true,
|
||||||
startPosition,
|
startPosition,
|
||||||
externalSubtitles,
|
externalSubtitles,
|
||||||
initOptions,
|
initOptions: optimizedInitOptions,
|
||||||
}}
|
}}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
onVideoProgress={onProgress}
|
onVideoProgress={onProgress}
|
||||||
progressUpdateInterval={1000}
|
progressUpdateInterval={1000}
|
||||||
onVideoStateChange={onPlaybackStateChanged}
|
onVideoStateChange={onPlaybackStateChanged}
|
||||||
onPipStarted={onPipStarted}
|
onPipStarted={onPipStarted}
|
||||||
onVideoLoadEnd={() => {
|
// Mark video as loaded on load end to enable player method calls safely
|
||||||
setIsVideoLoaded(true);
|
onVideoLoadEnd={() => dispatch({ type: "VIDEO_LOADED" })}
|
||||||
}}
|
|
||||||
onVideoError={(e) => {
|
onVideoError={(e) => {
|
||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -471,36 +644,42 @@ export default function page() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{videoRef.current && !isPipStarted && isMounted === true ? (
|
|
||||||
|
{!videoState.isPipStarted && (
|
||||||
<Controls
|
<Controls
|
||||||
mediaSource={stream?.mediaSource}
|
mediaSource={stream.mediaSource}
|
||||||
item={item}
|
item={item}
|
||||||
videoRef={videoRef}
|
videoRef={videoRef}
|
||||||
togglePlay={togglePlay}
|
togglePlay={togglePlay}
|
||||||
isPlaying={isPlaying}
|
isPlaying={videoState.isPlaying}
|
||||||
isSeeking={isSeeking}
|
isSeeking={isSeeking}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
cacheProgress={cacheProgress}
|
cacheProgress={cacheProgress}
|
||||||
isBuffering={isBuffering}
|
isBuffering={videoState.isBuffering}
|
||||||
showControls={showControls}
|
showControls={showControls}
|
||||||
setShowControls={setShowControls}
|
setShowControls={setShowControls}
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
ignoreSafeAreas={ignoreSafeAreas}
|
||||||
isVideoLoaded={isVideoLoaded}
|
isVideoLoaded={videoState.isVideoLoaded}
|
||||||
startPictureInPicture={videoRef?.current?.startPictureInPicture}
|
startPictureInPicture={startPictureInPicture}
|
||||||
play={videoRef.current?.play}
|
play={play}
|
||||||
pause={videoRef.current?.pause}
|
pause={pause}
|
||||||
seek={videoRef.current?.seekTo}
|
seek={seek}
|
||||||
enableTrickplay={true}
|
enableTrickplay
|
||||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
// Pass undefined for player methods until the video is loaded to avoid crashes
|
||||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
getAudioTracks={videoState.isVideoLoaded ? getAudioTracks : undefined}
|
||||||
|
getSubtitleTracks={
|
||||||
|
videoState.isVideoLoaded ? getSubtitleTracks : undefined
|
||||||
|
}
|
||||||
offline={offline}
|
offline={offline}
|
||||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
setSubtitleTrack={
|
||||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
videoState.isVideoLoaded ? setSubtitleTrack : undefined
|
||||||
setAudioTrack={videoRef.current.setAudioTrack}
|
}
|
||||||
|
setSubtitleURL={videoState.isVideoLoaded ? setSubtitleURL : undefined}
|
||||||
|
setAudioTrack={videoState.isVideoLoaded ? setAudioTrack : undefined}
|
||||||
isVlc
|
isVlc
|
||||||
/>
|
/>
|
||||||
) : null}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ScrollViewStyleReset } from "expo-router/html";
|
import { ScrollViewStyleReset } from "expo-router/html";
|
||||||
import type { PropsWithChildren } from "react";
|
import { type PropsWithChildren } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This file is web-only and used to configure the root HTML for every web page during static rendering.
|
* This file is web-only and used to configure the root HTML for every web page during static rendering.
|
||||||
|
|||||||
353
app/_layout.tsx
@@ -1,11 +1,15 @@
|
|||||||
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 i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
import {
|
import {
|
||||||
JellyfinProvider,
|
|
||||||
apiAtom,
|
apiAtom,
|
||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
getTokenFromStorage,
|
getTokenFromStorage,
|
||||||
|
JellyfinProvider,
|
||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
@@ -24,35 +28,37 @@ import {
|
|||||||
} from "@/utils/log";
|
} 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 type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
const BackGroundDownloader = !Platform.isTV
|
const BackGroundDownloader = !Platform.isTV
|
||||||
? require("@kesha-antonov/react-native-background-downloader")
|
? require("@kesha-antonov/react-native-background-downloader")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
const BackgroundFetch = !Platform.isTV
|
const BackgroundFetch = !Platform.isTV
|
||||||
? require("expo-background-fetch")
|
? require("expo-background-fetch")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
import * as Device from "expo-device";
|
import * as Device from "expo-device";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
|
|
||||||
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import { Stack, router, useSegments } from "expo-router";
|
import { router, Stack, useSegments } from "expo-router";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
|
||||||
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
||||||
|
|
||||||
import { getLocales } from "expo-localization";
|
import { getLocales } from "expo-localization";
|
||||||
import { Provider as JotaiProvider } from "jotai";
|
import { Provider as JotaiProvider } from "jotai";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
import { AppState, Appearance } from "react-native";
|
import { Appearance, AppState } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { store } from "@/utils/store";
|
|
||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
import type { EventSubscription } from "expo-modules-core";
|
import type { EventSubscription } from "expo-modules-core";
|
||||||
import type {
|
import type {
|
||||||
@@ -62,6 +68,8 @@ import type {
|
|||||||
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Toaster } from "sonner-native";
|
import { Toaster } from "sonner-native";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
@@ -83,9 +91,9 @@ SplashScreen.setOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function useNotificationObserver() {
|
function useNotificationObserver() {
|
||||||
if (Platform.isTV) return;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
function redirect(notification: typeof Notifications.Notification) {
|
function redirect(notification: typeof Notifications.Notification) {
|
||||||
@@ -139,93 +147,98 @@ if (!Platform.isTV) {
|
|||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const settingsData = storage.getString("settings");
|
try {
|
||||||
|
const settingsData = storage.getString("settings");
|
||||||
|
|
||||||
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||||
const url = settings?.optimizedVersionsServerUrl;
|
const url = settings?.optimizedVersionsServerUrl;
|
||||||
|
|
||||||
if (!settings?.autoDownload || !url)
|
if (!settings?.autoDownload || !url)
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
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)
|
if (!token || !deviceId || !baseDirectory)
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const jobs = await getAllJobsByDeviceId({
|
const jobs = await getAllJobsByDeviceId({
|
||||||
deviceId,
|
deviceId,
|
||||||
authHeader: token,
|
authHeader: token,
|
||||||
url,
|
url,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
||||||
|
|
||||||
for (const job of jobs) {
|
for (const job of jobs) {
|
||||||
if (job.status === "completed") {
|
if (job.status === "completed") {
|
||||||
const downloadUrl = `${url}download/${job.id}`;
|
const downloadUrl = `${url}download/${job.id}`;
|
||||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
|
||||||
if (tasks.find((task: { id: string }) => task.id === job.id)) {
|
if (tasks.find((task: { id: string }) => task.id === job.id)) {
|
||||||
console.log("TaskManager ~ Download already in progress: ", job.id);
|
console.log("TaskManager ~ Download already in progress: ", job.id);
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
BackGroundDownloader.download({
|
||||||
|
id: job.id,
|
||||||
|
url: downloadUrl,
|
||||||
|
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
||||||
|
headers: {
|
||||||
|
Authorization: token,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.begin(() => {
|
||||||
|
console.log("TaskManager ~ Download started: ", job.id);
|
||||||
|
})
|
||||||
|
.done(() => {
|
||||||
|
console.log("TaskManager ~ Download completed: ", job.id);
|
||||||
|
saveDownloadedItemInfo(job.item);
|
||||||
|
BackGroundDownloader.completeHandler(job.id);
|
||||||
|
cancelJobById({
|
||||||
|
authHeader: token,
|
||||||
|
id: job.id,
|
||||||
|
url: url,
|
||||||
|
});
|
||||||
|
Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: job.item.Name,
|
||||||
|
body: "Download completed",
|
||||||
|
data: {
|
||||||
|
url: "/downloads",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trigger: null,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.error((error: any) => {
|
||||||
|
console.log("TaskManager ~ Download error: ", job.id, error);
|
||||||
|
BackGroundDownloader.completeHandler(job.id);
|
||||||
|
Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: job.item.Name,
|
||||||
|
body: "Download failed",
|
||||||
|
data: {
|
||||||
|
url: "/downloads",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trigger: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
BackGroundDownloader.download({
|
|
||||||
id: job.id,
|
|
||||||
url: downloadUrl,
|
|
||||||
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
|
||||||
headers: {
|
|
||||||
Authorization: token,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.begin(() => {
|
|
||||||
console.log("TaskManager ~ Download started: ", job.id);
|
|
||||||
})
|
|
||||||
.done(() => {
|
|
||||||
console.log("TaskManager ~ Download completed: ", job.id);
|
|
||||||
saveDownloadedItemInfo(job.item);
|
|
||||||
BackGroundDownloader.completeHandler(job.id);
|
|
||||||
cancelJobById({
|
|
||||||
authHeader: token,
|
|
||||||
id: job.id,
|
|
||||||
url: url,
|
|
||||||
});
|
|
||||||
Notifications.scheduleNotificationAsync({
|
|
||||||
content: {
|
|
||||||
title: job.item.Name,
|
|
||||||
body: "Download completed",
|
|
||||||
data: {
|
|
||||||
url: "/downloads",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
trigger: null,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.error((error: any) => {
|
|
||||||
console.log("TaskManager ~ Download error: ", job.id, error);
|
|
||||||
BackGroundDownloader.completeHandler(job.id);
|
|
||||||
Notifications.scheduleNotificationAsync({
|
|
||||||
content: {
|
|
||||||
title: job.item.Name,
|
|
||||||
body: "Download failed",
|
|
||||||
data: {
|
|
||||||
url: "/downloads",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
trigger: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
||||||
|
|
||||||
|
// Be sure to return the successful result type!
|
||||||
|
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Background task error:", error);
|
||||||
|
return BackgroundFetch.BackgroundFetchResult.Failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
|
||||||
|
|
||||||
// Be sure to return the successful result type!
|
|
||||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,51 +314,51 @@ function Layout() {
|
|||||||
);
|
);
|
||||||
}, [settings?.preferedLanguage, i18n]);
|
}, [settings?.preferedLanguage, i18n]);
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
useNotificationObserver();
|
||||||
useNotificationObserver();
|
|
||||||
|
|
||||||
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>();
|
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>();
|
||||||
const notificationListener = useRef<EventSubscription>();
|
const notificationListener = useRef<EventSubscription>();
|
||||||
const responseListener = useRef<EventSubscription>();
|
const responseListener = useRef<EventSubscription>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (expoPushToken && api && user) {
|
if (!Platform.isTV && expoPushToken && api && user) {
|
||||||
api
|
api
|
||||||
?.post("/Streamyfin/device", {
|
?.post("/Streamyfin/device", {
|
||||||
token: expoPushToken.data,
|
token: expoPushToken.data,
|
||||||
deviceId: getOrSetDeviceId(),
|
deviceId: getOrSetDeviceId(),
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
})
|
})
|
||||||
.then((_) => console.log("Posted expo push token"))
|
.then((_) => console.log("Posted expo push token"))
|
||||||
.catch((_) =>
|
.catch((_) =>
|
||||||
writeErrorLog("Failed to push expo push token to plugin"),
|
writeErrorLog("Failed to push expo push token to plugin"),
|
||||||
);
|
);
|
||||||
} else console.log("No token available");
|
} else console.log("No token available");
|
||||||
}, [api, expoPushToken, user]);
|
}, [api, expoPushToken, user]);
|
||||||
|
|
||||||
async function registerNotifications() {
|
async function registerNotifications() {
|
||||||
if (Platform.OS === "android") {
|
if (Platform.OS === "android") {
|
||||||
console.log("Setting android notification channel 'default'");
|
console.log("Setting android notification channel 'default'");
|
||||||
await Notifications?.setNotificationChannelAsync("default", {
|
await Notifications?.setNotificationChannelAsync("default", {
|
||||||
name: "default",
|
name: "default",
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
await checkAndRequestPermissions();
|
|
||||||
|
|
||||||
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
|
|
||||||
await registerBackgroundFetchAsyncSessions();
|
|
||||||
}
|
|
||||||
|
|
||||||
// only create push token for real devices (pointless for emulators)
|
|
||||||
if (Device.isDevice) {
|
|
||||||
Notifications?.getExpoPushTokenAsync()
|
|
||||||
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
|
|
||||||
.catch((reason: any) => console.log("Failed to get token", reason));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
await checkAndRequestPermissions();
|
||||||
|
|
||||||
|
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
|
||||||
|
await registerBackgroundFetchAsyncSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// only create push token for real devices (pointless for emulators)
|
||||||
|
if (Device.isDevice) {
|
||||||
|
Notifications?.getExpoPushTokenAsync()
|
||||||
|
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
|
||||||
|
.catch((reason: any) => console.log("Failed to get token", reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Platform.isTV) {
|
||||||
registerNotifications();
|
registerNotifications();
|
||||||
|
|
||||||
notificationListener.current =
|
notificationListener.current =
|
||||||
@@ -363,12 +376,10 @@ function Layout() {
|
|||||||
(response: NotificationResponse) => {
|
(response: NotificationResponse) => {
|
||||||
// Currently the notifications supported by the plugin will send data for deep links.
|
// Currently the notifications supported by the plugin will send data for deep links.
|
||||||
const { title, data } = response.notification.request.content;
|
const { title, data } = response.notification.request.content;
|
||||||
|
|
||||||
writeDebugLog(
|
writeDebugLog(
|
||||||
`Notification ${title} opened`,
|
`Notification ${title} opened`,
|
||||||
response.notification.request.content,
|
response.notification.request.content,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (data && Object.keys(data).length > 0) {
|
if (data && Object.keys(data).length > 0) {
|
||||||
const type = data?.type?.toLower?.();
|
const type = data?.type?.toLower?.();
|
||||||
const itemId = data?.id;
|
const itemId = data?.id;
|
||||||
@@ -381,12 +392,10 @@ function Layout() {
|
|||||||
// We just clicked a notification for an individual episode.
|
// We just clicked a notification for an individual episode.
|
||||||
if (itemId) {
|
if (itemId) {
|
||||||
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
|
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
|
||||||
}
|
// summarized season notification for multiple episodes. Bring them to series season
|
||||||
// summarized season notification for multiple episodes. Bring them to series season
|
} else {
|
||||||
else {
|
|
||||||
const seriesId = data.seriesId;
|
const seriesId = data.seriesId;
|
||||||
const seasonIndex = data.seasonIndex;
|
const seasonIndex = data.seasonIndex;
|
||||||
|
|
||||||
if (seasonIndex) {
|
if (seasonIndex) {
|
||||||
router.push(
|
router.push(
|
||||||
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`,
|
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`,
|
||||||
@@ -411,45 +420,57 @@ function Layout() {
|
|||||||
responseListener.current,
|
responseListener.current,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, []);
|
}
|
||||||
|
}, [user, api]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) {
|
||||||
if (segments.includes("direct-player" as never)) {
|
return;
|
||||||
return;
|
}
|
||||||
|
|
||||||
|
if (segments.includes("direct-player" as never)) {
|
||||||
|
if (
|
||||||
|
!settings.followDeviceOrientation &&
|
||||||
|
settings.defaultVideoOrientation
|
||||||
|
) {
|
||||||
|
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If the user has auto rotate enabled, unlock the orientation
|
if (settings.followDeviceOrientation === true) {
|
||||||
if (settings.followDeviceOrientation === true) {
|
ScreenOrientation.unlockAsync();
|
||||||
ScreenOrientation.unlockAsync();
|
} else {
|
||||||
} else {
|
ScreenOrientation.lockAsync(
|
||||||
// If the user has auto rotate disabled, lock the orientation to portrait
|
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||||
ScreenOrientation.lockAsync(
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [settings.followDeviceOrientation, segments]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = AppState.addEventListener(
|
|
||||||
"change",
|
|
||||||
(nextAppState) => {
|
|
||||||
if (
|
|
||||||
appState.current.match(/inactive|background/) &&
|
|
||||||
nextAppState === "active"
|
|
||||||
) {
|
|
||||||
BackGroundDownloader.checkForExistingDownloads();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
settings.followDeviceOrientation,
|
||||||
|
settings.defaultVideoOrientation,
|
||||||
|
segments,
|
||||||
|
]);
|
||||||
|
|
||||||
BackGroundDownloader.checkForExistingDownloads();
|
useEffect(() => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||||
subscription.remove();
|
if (
|
||||||
};
|
appState.current.match(/inactive|background/) &&
|
||||||
}, []);
|
nextAppState === "active"
|
||||||
}
|
) {
|
||||||
|
BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
252
app/login.tsx
@@ -1,29 +1,29 @@
|
|||||||
import { Button } from "@/components/Button";
|
|
||||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
|
||||||
import { Input } from "@/components/common/Input";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
|
||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { t } from "i18next";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Keyboard,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Keyboard } from "react-native";
|
|
||||||
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Input } from "@/components/common/Input";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||||
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
const CredentialsSchema = z.object({
|
const CredentialsSchema = z.object({
|
||||||
username: z.string().min(1, t("login.username_required")),
|
username: z.string().min(1, t("login.username_required")),
|
||||||
});
|
});
|
||||||
@@ -199,7 +199,7 @@ const Login: React.FC = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.error_title"),
|
t("login.error_title"),
|
||||||
t("login.failed_to_initiate_quick_connect"),
|
t("login.failed_to_initiate_quick_connect"),
|
||||||
@@ -213,133 +213,127 @@ const Login: React.FC = () => {
|
|||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
>
|
>
|
||||||
{api?.basePath ? (
|
{api?.basePath ? (
|
||||||
<>
|
<View className='flex flex-col h-full relative items-center justify-center'>
|
||||||
<View className='flex flex-col h-full relative items-center justify-center'>
|
<View className='px-4 -mt-20 w-full'>
|
||||||
<View className='px-4 -mt-20 w-full'>
|
<View className='flex flex-col space-y-2'>
|
||||||
<View className='flex flex-col space-y-2'>
|
<Text className='text-2xl font-bold -mb-2'>
|
||||||
<Text className='text-2xl font-bold -mb-2'>
|
{serverName ? (
|
||||||
{serverName ? (
|
<>
|
||||||
<>
|
{`${t("login.login_to_title")} `}
|
||||||
{`${t("login.login_to_title")} `}
|
<Text className='text-purple-600'>{serverName}</Text>
|
||||||
<Text className='text-purple-600'>{serverName}</Text>
|
</>
|
||||||
</>
|
) : (
|
||||||
) : (
|
t("login.login_title")
|
||||||
t("login.login_title")
|
)}
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-xs text-neutral-400'>
|
|
||||||
{api.basePath}
|
|
||||||
</Text>
|
|
||||||
<Input
|
|
||||||
placeholder={t("login.username_placeholder")}
|
|
||||||
onChangeText={(text) =>
|
|
||||||
setCredentials({ ...credentials, username: text })
|
|
||||||
}
|
|
||||||
value={credentials.username}
|
|
||||||
keyboardType='default'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
// Changed from username to oneTimeCode because it is a known issue in RN
|
|
||||||
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
|
|
||||||
textContentType='oneTimeCode'
|
|
||||||
clearButtonMode='while-editing'
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder={t("login.password_placeholder")}
|
|
||||||
onChangeText={(text) =>
|
|
||||||
setCredentials({ ...credentials, password: text })
|
|
||||||
}
|
|
||||||
value={credentials.password}
|
|
||||||
secureTextEntry
|
|
||||||
keyboardType='default'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
textContentType='password'
|
|
||||||
clearButtonMode='while-editing'
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
<View className='flex flex-row items-center justify-between'>
|
|
||||||
<Button
|
|
||||||
onPress={handleLogin}
|
|
||||||
loading={loading}
|
|
||||||
className='flex-1 mr-2'
|
|
||||||
>
|
|
||||||
{t("login.login_button")}
|
|
||||||
</Button>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleQuickConnect}
|
|
||||||
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name='cellphone-lock'
|
|
||||||
size={24}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<View className='flex flex-col h-full items-center justify-center w-full'>
|
|
||||||
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
|
||||||
<Image
|
|
||||||
style={{
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
marginLeft: -23,
|
|
||||||
marginBottom: -20,
|
|
||||||
}}
|
|
||||||
source={require("@/assets/images/StreamyFinFinal.png")}
|
|
||||||
/>
|
|
||||||
<Text className='text-3xl font-bold'>Streamyfin</Text>
|
|
||||||
<Text className='text-neutral-500'>
|
|
||||||
{t("server.enter_url_to_jellyfin_server")}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text className='text-xs text-neutral-400'>{api.basePath}</Text>
|
||||||
<Input
|
<Input
|
||||||
aria-label='Server URL'
|
placeholder={t("login.username_placeholder")}
|
||||||
placeholder={t("server.server_url_placeholder")}
|
onChangeText={(text) =>
|
||||||
onChangeText={setServerURL}
|
setCredentials({ ...credentials, username: text })
|
||||||
value={serverURL}
|
}
|
||||||
keyboardType='url'
|
value={credentials.username}
|
||||||
|
keyboardType='default'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
textContentType='URL'
|
// Changed from username to oneTimeCode because it is a known issue in RN
|
||||||
|
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
|
||||||
|
textContentType='oneTimeCode'
|
||||||
|
clearButtonMode='while-editing'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
loading={loadingServerCheck}
|
<Input
|
||||||
disabled={loadingServerCheck}
|
placeholder={t("login.password_placeholder")}
|
||||||
onPress={async () => {
|
onChangeText={(text) =>
|
||||||
await handleConnect(serverURL);
|
setCredentials({ ...credentials, password: text })
|
||||||
}}
|
}
|
||||||
className='w-full grow'
|
value={credentials.password}
|
||||||
>
|
secureTextEntry
|
||||||
{t("server.connect_button")}
|
keyboardType='default'
|
||||||
</Button>
|
returnKeyType='done'
|
||||||
<JellyfinServerDiscovery
|
autoCapitalize='none'
|
||||||
onServerSelect={async (server) => {
|
textContentType='password'
|
||||||
setServerURL(server.address);
|
clearButtonMode='while-editing'
|
||||||
if (server.serverName) {
|
maxLength={500}
|
||||||
setServerName(server.serverName);
|
|
||||||
}
|
|
||||||
await handleConnect(server.address);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<PreviousServersList
|
|
||||||
onServerSelect={async (s) => {
|
|
||||||
await handleConnect(s.address);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
<View className='flex flex-row items-center justify-between'>
|
||||||
|
<Button
|
||||||
|
onPress={handleLogin}
|
||||||
|
loading={loading}
|
||||||
|
className='flex-1 mr-2'
|
||||||
|
>
|
||||||
|
{t("login.login_button")}
|
||||||
|
</Button>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleQuickConnect}
|
||||||
|
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name='cellphone-lock'
|
||||||
|
size={24}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</>
|
|
||||||
|
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className='flex flex-col h-full items-center justify-center w-full'>
|
||||||
|
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
||||||
|
<Image
|
||||||
|
style={{
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
marginLeft: -23,
|
||||||
|
marginBottom: -20,
|
||||||
|
}}
|
||||||
|
source={require("@/assets/images/icon-ios-plain.png")}
|
||||||
|
/>
|
||||||
|
<Text className='text-3xl font-bold'>Streamyfin</Text>
|
||||||
|
<Text className='text-neutral-500'>
|
||||||
|
{t("server.enter_url_to_jellyfin_server")}
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
aria-label='Server URL'
|
||||||
|
placeholder={t("server.server_url_placeholder")}
|
||||||
|
onChangeText={setServerURL}
|
||||||
|
value={serverURL}
|
||||||
|
keyboardType='url'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='URL'
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
loading={loadingServerCheck}
|
||||||
|
disabled={loadingServerCheck}
|
||||||
|
onPress={async () => {
|
||||||
|
await handleConnect(serverURL);
|
||||||
|
}}
|
||||||
|
className='w-full grow'
|
||||||
|
>
|
||||||
|
{t("server.connect_button")}
|
||||||
|
</Button>
|
||||||
|
<JellyfinServerDiscovery
|
||||||
|
onServerSelect={async (server) => {
|
||||||
|
setServerURL(server.address);
|
||||||
|
if (server.serverName) {
|
||||||
|
setServerName(server.serverName);
|
||||||
|
}
|
||||||
|
await handleConnect(server.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PreviousServersList
|
||||||
|
onServerSelect={async (s) => {
|
||||||
|
await handleConnect(s.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 79 KiB |
BIN
assets/images/icon-android-plain.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
assets/images/icon-android-themed.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
assets/images/icon-ios-light.png
Normal file
|
After Width: | Height: | Size: 305 KiB |
BIN
assets/images/icon-ios-plain.png
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
assets/images/icon-ios-tinted.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 75 KiB |
@@ -1,6 +1,6 @@
|
|||||||
import type { StreamyfinPluginConfig } from "@/utils/atoms/settings";
|
import { Api, AUTHORIZATION_HEADER } from "@jellyfin/sdk";
|
||||||
import { AUTHORIZATION_HEADER, Api } from "@jellyfin/sdk";
|
|
||||||
import type { AxiosRequestConfig, AxiosResponse } from "axios";
|
import type { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||||
|
import type { StreamyfinPluginConfig } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
declare module "@jellyfin/sdk" {
|
declare module "@jellyfin/sdk" {
|
||||||
interface Api {
|
interface Api {
|
||||||
|
|||||||
@@ -7,15 +7,27 @@ declare module "react-native-mmkv" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add the augmentation methods directly to the MMKV prototype
|
||||||
|
// This follows the recommended pattern while adding the helper methods your app uses
|
||||||
MMKV.prototype.get = function <T>(key: string): T | undefined {
|
MMKV.prototype.get = function <T>(key: string): T | undefined {
|
||||||
const serializedItem = this.getString(key);
|
try {
|
||||||
return serializedItem ? JSON.parse(serializedItem) : undefined;
|
const serializedItem = this.getString(key);
|
||||||
|
if (!serializedItem) return undefined;
|
||||||
|
return JSON.parse(serializedItem);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to parse MMKV value for key "${key}":`, error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||||
if (value === undefined) {
|
try {
|
||||||
this.delete(key);
|
if (value === undefined) {
|
||||||
} else {
|
this.delete(key);
|
||||||
this.set(key, JSON.stringify(value));
|
} else {
|
||||||
|
this.set(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to set MMKV value for key "${key}":`, error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
20
biome.json
@@ -1,16 +1,14 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.1.4/schema.json",
|
||||||
"organizeImports": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"files": {
|
"files": {
|
||||||
"ignore": [
|
"includes": [
|
||||||
"node_modules",
|
"**/*",
|
||||||
"ios",
|
"!node_modules/**",
|
||||||
"android",
|
"!ios/**",
|
||||||
"Streamyfin.app",
|
"!android/**",
|
||||||
"utils/jellyseerr",
|
"!Streamyfin.app/**",
|
||||||
".expo"
|
"!utils/jellyseerr/**",
|
||||||
|
"!.expo/**"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { RoundButton } from "@/components/RoundButton";
|
|
||||||
import { useFavorite } from "@/hooks/useFavorite";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
@@ -17,7 +19,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
if (Platform.isTV) return null;
|
const isTv = Platform.isTV;
|
||||||
|
|
||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
[source],
|
[source],
|
||||||
@@ -30,6 +33,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className='flex shrink'
|
className='flex shrink'
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
@@ -58,23 +60,26 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
inverted,
|
inverted,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
if (Platform.isTV) return null;
|
const isTv = Platform.isTV;
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (inverted)
|
if (inverted)
|
||||||
return BITRATES.sort(
|
return BITRATES.slice().sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(a.value || Number.POSITIVE_INFINITY) -
|
(a.value || Number.POSITIVE_INFINITY) -
|
||||||
(b.value || Number.POSITIVE_INFINITY),
|
(b.value || Number.POSITIVE_INFINITY),
|
||||||
);
|
);
|
||||||
return BITRATES.sort(
|
return BITRATES.slice().sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(b.value || Number.POSITIVE_INFINITY) -
|
(b.value || Number.POSITIVE_INFINITY) -
|
||||||
(a.value || Number.POSITIVE_INFINITY),
|
(a.value || Number.POSITIVE_INFINITY),
|
||||||
);
|
);
|
||||||
}, []);
|
}, [inverted]);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className='flex shrink'
|
className='flex shrink'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { type PropsWithChildren, type ReactNode, useMemo } from "react";
|
import { type PropsWithChildren, type ReactNode, useMemo } from "react";
|
||||||
import { Platform, Text, TouchableOpacity, View } from "react-native";
|
import { Text, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import React, { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { Platform, TouchableOpacity, type ViewProps } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
CastButton,
|
CastButton,
|
||||||
CastContext,
|
CastContext,
|
||||||
@@ -11,12 +11,6 @@ import GoogleCast, {
|
|||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
background?: "blur" | "transparent";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Chromecast({
|
export function Chromecast({
|
||||||
width = 48,
|
width = 48,
|
||||||
height = 48,
|
height = 48,
|
||||||
@@ -44,11 +38,7 @@ export function Chromecast({
|
|||||||
// Android requires the cast button to be present for startDiscovery to work
|
// Android requires the cast button to be present for startDiscovery to work
|
||||||
const AndroidCastButton = useCallback(
|
const AndroidCastButton = useCallback(
|
||||||
() =>
|
() =>
|
||||||
Platform.OS === "android" ? (
|
Platform.OS === "android" ? <CastButton tintColor='transparent' /> : null,
|
||||||
<CastButton tintColor='transparent' />
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
),
|
|
||||||
[Platform.OS],
|
[Platform.OS],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from "zeego/context-menu";
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useMemo } from "react";
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { WatchedIndicator } from "./WatchedIndicator";
|
import { WatchedIndicator } from "./WatchedIndicator";
|
||||||
|
|
||||||
type ContinueWatchingPosterProps = {
|
type ContinueWatchingPosterProps = {
|
||||||
|
|||||||
@@ -1,12 +1,3 @@
|
|||||||
//import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
|
||||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
|
||||||
import download from "@/utils/profiles/download";
|
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
@@ -25,15 +16,23 @@ import type React from "react";
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
import { Alert, Platform, View, type ViewProps } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
|
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
||||||
|
import download from "@/utils/profiles/download";
|
||||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||||
import { type Bitrate, BitrateSelector } from "./BitrateSelector";
|
import { type Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import ProgressCircle from "./ProgressCircle";
|
import ProgressCircle from "./ProgressCircle";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||||
import { Text } from "./common/Text";
|
|
||||||
|
|
||||||
interface DownloadProps extends ViewProps {
|
interface DownloadProps extends ViewProps {
|
||||||
items: BaseItemDto[];
|
items: BaseItemDto[];
|
||||||
@@ -89,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();
|
||||||
@@ -152,18 +151,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
}
|
}
|
||||||
closeModal();
|
closeModal();
|
||||||
|
|
||||||
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
|
initiateDownload(...itemsNotDownloaded);
|
||||||
else {
|
|
||||||
queueActions.enqueue(
|
|
||||||
queue,
|
|
||||||
setQueue,
|
|
||||||
...itemsNotDownloaded.map((item) => ({
|
|
||||||
id: item.Id!,
|
|
||||||
execute: async () => await initiateDownload(item),
|
|
||||||
item,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
||||||
@@ -203,7 +191,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
mediaSource = defaults.mediaSource;
|
mediaSource = defaults.mediaSource;
|
||||||
audioIndex = defaults.audioIndex;
|
audioIndex = defaults.audioIndex;
|
||||||
subtitleIndex = defaults.subtitleIndex;
|
subtitleIndex = defaults.subtitleIndex;
|
||||||
// Keep using the selected bitrate for consistency across all downloads
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
const res = await getStreamUrl({
|
||||||
@@ -216,6 +203,8 @@ 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) {
|
||||||
@@ -230,12 +219,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
|
|
||||||
if (!url || !source) throw new Error("No url");
|
if (!url || !source) throw new Error("No url");
|
||||||
|
|
||||||
if (usingOptimizedServer) {
|
saveDownloadItemInfoToDiskTmp(item, source, url);
|
||||||
saveDownloadItemInfoToDiskTmp(item, source, url);
|
await startBackgroundDownload(url, item, source, maxBitrate);
|
||||||
await startBackgroundDownload(url, item, source);
|
|
||||||
} else {
|
|
||||||
//await startRemuxing(item, url, source);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -249,7 +234,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
maxBitrate,
|
maxBitrate,
|
||||||
usingOptimizedServer,
|
usingOptimizedServer,
|
||||||
startBackgroundDownload,
|
startBackgroundDownload,
|
||||||
//startRemuxing,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { tc } from "@/utils/textTools";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
|||||||
@@ -1,24 +1,3 @@
|
|||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
|
||||||
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
|
||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
|
||||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
|
||||||
import { SimilarItems } from "@/components/SimilarItems";
|
|
||||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
|
||||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
|
||||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
|
||||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
|
||||||
import { useImageColors } from "@/hooks/useImageColors";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
@@ -29,11 +8,34 @@ import { useAtom } from "jotai";
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||||
|
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||||
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
||||||
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
|
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||||
|
import { SimilarItems } from "@/components/SimilarItems";
|
||||||
|
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||||
|
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||||
|
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||||
|
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||||
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { AddToFavorites } from "./AddToFavorites";
|
import { AddToFavorites } from "./AddToFavorites";
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
|
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||||
|
|
||||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||||
|
|
||||||
export type SelectedOptions = {
|
export type SelectedOptions = {
|
||||||
@@ -50,6 +52,8 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
useImageColors({ item });
|
useImageColors({ item });
|
||||||
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||||
@@ -81,8 +85,8 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
useEffect(() => {
|
||||||
useEffect(() => {
|
if (!Platform.isTV) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
item && (
|
item && (
|
||||||
@@ -97,6 +101,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<DownloadSingleItem item={item} size='large' />
|
<DownloadSingleItem item={item} size='large' />
|
||||||
)}
|
)}
|
||||||
|
{user?.Policy?.IsAdministrator && (
|
||||||
|
<PlayInRemoteSessionButton item={item} size='large' />
|
||||||
|
)}
|
||||||
|
|
||||||
<PlayedStatus items={[item]} size='large' />
|
<PlayedStatus items={[item]} size='large' />
|
||||||
<AddToFavorites item={item} />
|
<AddToFavorites item={item} />
|
||||||
</View>
|
</View>
|
||||||
@@ -104,8 +112,8 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}, [item]);
|
}
|
||||||
}
|
}, [item, navigation, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||||
@@ -114,12 +122,16 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
else setHeaderHeight(350);
|
else setHeaderHeight(350);
|
||||||
}, [item.Type, orientation]);
|
}, [item.Type, orientation]);
|
||||||
|
|
||||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
const logoUrl = useMemo(
|
||||||
|
() => getLogoImageUrlById({ api, item }),
|
||||||
|
[api, item],
|
||||||
|
);
|
||||||
|
|
||||||
const loading = useMemo(() => {
|
const loading = useMemo(() => {
|
||||||
return Boolean(logoUrl && loadingLogo);
|
return Boolean(logoUrl && loadingLogo);
|
||||||
}, [loadingLogo, logoUrl]);
|
}, [loadingLogo, logoUrl]);
|
||||||
if (!selectedOptions) return null;
|
|
||||||
|
if (!selectedOptions) return <View />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -160,7 +172,9 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
onLoad={() => setLoadingLogo(false)}
|
onLoad={() => setLoadingLogo(false)}
|
||||||
onError={() => setLoadingLogo(false)}
|
onError={() => setLoadingLogo(false)}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : (
|
||||||
|
<View />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col bg-transparent shrink'>
|
<View className='flex flex-col bg-transparent shrink'>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { GenreTags } from "./GenreTags";
|
import { GenreTags } from "./GenreTags";
|
||||||
import { Ratings } from "./Ratings";
|
|
||||||
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||||
|
import { Ratings } from "./Ratings";
|
||||||
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
||||||
import { ItemActions } from "./series/SeriesActions";
|
import { ItemActions } from "./series/SeriesActions";
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { formatBitrate } from "@/utils/bitrate";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
type BottomSheetBackdropProps,
|
type BottomSheetBackdropProps,
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import type {
|
import type {
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
@@ -15,15 +13,15 @@ import type React from "react";
|
|||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
import { Badge } from "./Badge";
|
import { Badge } from "./Badge";
|
||||||
import { Button } from "./Button";
|
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -55,7 +53,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
|||||||
>
|
>
|
||||||
<BottomSheetScrollView>
|
<BottomSheetScrollView>
|
||||||
<View className='flex flex-col space-y-2 p-4 mb-4'>
|
<View className='flex flex-col space-y-2 p-4 mb-4'>
|
||||||
<View className=''>
|
<View>
|
||||||
<Text className='text-lg font-bold mb-4'>
|
<Text className='text-lg font-bold mb-4'>
|
||||||
{t("item_card.video")}
|
{t("item_card.video")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -64,7 +62,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className=''>
|
<View>
|
||||||
<Text className='text-lg font-bold mb-2'>
|
<Text className='text-lg font-bold mb-2'>
|
||||||
{t("item_card.audio")}
|
{t("item_card.audio")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -77,7 +75,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className=''>
|
<View>
|
||||||
<Text className='text-lg font-bold mb-2'>
|
<Text className='text-lg font-bold mb-2'>
|
||||||
{t("item_card.subtitles")}
|
{t("item_card.subtitles")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -103,7 +101,7 @@ const SubtitleStreamInfo = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col'>
|
<View className='flex flex-col'>
|
||||||
{subtitleStreams.map((stream, index) => (
|
{subtitleStreams.map((stream, _index) => (
|
||||||
<View key={stream.Index} className='flex flex-col'>
|
<View key={stream.Index} className='flex flex-col'>
|
||||||
<Text className='text-xs mb-3 text-neutral-400'>
|
<Text className='text-xs mb-3 text-neutral-400'>
|
||||||
{stream.DisplayTitle}
|
{stream.DisplayTitle}
|
||||||
@@ -177,15 +175,13 @@ const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
||||||
if (!source) return null;
|
|
||||||
|
|
||||||
const videoStream = useMemo(() => {
|
const videoStream = useMemo(() => {
|
||||||
return source.MediaStreams?.find(
|
return source?.MediaStreams?.find((stream) => stream.Type === "Video") as
|
||||||
(stream) => stream.Type === "Video",
|
| MediaStream
|
||||||
) as MediaStream;
|
| undefined;
|
||||||
}, [source.MediaStreams]);
|
}, [source?.MediaStreams]);
|
||||||
|
|
||||||
if (!videoStream) return null;
|
if (!source || !videoStream) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex-row flex-wrap gap-2'>
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
@@ -223,7 +219,11 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
|||||||
<Badge
|
<Badge
|
||||||
variant='gray'
|
variant='gray'
|
||||||
iconLeft={<Ionicons name='play-outline' size={16} color='white' />}
|
iconLeft={<Ionicons name='play-outline' size={16} color='white' />}
|
||||||
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
|
text={
|
||||||
|
videoStream.AverageFrameRate != null
|
||||||
|
? `${videoStream.AverageFrameRate.toFixed(0)} fps`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text, TouchableOpacity, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
|
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { ListGroup } from "./list/ListGroup";
|
import { ListGroup } from "./list/ListGroup";
|
||||||
import { ListItem } from "./list/ListItem";
|
import { ListItem } from "./list/ListItem";
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
type ActivityIndicatorProps,
|
type ActivityIndicatorProps,
|
||||||
Platform,
|
Platform,
|
||||||
View,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
|
||||||
interface Props extends ActivityIndicatorProps {}
|
interface Props extends ActivityIndicatorProps {}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import type {
|
|||||||
} 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 { useTranslation } from "react-i18next";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
@@ -20,7 +22,8 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
if (Platform.isTV) return null;
|
const isTv = Platform.isTV;
|
||||||
|
|
||||||
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(
|
||||||
@@ -52,6 +55,8 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
return name?.replace(commonPrefix, "").toLowerCase();
|
return name?.replace(commonPrefix, "").toLowerCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className='flex shrink'
|
className='flex shrink'
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -12,6 +5,13 @@ import { useAtom } from "jotai";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
|
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
actorId: string;
|
actorId: string;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { tc } from "@/utils/textTools";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { tc } from "@/utils/textTools";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
text?: string | null;
|
text?: string | null;
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import type { PropsWithChildren, ReactElement } from "react";
|
import type { PropsWithChildren, ReactElement } from "react";
|
||||||
import {
|
import { type NativeScrollEvent, View, type ViewProps } from "react-native";
|
||||||
type NativeScrollEvent,
|
|
||||||
NativeSyntheticEvent,
|
|
||||||
View,
|
|
||||||
type ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
interpolate,
|
interpolate,
|
||||||
useAnimatedRef,
|
useAnimatedRef,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
blurAmount?: number;
|
blurAmount?: number;
|
||||||
blurType?: "light" | "dark" | "xlight";
|
blurType?: "light" | "dark" | "xlight";
|
||||||
|
|||||||
@@ -1,14 +1,3 @@
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { chromecast } from "@/utils/profiles/chromecast";
|
|
||||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
|
||||||
import 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 type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
@@ -16,7 +5,6 @@ import { useRouter } from "expo-router";
|
|||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, Pressable } from "react-native";
|
|
||||||
import { Alert, TouchableOpacity, View } from "react-native";
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
CastButton,
|
||||||
@@ -34,6 +22,16 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import { chromecast } from "@/utils/profiles/chromecast";
|
||||||
|
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import type { Button } from "./Button";
|
import type { Button } from "./Button";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
|
|
||||||
@@ -67,11 +65,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] = useSettings();
|
const [settings, updateSettings] = 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],
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
|
||||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { Alert, TouchableOpacity, View } from "react-native";
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
interpolate,
|
interpolate,
|
||||||
@@ -22,6 +14,10 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import type { Button } from "./Button";
|
import type { Button } from "./Button";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
|
|
||||||
@@ -38,12 +34,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
selectedOptions,
|
selectedOptions,
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|||||||
194
components/PlayInRemoteSession.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
type BaseItemDto,
|
||||||
|
PlayCommand,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
FlatList,
|
||||||
|
Modal,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useAllSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
import { Loader } from "./Loader";
|
||||||
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
|
item: BaseItemDto;
|
||||||
|
size?: "default" | "large";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
||||||
|
const handlePlayInSession = async (sessionId: string) => {
|
||||||
|
if (!api || !item.Id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Playing ${item.Name} in session ${sessionId}`);
|
||||||
|
getSessionApi(api).play({
|
||||||
|
sessionId,
|
||||||
|
itemIds: [item.Id],
|
||||||
|
playCommand: PlayCommand.PlayNow,
|
||||||
|
});
|
||||||
|
|
||||||
|
setModalVisible(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error playing in remote session:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<RoundButton
|
||||||
|
icon='play-circle-outline'
|
||||||
|
onPress={() => setModalVisible(true)}
|
||||||
|
size={props.size}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
animationType='slide'
|
||||||
|
transparent={true}
|
||||||
|
visible={modalVisible}
|
||||||
|
onRequestClose={() => setModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.centeredView}>
|
||||||
|
<View style={styles.modalView}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<Text style={styles.modalTitle}>Select Session</Text>
|
||||||
|
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
||||||
|
<Ionicons name='close' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : !sessions || sessions.length === 0 ? (
|
||||||
|
<Text style={styles.noSessionsText}>
|
||||||
|
No active sessions found
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={sessions}
|
||||||
|
keyExtractor={(session) => session.Id || "unknown"}
|
||||||
|
renderItem={({ item: session }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.sessionItem}
|
||||||
|
onPress={() => handlePlayInSession(session.Id || "")}
|
||||||
|
>
|
||||||
|
<View style={styles.sessionInfo}>
|
||||||
|
<Text style={styles.sessionName}>
|
||||||
|
{session.DeviceName}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.sessionDetails}>
|
||||||
|
{session.UserName} • {session.Client}
|
||||||
|
</Text>
|
||||||
|
{session.NowPlayingItem && (
|
||||||
|
<Text style={styles.nowPlaying} numberOfLines={1}>
|
||||||
|
Now playing:{" "}
|
||||||
|
{session.NowPlayingItem.SeriesName
|
||||||
|
? `${session.NowPlayingItem.SeriesName} :`
|
||||||
|
: ""}
|
||||||
|
{session.NowPlayingItem.Name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Ionicons name='play-sharp' size={20} color='#888' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
centeredView: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||||
|
},
|
||||||
|
modalView: {
|
||||||
|
width: "90%",
|
||||||
|
maxHeight: "80%",
|
||||||
|
backgroundColor: "#1c1c1c",
|
||||||
|
borderRadius: 20,
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#333",
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
padding: 40,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
noSessionsText: {
|
||||||
|
padding: 40,
|
||||||
|
textAlign: "center",
|
||||||
|
color: "#888",
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
sessionItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#333",
|
||||||
|
},
|
||||||
|
sessionInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
sessionName: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "500",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
sessionDetails: {
|
||||||
|
fontSize: 13,
|
||||||
|
opacity: 0.7,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
nowPlaying: {
|
||||||
|
fontSize: 12,
|
||||||
|
opacity: 0.5,
|
||||||
|
fontStyle: "italic",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -13,7 +13,7 @@ interface Props extends ViewProps {
|
|||||||
export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const invalidateQueries = () => {
|
const _invalidateQueries = () => {
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["item", item.Id],
|
queryKey: ["item", item.Id],
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { StyleSheet, View } from "react-native";
|
|
||||||
import { AnimatedCircularProgress } from "react-native-circular-progress";
|
import { AnimatedCircularProgress } from "react-native-circular-progress";
|
||||||
|
|
||||||
type ProgressCircleProps = {
|
type ProgressCircleProps = {
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
@@ -6,12 +12,6 @@ import type {
|
|||||||
TvResult,
|
TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { View, type ViewProps } from "react-native";
|
|
||||||
import { Badge } from "./Badge";
|
import { Badge } from "./Badge";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
@@ -7,6 +6,7 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
type TouchableOpacityProps,
|
type TouchableOpacityProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { router } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { View, type ViewProps } from "react-native";
|
||||||
ScrollView,
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
TouchableOpacity,
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
View,
|
|
||||||
type ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import { ItemCardText } from "./ItemCardText";
|
|
||||||
import { Loader } from "./Loader";
|
|
||||||
import { HorizontalScroll } from "./common/HorrizontalScroll";
|
import { HorizontalScroll } from "./common/HorrizontalScroll";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
||||||
|
import { ItemCardText } from "./ItemCardText";
|
||||||
|
|
||||||
interface SimilarItemsProps extends ViewProps {
|
interface SimilarItemsProps extends ViewProps {
|
||||||
itemId?: string | null;
|
itemId?: string | null;
|
||||||
|
|||||||