forked from Ninjalama/streamyfin_mirror
Compare commits
162 Commits
fix/extern
...
feature/of
| Author | SHA1 | Date | |
|---|---|---|---|
| 1726aed3a0 | |||
|
|
2da861e4c2 | ||
|
|
539889b6d9 | ||
|
|
43c8187f52 | ||
|
|
2bf75b02e3 | ||
|
|
8213504665 | ||
|
|
119d6e56c6 | ||
|
|
3a82c9ec21 | ||
|
|
756402fd11 | ||
|
|
671a3e2570 | ||
|
|
e9673cca62 | ||
|
|
4aea5c0155 | ||
|
|
d0b1c51fac | ||
|
|
70a503b8b0 | ||
|
|
7cab178c71 | ||
|
|
3db9810e2f | ||
|
|
60c7c88880 | ||
|
|
32a1bbe7de | ||
|
|
2342c776f2 | ||
|
|
25383edd43 | ||
|
|
d4a8c5fc7e | ||
|
|
c010e73097 | ||
|
|
270c12c2f2 | ||
|
|
f88771acda | ||
|
|
0021b94e00 | ||
|
|
0da89bd6f3 | ||
|
|
501b88a71e | ||
|
|
53b43edc2a | ||
|
|
c71c7e38e1 | ||
|
|
b6eb8249b0 | ||
|
|
ebe36774b0 | ||
|
|
8f943786af | ||
|
|
a40dfd0d6f | ||
|
|
bcd54718c7 | ||
|
|
3e74bfdeee | ||
|
|
b96ca1702f | ||
|
|
0e8704e9b5 | ||
|
|
e247438628 | ||
|
|
bd073ec574 | ||
|
|
5a38e29854 | ||
|
|
5d2ce263e2 | ||
|
|
e2c8ed7cbe | ||
|
|
54beb63adc | ||
|
|
8b0c1081ed | ||
|
|
924eb6695c | ||
|
|
bb7f708a68 | ||
|
|
436868cac1 | ||
|
|
fb3a105bdf | ||
|
|
8ee7c57606 | ||
|
|
3e5d5aad9f | ||
|
|
d9dc2e089a | ||
|
|
edc3c633f3 | ||
|
|
afc96cde05 | ||
|
|
4f75cf64dc | ||
|
|
f0f2bd34ba | ||
|
|
64b353e683 | ||
|
|
d6c242d0d5 | ||
|
|
ab16972921 | ||
|
|
ef4bb14216 | ||
|
|
d48398589d | ||
|
|
2133b382a1 | ||
|
|
8c5b9d068d | ||
|
|
26225bbf52 | ||
|
|
0a9da729a1 | ||
|
|
cad9472779 | ||
|
|
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 | ||
| ec7f99d216 | |||
| 9fcd184ad1 | |||
| c8f8661eac | |||
| eef9fe397f | |||
| a00d15aa5c | |||
|
|
4ccffad3e7 | ||
| 0b8642a217 | |||
| 405111a3d3 | |||
|
|
46b08007a4 | ||
|
|
7b05fe43cf | ||
|
|
8f7749160e | ||
|
|
0a72396a16 | ||
|
|
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 |
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -43,6 +43,7 @@ body:
|
||||
label: Version
|
||||
description: What version of Streamyfin are you running?
|
||||
options:
|
||||
- 0.28.1
|
||||
- 0.28.0
|
||||
- 0.27.0
|
||||
- 0.26.1
|
||||
|
||||
82
.github/workflows/build-android_Miron.yml
vendored
Normal file
82
.github/workflows/build-android_Miron.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
name: 🤖 Android APK Build
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [develop, master,ninjalama-patch-1]
|
||||
push:
|
||||
branches: [develop, master, ninjalama-patch-1]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
name: 🏗️ Build Android APK
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@v4 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
bun-version: '1.2.17'
|
||||
|
||||
- name: ☕ Setup JDK
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v2
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache-
|
||||
|
||||
- name: 📦 Install dependencies
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 💾 Cache Android dependencies
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: |
|
||||
android/.gradle
|
||||
key: ${{ runner.os }}-android-deps-${{ hashFiles('android/**/build.gradle') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-android-deps-
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 🚀 Build APK via Bun
|
||||
run: bun run build:android:local
|
||||
|
||||
- name: 📅 Set date tag
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload APK artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: streamyfin-apk-${{ env.DATE_TAG }}
|
||||
path: |
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
android/app/build/outputs/bundle/release/*.aab
|
||||
retention-days: 7
|
||||
49
.github/workflows/build-ios.yaml
vendored
49
.github/workflows/build-ios.yaml
vendored
@@ -1,49 +0,0 @@
|
||||
name: Automatic Build and Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-15
|
||||
name: Build IOS
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
name: Check out repository
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- run: |
|
||||
bun i && bun run submodule-reload
|
||||
npx expo prebuild
|
||||
- uses: sparkfabrik/ios-build-action@v2.3.0
|
||||
with:
|
||||
upload-to-testflight: false
|
||||
increment-build-number: false
|
||||
build-pods: true
|
||||
pods-path: "ios/Podfile"
|
||||
configuration: Release
|
||||
# Change later to app-store if wanted
|
||||
export-method: appstore
|
||||
#export-method: ad-hoc
|
||||
workspace-path: "ios/Streamyfin.xcodeproj/project.xcworkspace/"
|
||||
project-path: "ios/Streamyfin.xcodeproj"
|
||||
scheme: Streamyfin
|
||||
apple-key-id: ${{ secrets.APPLE_KEY_ID }}
|
||||
apple-key-issuer-id: ${{ secrets.APPLE_KEY_ISSUER_ID }}
|
||||
apple-key-content: ${{ secrets.APPLE_KEY_CONTENT }}
|
||||
team-id: ${{ secrets.TEAM_ID }}
|
||||
team-name: ${{ secrets.TEAM_NAME }}
|
||||
#match-password: ${{ secrets.MATCH_PASSWORD }}
|
||||
#match-git-url: ${{ secrets.MATCH_GIT_URL }}
|
||||
#match-git-basic-authorization: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
|
||||
#match-build-type: "appstore"
|
||||
#browserstack-upload: true
|
||||
#browserstack-username: ${{ secrets.BROWSERSTACK_USERNAME }}
|
||||
#browserstack-access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
|
||||
#fastlane-env: stage
|
||||
ios-app-id: com.stetsed.teststreamyfin
|
||||
output-path: build-${{ github.sha }}.ipa
|
||||
70
.github/workflows/build-ios.yml
vendored
Normal file
70
.github/workflows/build-ios.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: 🤖 iOS IPA Build
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [develop, master]
|
||||
push:
|
||||
branches: [develop, master]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-15
|
||||
name: 🏗️ Build iOS IPA
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: 📥 Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
bun-version: '1.2.17'
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache-
|
||||
|
||||
- name: 📦 Install & Prepare
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 🏗 Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
with:
|
||||
eas-version: 16.7.1
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: 🏗️ Build iOS app
|
||||
run: |
|
||||
eas build -p ios --local --non-interactive
|
||||
|
||||
- name: 📅 Set date tag
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload IPA artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: streamyfin-ipa-${{ env.DATE_TAG }}
|
||||
path: |
|
||||
build-*.ipa
|
||||
retention-days: 7
|
||||
46
.github/workflows/check-lockfile.yml
vendored
Normal file
46
.github/workflows/check-lockfile.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
name: 🔒 Lockfile Consistency Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [develop, master]
|
||||
push:
|
||||
branches: [develop, master]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-lockfile:
|
||||
name: 🔍 Check bun.lock and package.json consistency
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
bun-version: '1.2.17'
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||
|
||||
- name: 🛡️ Verify lockfile consistency
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
echo "➡️ Checking for discrepancies between bun.lock and package.json..."
|
||||
bun install --frozen-lockfile --dry-run --ignore-scripts
|
||||
echo "✅ Lockfile is consistent with package.json!"
|
||||
43
.github/workflows/ci-codeql.yml
vendored
Normal file
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@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended,security-and-quality
|
||||
|
||||
- name: 🛠️ Autobuild
|
||||
uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
|
||||
- name: 🧪 Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
24
.github/workflows/conflict.yml
vendored
Normal file
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
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
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
|
||||
95
.github/workflows/linting.yml
vendored
Normal file
95
.github/workflows/linting.yml
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
name: 🚦 Security & Quality Gate
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
branches: [develop, master]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate_pr_title:
|
||||
name: "📝 Validate PR Title"
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3
|
||||
id: lint_pr_title
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: marocchino/sticky-pull-request-comment@d2ad0de260ae8b0235ce059e63f2949ba9e05943 # v2.9.3
|
||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
Hey there and thank you for opening this pull request! 👋🏼
|
||||
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
|
||||
|
||||
**Error details:**
|
||||
```
|
||||
${{ steps.lint_pr_title.outputs.error_message }}
|
||||
```
|
||||
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@d2ad0de260ae8b0235ce059e63f2949ba9e05943 # v2.9.3
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
delete: true
|
||||
|
||||
dependency-review:
|
||||
name: 🔍 Vulnerable Dependencies
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
|
||||
with:
|
||||
fail-on-severity: high
|
||||
deny-licenses: GPL-3.0, AGPL-3.0
|
||||
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
||||
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||
|
||||
code_quality:
|
||||
name: "🔍 Lint & Test (${{ matrix.command }})"
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
command:
|
||||
- "lint"
|
||||
- "check"
|
||||
steps:
|
||||
- name: "📥 Checkout PR code"
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: '22.x'
|
||||
|
||||
- name: "🍞 Setup Bun"
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
bun-version: '1.2.17'
|
||||
|
||||
- name: "📦 Install dependencies"
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: "🚨 Run ${{ matrix.command }}"
|
||||
run: bun run ${{ matrix.command }}
|
||||
39
.github/workflows/main.yml
vendored
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
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
23
.github/workflows/notification.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: 🛎️ Discord Pull Request Notification
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
branches: [develop]
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: 🛎️ Notify Discord
|
||||
uses: Ilshidur/action-discord@0c4b27844ba47cb1c7bee539c8eead5284ce9fa9 # 0.3.2
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
|
||||
with:
|
||||
args: |
|
||||
📢 New Pull Request in **${{ github.repository }}**
|
||||
**Title:** ${{ github.event.pull_request.title }}
|
||||
**By:** ${{ github.event.pull_request.user.login }}
|
||||
**Branch:** ${{ github.event.pull_request.head.ref }}
|
||||
🔗 ${{ github.event.pull_request.html_url }}
|
||||
49
.github/workflows/stale.yml
vendored
Normal file
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
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -11,7 +11,6 @@ npm-debug.*
|
||||
web-build/
|
||||
modules/vlc-player/android/build
|
||||
modules/vlc-player/android/.gradle
|
||||
bun.lockb
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
@@ -20,9 +19,7 @@ expo-env.d.ts
|
||||
|
||||
Streamyfin.app
|
||||
|
||||
build-*
|
||||
*.mp4
|
||||
build-*
|
||||
Streamyfin.app
|
||||
package-lock.json
|
||||
|
||||
@@ -47,4 +44,5 @@ credentials.json
|
||||
modules/hls-downloader/android/build
|
||||
streamyfin-4fec1-firebase-adminsdk.json
|
||||
.env
|
||||
.env.local
|
||||
.env.local
|
||||
*.aab
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -4,7 +4,7 @@
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"prettier.printWidth": 120,
|
||||
|
||||
53
README.md
53
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||
|
||||
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.
|
||||
A simple and user-friendly Jellyfin video streaming client built with Expo. If you are looking for an alternative to other Jellyfin clients, we hope you find Streamyfin a useful addition to your media streaming toolbox.
|
||||
|
||||
<div style="display: flex; flex-direction: row; gap: 8px">
|
||||
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
||||
@@ -15,47 +15,47 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
||||
|
||||
- 🚀 **Skip Intro / Credits Support**
|
||||
- 🖼️ **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.
|
||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
||||
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
|
||||
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
||||
- 👁️ **Sessions View:** View all active sessions currently streaming on your server.
|
||||
|
||||
## 🧪 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.
|
||||
|
||||
### 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
|
||||
- Set download method and search provider
|
||||
- Customize homescreen
|
||||
- And more...
|
||||
- Customize home screen
|
||||
- And much more...
|
||||
|
||||
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
||||
|
||||
### Jellysearch
|
||||
### 🔍 Jellysearch
|
||||
|
||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀
|
||||
|
||||
> 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;">
|
||||
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||
@@ -64,9 +64,9 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to
|
||||
|
||||
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -81,7 +81,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.
|
||||
|
||||
### Development info
|
||||
### 👨💻 Development info
|
||||
|
||||
1. Use node `>20`
|
||||
2. Install dependencies `bun i && bun run submodule-reload`
|
||||
@@ -118,6 +118,13 @@ If you have questions or need support, feel free to reach out:
|
||||
- GitHub Issues: Report bugs or request features here.
|
||||
- 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
|
||||
|
||||
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,7 +135,7 @@ 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.
|
||||
|
||||
### Core Developers
|
||||
### 🏆 Core Developers
|
||||
|
||||
Thanks to the following contributors for their significant contributions:
|
||||
|
||||
@@ -213,6 +220,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.
|
||||
- The Jellyfin devs for always being helpful in the Discord.
|
||||
|
||||
## Star History
|
||||
## ⭐ Star History
|
||||
|
||||
[](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)
|
||||
|
||||
22
app.json
22
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.28.0",
|
||||
"version": "0.28.1",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -27,13 +27,19 @@
|
||||
"usesNonExemptEncryption": false
|
||||
},
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.fredrikburmester.streamyfin"
|
||||
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||
"icon": {
|
||||
"dark": "./assets/images/icon-plain.png",
|
||||
"light": "./assets/images/icon-ios-light.png",
|
||||
"tinted": "./assets/images/icon-ios-tinted.png"
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 54,
|
||||
"versionCode": 56,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive_icon.png",
|
||||
"foregroundImage": "./assets/images/icon-plain.png",
|
||||
"monochromeImage": "./assets/images/icon-mono.png",
|
||||
"backgroundColor": "#464646"
|
||||
},
|
||||
"package": "com.fredrikburmester.streamyfin",
|
||||
@@ -48,7 +54,6 @@
|
||||
"@react-native-tvos/config-tv",
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
"@config-plugins/ffmpeg-kit-react-native",
|
||||
[
|
||||
"react-native-video",
|
||||
{
|
||||
@@ -113,6 +118,7 @@
|
||||
["./plugins/withAndroidManifest.js"],
|
||||
["./plugins/withTrustLocalCerts.js"],
|
||||
["./plugins/withGradleProperties.js"],
|
||||
["./plugins/withRNBackgroundDownloader.js"],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
@@ -127,6 +133,12 @@
|
||||
"icon": "./assets/images/notification.png",
|
||||
"color": "#9333EA"
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-google-cast",
|
||||
{
|
||||
"useDefaultExpandedMediaControls": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
|
||||
@@ -64,12 +64,6 @@ export default function IndexLayout() {
|
||||
title: t("home.settings.settings_title"),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/optimized-server/page'
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/marlin-search/page'
|
||||
options={{
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||
import { type DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
@@ -16,30 +6,58 @@ import {
|
||||
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 { useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { type DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
function migration_20241124(
|
||||
deleteAllFiles: () => Promise<void>,
|
||||
router: any,
|
||||
t: any,
|
||||
) {
|
||||
Alert.alert(
|
||||
t("home.downloads.new_app_version_requires_re_download"),
|
||||
undefined,
|
||||
[
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
onPress: () => router.back(),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("common.continue"),
|
||||
onPress: () => deleteAllFiles(),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
|
||||
const { deleteFileByType, downloadedFiles, removeProcess, deleteAllFiles } = useDownload();
|
||||
const router = useRouter();
|
||||
const [settings] = useSettings();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const movies = useMemo(() => {
|
||||
try {
|
||||
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
||||
} catch {
|
||||
migration_20241124();
|
||||
return [];
|
||||
}
|
||||
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const groupedBySeries = useMemo(() => {
|
||||
@@ -54,12 +72,12 @@ export default function page() {
|
||||
});
|
||||
return Object.values(series);
|
||||
} catch {
|
||||
migration_20241124();
|
||||
migration_20241124(deleteAllFiles, router, t);
|
||||
return [];
|
||||
}
|
||||
}, [downloadedFiles]);
|
||||
}, [downloadedFiles, deleteAllFiles, router, t]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
const _insets = useSafeAreaInsets();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
@@ -98,16 +116,10 @@ export default function page() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 100,
|
||||
}}
|
||||
>
|
||||
<View className='py-4'>
|
||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||
{settings?.downloadMethod === DownloadMethod.Remux && (
|
||||
<View style={{ flex: 1 }}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
|
||||
<View className='py-4'>
|
||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.queue")}
|
||||
@@ -151,70 +163,74 @@ export default function page() {
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ActiveDownloads />
|
||||
</View>
|
||||
|
||||
{movies.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.movies")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{movies?.map((item) => (
|
||||
<View className='mb-2 last:mb-0' key={item.item.Id}>
|
||||
<MovieCard item={item.item} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
<ActiveDownloads />
|
||||
</View>
|
||||
)}
|
||||
{groupedBySeries.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.tvseries")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>
|
||||
{groupedBySeries?.length}
|
||||
|
||||
{movies.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.movies")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{movies?.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item.item}
|
||||
isOffline
|
||||
key={item.item.Id}
|
||||
>
|
||||
<MovieCard item={item.item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{groupedBySeries?.map((items) => (
|
||||
<View
|
||||
className='mb-2 last:mb-0'
|
||||
key={items[0].item.SeriesId}
|
||||
>
|
||||
<SeriesCard
|
||||
items={items.map((i) => i.item)}
|
||||
key={items[0].item.SeriesId}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
)}
|
||||
{groupedBySeries.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.tvseries")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>
|
||||
{groupedBySeries?.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{downloadedFiles?.length === 0 && (
|
||||
<View className='flex px-4'>
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_downloaded_items")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{groupedBySeries?.map((items) => (
|
||||
<View
|
||||
className='mb-2 last:mb-0'
|
||||
key={items[0].item.SeriesId}
|
||||
>
|
||||
<SeriesCard
|
||||
items={items.map((i) => i.item)}
|
||||
key={items[0].item.SeriesId}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{downloadedFiles?.length === 0 && (
|
||||
<View className='flex px-4'>
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_downloaded_items")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
@@ -249,23 +265,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(),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,12 +18,18 @@ import {
|
||||
HardwareAccelerationType,
|
||||
type SessionInfoDto,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
GeneralCommandType,
|
||||
PlaystateCommand,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { get } from "lodash";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
|
||||
export default function page() {
|
||||
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
||||
@@ -110,6 +116,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);
|
||||
|
||||
return (
|
||||
@@ -181,6 +258,107 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getOrSetDeviceId } from "@/utils/device";
|
||||
import { getStatistics } from "@/utils/optimize-server";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||
|
||||
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
||||
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (newVal: string) => {
|
||||
if (newVal.length === 0 || !newVal.startsWith("http")) {
|
||||
toast.error(t("home.settings.toasts.invalid_url"));
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedUrl = newVal.endsWith("/") ? newVal : `${newVal}/`;
|
||||
|
||||
updateSettings({
|
||||
optimizedVersionsServerUrl: updatedUrl,
|
||||
});
|
||||
|
||||
return await getStatistics({
|
||||
url: updatedUrl,
|
||||
authHeader: api?.accessToken,
|
||||
deviceId: getOrSetDeviceId(),
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
toast.success(t("home.settings.toasts.connected"));
|
||||
} else {
|
||||
toast.error(t("home.settings.toasts.could_not_connect"));
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("home.settings.toasts.could_not_connect"));
|
||||
},
|
||||
});
|
||||
|
||||
const onSave = (newVal: string) => {
|
||||
saveMutation.mutate(newVal);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!pluginSettings?.optimizedVersionsServerUrl?.locked) {
|
||||
navigation.setOptions({
|
||||
title: t("home.settings.downloads.optimized_server"),
|
||||
headerRight: () =>
|
||||
saveMutation.isPending ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => onSave(optimizedVersionsServerUrl)}
|
||||
>
|
||||
<Text className='text-blue-500'>
|
||||
{t("home.settings.downloads.save_button")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
|
||||
|
||||
return (
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
|
||||
className='p-4'
|
||||
>
|
||||
<OptimizedServerForm
|
||||
value={optimizedVersionsServerUrl}
|
||||
onChangeValue={setOptimizedVersionsServerUrl}
|
||||
/>
|
||||
</DisabledSetting>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -15,29 +9,18 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { id } = useLocalSearchParams() as { id: string };
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: item, isError } = useQuery({
|
||||
queryKey: ["item", id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || !id) return;
|
||||
const res = await getUserLibraryApi(api).getItem({
|
||||
itemId: id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||
const isOffline = offline === "true";
|
||||
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
const { data: item, isError } = useItemQuery(id, isOffline);
|
||||
|
||||
const opacity = useSharedValue(1);
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
@@ -107,7 +90,7 @@ const Page: React.FC = () => {
|
||||
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||
</Animated.View>
|
||||
{item && <ItemContent item={item} />}
|
||||
{item && <ItemContent item={item} isOffline={isOffline} />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
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";
|
||||
@@ -46,6 +46,7 @@ const Page: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
|
||||
params as unknown as {
|
||||
@@ -236,30 +237,65 @@ const Page: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View className='mb-4'>
|
||||
<View>
|
||||
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
||||
</View>
|
||||
{isLoading || isFetching ? (
|
||||
<Button loading={true} disabled={true} color='purple' />
|
||||
<Button
|
||||
loading={true}
|
||||
disabled={true}
|
||||
color='purple'
|
||||
className='mt-4'
|
||||
/>
|
||||
) : canRequest ? (
|
||||
<Button color='purple' onPress={request}>
|
||||
<Button color='purple' onPress={request} className='mt-4'>
|
||||
{t("jellyseerr.request_button")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className='bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||
color='transparent'
|
||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||
iconLeft={
|
||||
<Ionicons name='warning-outline' size={24} color='white' />
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
{t("jellyseerr.report_issue_button")}
|
||||
</Button>
|
||||
details?.mediaInfo?.jellyfinMediaId && (
|
||||
<View className='flex flex-row space-x-2 mt-4'>
|
||||
<Button
|
||||
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||
color='transparent'
|
||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name='warning-outline'
|
||||
size={20}
|
||||
color='white'
|
||||
/>
|
||||
}
|
||||
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' />
|
||||
</View>
|
||||
|
||||
@@ -69,10 +69,16 @@ const page: React.FC = () => {
|
||||
seriesId: item?.Id!,
|
||||
userId: user?.Id!,
|
||||
enableUserData: true,
|
||||
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||
// Note: Including trick play is necessary to enable trick play downloads
|
||||
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||
});
|
||||
return res?.data.Items || [];
|
||||
},
|
||||
select: (data) =>
|
||||
// This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order.
|
||||
[...(data || [])].sort(
|
||||
(a, b) => (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) || (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0)
|
||||
),
|
||||
staleTime: 60,
|
||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
||||
});
|
||||
@@ -136,7 +142,7 @@ const page: React.FC = () => {
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col pt-4'>
|
||||
|
||||
@@ -433,15 +433,6 @@ const Page = () => {
|
||||
</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 (
|
||||
<FlashList
|
||||
key={orientation}
|
||||
|
||||
@@ -331,7 +331,7 @@ export default function search() {
|
||||
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||
<SearchItemWrapper
|
||||
header={t("search.movies")}
|
||||
ids={movies?.map((m) => m.Id!)}
|
||||
items={movies}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
@@ -349,7 +349,7 @@ export default function search() {
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={series?.map((m) => m.Id!)}
|
||||
items={series}
|
||||
header={t("search.series")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
@@ -368,7 +368,7 @@ export default function search() {
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={episodes?.map((m) => m.Id!)}
|
||||
items={episodes}
|
||||
header={t("search.episodes")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
@@ -382,7 +382,7 @@ export default function search() {
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={collections?.map((m) => m.Id!)}
|
||||
items={collections}
|
||||
header={t("search.collections")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
@@ -398,7 +398,7 @@ export default function search() {
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={actors?.map((m) => m.Id!)}
|
||||
items={actors}
|
||||
header={t("search.actors")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
@@ -434,7 +434,10 @@ export default function search() {
|
||||
<View className='mt-4 flex flex-col items-center space-y-2'>
|
||||
{exampleSearches.map((e) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => setSearch(e)}
|
||||
onPress={() => {
|
||||
setSearch(e);
|
||||
searchBarRef.current?.setText(e);
|
||||
}}
|
||||
key={e}
|
||||
className='mb-2'
|
||||
>
|
||||
|
||||
@@ -1,33 +1,8 @@
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Stack } from "expo-router";
|
||||
import React, { useLayoutEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import React from "react";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
|
||||
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 (
|
||||
<>
|
||||
<SystemBars hidden />
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
import {
|
||||
type BaseItemDto,
|
||||
type MediaSourceInfo,
|
||||
PlaybackOrder,
|
||||
PlaybackStartInfo,
|
||||
RepeatMode,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
getPlaystateApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||
import { router, useGlobalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, 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 { Loader } from "@/components/Loader";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { VlcPlayerView } from "@/modules";
|
||||
@@ -13,41 +32,17 @@ import type {
|
||||
ProgressUpdatePayload,
|
||||
VlcPlayerViewRef,
|
||||
} from "@/modules/VlcPlayer.types";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
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 {
|
||||
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
|
||||
? require("@/providers/DownloadProvider")
|
||||
: null;
|
||||
|
||||
const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas";
|
||||
|
||||
export default function page() {
|
||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||
@@ -58,8 +53,13 @@ export default function page() {
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(() => {
|
||||
// Load persisted state from storage
|
||||
const saved = storage.getBoolean(IGNORE_SAFE_AREAS_KEY);
|
||||
return saved ?? false;
|
||||
});
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
const [isPipStarted, setIsPipStarted] = useState(false);
|
||||
@@ -67,10 +67,11 @@ export default function page() {
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
let getDownloadedItem = null;
|
||||
if (!Platform.isTV) {
|
||||
getDownloadedItem = downloadProvider.useDownload();
|
||||
}
|
||||
const VolumeManager = Platform.isTV
|
||||
? null
|
||||
: require("react-native-volume-manager");
|
||||
|
||||
const downloadUtils = useDownload();
|
||||
|
||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
@@ -81,6 +82,11 @@ export default function page() {
|
||||
lightHapticFeedback();
|
||||
}, []);
|
||||
|
||||
// Persist ignoreSafeAreas state whenever it changes
|
||||
useEffect(() => {
|
||||
storage.set(IGNORE_SAFE_AREAS_KEY, ignoreSafeAreas);
|
||||
}, [ignoreSafeAreas]);
|
||||
|
||||
const {
|
||||
itemId,
|
||||
audioIndex: audioIndexStr,
|
||||
@@ -88,6 +94,7 @@ export default function page() {
|
||||
mediaSourceId,
|
||||
bitrateValue: bitrateValueStr,
|
||||
offline: offlineStr,
|
||||
playbackPosition: playbackPositionFromUrl,
|
||||
} = useGlobalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
@@ -95,10 +102,13 @@ export default function page() {
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
offline: string;
|
||||
/** Playback position in ticks. */
|
||||
playbackPosition?: string;
|
||||
}>();
|
||||
const [settings] = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const offline = offlineStr === "true";
|
||||
const playbackManager = usePlaybackManager();
|
||||
|
||||
const audioIndex = audioIndexStr
|
||||
? Number.parseInt(audioIndexStr, 10)
|
||||
@@ -111,19 +121,33 @@ export default function page() {
|
||||
: BITRATES[0].value;
|
||||
|
||||
const [item, setItem] = useState<BaseItemDto | null>(null);
|
||||
const [downloadedItem, setDownloadedItem] = useState<DownloadedItem | null>(
|
||||
null,
|
||||
);
|
||||
const [itemStatus, setItemStatus] = useState({
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
/** Gets the initial playback position from the URL. */
|
||||
const getInitialPlaybackTicks = useCallback((): number => {
|
||||
if (playbackPositionFromUrl) {
|
||||
return Number.parseInt(playbackPositionFromUrl, 10);
|
||||
}
|
||||
return 0;
|
||||
}, [playbackPositionFromUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchItemData = async () => {
|
||||
setItemStatus({ isLoading: true, isError: false });
|
||||
try {
|
||||
let fetchedItem: BaseItemDto | null = null;
|
||||
if (offline && !Platform.isTV) {
|
||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||
if (data) fetchedItem = data.item as BaseItemDto;
|
||||
const data = downloadUtils.getDownloadedItemById(itemId);
|
||||
if (data) {
|
||||
fetchedItem = data.item as BaseItemDto;
|
||||
setDownloadedItem(data);
|
||||
}
|
||||
} else {
|
||||
const res = await getUserLibraryApi(api!).getItem({
|
||||
itemId,
|
||||
@@ -132,11 +156,10 @@ export default function page() {
|
||||
fetchedItem = res.data;
|
||||
}
|
||||
setItem(fetchedItem);
|
||||
setItemStatus({ isLoading: false, isError: false });
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch item:", error);
|
||||
setItemStatus({ isLoading: false, isError: true });
|
||||
} finally {
|
||||
setItemStatus({ isLoading: false, isError: false });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -159,26 +182,32 @@ export default function page() {
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStreamData = async () => {
|
||||
setStreamStatus({ isLoading: true, isError: false });
|
||||
try {
|
||||
let result: Stream | null = null;
|
||||
if (offline && !Platform.isTV) {
|
||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||
if (!data?.mediaSource) return;
|
||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||
if (offline && downloadedItem) {
|
||||
if (!downloadedItem?.mediaSource) return;
|
||||
const url = downloadedItem.videoFilePath;
|
||||
if (item) {
|
||||
result = { mediaSource: data.mediaSource, sessionId: "", url };
|
||||
result = {
|
||||
mediaSource: downloadedItem.mediaSource,
|
||||
sessionId: "",
|
||||
url: url,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const native = await generateDeviceProfile();
|
||||
const transcoding = await generateDeviceProfile({ transcode: true });
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||
startTimeTicks: getInitialPlaybackTicks(),
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: native,
|
||||
deviceProfile: bitrateValue ? transcoding : native,
|
||||
});
|
||||
if (!res) return;
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
@@ -192,34 +221,43 @@ export default function page() {
|
||||
result = { mediaSource, sessionId, url };
|
||||
}
|
||||
setStream(result);
|
||||
setStreamStatus({ isLoading: false, isError: false });
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch stream:", error);
|
||||
setStreamStatus({ isLoading: false, isError: true });
|
||||
} finally {
|
||||
setStreamStatus({ isLoading: false, isError: false });
|
||||
}
|
||||
};
|
||||
fetchStreamData();
|
||||
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
|
||||
}, [
|
||||
itemId,
|
||||
mediaSourceId,
|
||||
bitrateValue,
|
||||
api,
|
||||
item,
|
||||
user?.Id,
|
||||
downloadedItem,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stream) return;
|
||||
|
||||
if (!stream || !api) return;
|
||||
const reportPlaybackStart = async () => {
|
||||
await getPlaystateApi(api!).reportPlaybackStart({
|
||||
console.log("reporting playback start", currentPlayStateInfo());
|
||||
await getPlaystateApi(api).reportPlaybackStart({
|
||||
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
||||
});
|
||||
};
|
||||
|
||||
reportPlaybackStart();
|
||||
}, [stream]);
|
||||
}, [stream, api]);
|
||||
|
||||
const togglePlay = async () => {
|
||||
lightHapticFeedback();
|
||||
setIsPlaying(!isPlaying);
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
reportPlaybackStopped();
|
||||
playbackManager.reportPlaybackProgress(
|
||||
item?.Id!,
|
||||
msToTicks(progress.get()),
|
||||
);
|
||||
} else {
|
||||
videoRef.current?.play();
|
||||
await getPlaystateApi(api!).reportPlaybackStart({
|
||||
@@ -229,7 +267,6 @@ export default function page() {
|
||||
};
|
||||
|
||||
const reportPlaybackStopped = useCallback(async () => {
|
||||
if (offline) return;
|
||||
const currentTimeInTicks = msToTicks(progress.get());
|
||||
await getPlaystateApi(api!).onPlaybackStopped({
|
||||
itemId: item?.Id!,
|
||||
@@ -237,14 +274,21 @@ export default function page() {
|
||||
positionTicks: currentTimeInTicks,
|
||||
playSessionId: stream?.sessionId!,
|
||||
});
|
||||
|
||||
revalidateProgressCache();
|
||||
}, [api, item, mediaSourceId, stream]);
|
||||
}, [
|
||||
api,
|
||||
item,
|
||||
mediaSourceId,
|
||||
stream,
|
||||
progress,
|
||||
offline,
|
||||
revalidateProgressCache,
|
||||
]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
reportPlaybackStopped();
|
||||
setIsPlaybackStopped(true);
|
||||
videoRef.current?.stop();
|
||||
revalidateProgressCache();
|
||||
}, [videoRef, reportPlaybackStopped]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -265,7 +309,7 @@ export default function page() {
|
||||
isPaused: !isPlaying,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream.sessionId,
|
||||
isMuted: false,
|
||||
isMuted: isMuted,
|
||||
canSeek: true,
|
||||
repeatMode: RepeatMode.RepeatNone,
|
||||
playbackOrder: PlaybackOrder.Default,
|
||||
@@ -283,11 +327,21 @@ export default function page() {
|
||||
|
||||
progress.set(currentTime);
|
||||
|
||||
if (offline) return;
|
||||
// Update the playback position in the URL.
|
||||
router.setParams({
|
||||
playbackPosition: msToTicks(currentTime).toString(),
|
||||
});
|
||||
|
||||
if (!item?.Id || !stream) return;
|
||||
if (!item?.Id) return;
|
||||
|
||||
reportPlaybackProgress();
|
||||
playbackManager.reportPlaybackProgress(
|
||||
item.Id,
|
||||
msToTicks(progress.get()),
|
||||
{
|
||||
AudioStreamIndex: audioIndex ?? -1,
|
||||
SubtitleStreamIndex: subtitleIndex ?? -1,
|
||||
},
|
||||
);
|
||||
},
|
||||
[
|
||||
item?.Id,
|
||||
@@ -307,35 +361,87 @@ export default function page() {
|
||||
setIsPipStarted(pipStarted);
|
||||
}, []);
|
||||
|
||||
const reportPlaybackProgress = useCallback(async () => {
|
||||
if (!api || offline || !stream) return;
|
||||
await getPlaystateApi(api).reportPlaybackProgress({
|
||||
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
|
||||
});
|
||||
}, [
|
||||
api,
|
||||
isPlaying,
|
||||
offline,
|
||||
stream,
|
||||
item?.Id,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
progress,
|
||||
]);
|
||||
|
||||
/** Gets the initial playback position in seconds. */
|
||||
const startPosition = useMemo(() => {
|
||||
if (offline) return 0;
|
||||
return item?.UserData?.PlaybackPositionTicks
|
||||
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
||||
: 0;
|
||||
}, [item]);
|
||||
return ticksToSeconds(getInitialPlaybackTicks());
|
||||
}, [getInitialPlaybackTicks]);
|
||||
|
||||
const volumeUpCb = useCallback(async () => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
try {
|
||||
const { volume: currentVolume } = await VolumeManager.getVolume();
|
||||
const newVolume = Math.min(currentVolume + 0.1, 1.0);
|
||||
|
||||
await VolumeManager.setVolume(newVolume);
|
||||
} catch (error) {
|
||||
console.error("Error adjusting volume:", error);
|
||||
}
|
||||
}, []);
|
||||
const [previousVolume, setPreviousVolume] = useState<number | null>(null);
|
||||
|
||||
const toggleMuteCb = useCallback(async () => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
try {
|
||||
const { volume: currentVolume } = await VolumeManager.getVolume();
|
||||
const currentVolumePercent = currentVolume * 100;
|
||||
|
||||
if (currentVolumePercent > 0) {
|
||||
// Currently not muted, so mute
|
||||
setPreviousVolume(currentVolumePercent);
|
||||
await VolumeManager.setVolume(0);
|
||||
setIsMuted(true);
|
||||
} else {
|
||||
// Currently muted, so restore previous volume
|
||||
const volumeToRestore = previousVolume || 50; // Default to 50% if no previous volume
|
||||
await VolumeManager.setVolume(volumeToRestore / 100);
|
||||
setPreviousVolume(null);
|
||||
setIsMuted(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error toggling mute:", error);
|
||||
}
|
||||
}, [previousVolume]);
|
||||
const volumeDownCb = useCallback(async () => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
try {
|
||||
const { volume: currentVolume } = await VolumeManager.getVolume();
|
||||
const newVolume = Math.max(currentVolume - 0.1, 0); // Decrease by 10%
|
||||
console.log(
|
||||
"Volume Down",
|
||||
Math.round(currentVolume * 100),
|
||||
"→",
|
||||
Math.round(newVolume * 100),
|
||||
);
|
||||
await VolumeManager.setVolume(newVolume);
|
||||
} catch (error) {
|
||||
console.error("Error adjusting volume:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setVolumeCb = useCallback(async (newVolume: number) => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
try {
|
||||
const clampedVolume = Math.max(0, Math.min(newVolume, 100));
|
||||
console.log("Setting volume to", clampedVolume);
|
||||
await VolumeManager.setVolume(clampedVolume / 100);
|
||||
} catch (error) {
|
||||
console.error("Error setting volume:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
togglePlay: togglePlay,
|
||||
stopPlayback: stop,
|
||||
offline,
|
||||
toggleMute: toggleMuteCb,
|
||||
volumeUp: volumeUpCb,
|
||||
volumeDown: volumeDownCb,
|
||||
setVolume: setVolumeCb,
|
||||
});
|
||||
|
||||
const onPlaybackStateChanged = useCallback(
|
||||
@@ -343,14 +449,24 @@ export default function page() {
|
||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||
if (state === "Playing") {
|
||||
setIsPlaying(true);
|
||||
reportPlaybackProgress();
|
||||
if (item?.Id) {
|
||||
playbackManager.reportPlaybackProgress(
|
||||
item.Id,
|
||||
msToTicks(progress.get()),
|
||||
);
|
||||
}
|
||||
if (!Platform.isTV) await activateKeepAwakeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "Paused") {
|
||||
setIsPlaying(false);
|
||||
reportPlaybackProgress();
|
||||
if (item?.Id) {
|
||||
playbackManager.reportPlaybackProgress(
|
||||
item.Id,
|
||||
msToTicks(progress.get()),
|
||||
);
|
||||
}
|
||||
if (!Platform.isTV) await deactivateKeepAwake();
|
||||
return;
|
||||
}
|
||||
@@ -362,7 +478,7 @@ export default function page() {
|
||||
setIsBuffering(true);
|
||||
}
|
||||
},
|
||||
[reportPlaybackProgress],
|
||||
[playbackManager, item?.Id, progress],
|
||||
);
|
||||
|
||||
const allAudio =
|
||||
@@ -380,25 +496,29 @@ export default function page() {
|
||||
.filter((sub: any) => sub.DeliveryMethod === "External")
|
||||
.map((sub: any) => ({
|
||||
name: sub.DisplayTitle,
|
||||
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
|
||||
DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
|
||||
}));
|
||||
|
||||
/** The text based subtitle tracks */
|
||||
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
||||
|
||||
/** The user chosen subtitle track from the server */
|
||||
const chosenSubtitleTrack = allSubs.find(
|
||||
(sub) => sub.Index === subtitleIndex,
|
||||
);
|
||||
/** The user chosen audio track from the server */
|
||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||
|
||||
/** Whether the stream we're playing is not transcoding*/
|
||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||
/** The initial options to pass to the VLC Player */
|
||||
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||
if (
|
||||
chosenSubtitleTrack &&
|
||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||
) {
|
||||
// If not transcoding, we can the index as normal.
|
||||
// If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
|
||||
const finalIndex = notTranscoding
|
||||
? allSubs.indexOf(chosenSubtitleTrack)
|
||||
: textSubs.indexOf(chosenSubtitleTrack);
|
||||
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
|
||||
initOptions.push(`--sub-track=${finalIndex}`);
|
||||
}
|
||||
|
||||
@@ -414,7 +534,7 @@ export default function page() {
|
||||
return () => setIsMounted(false);
|
||||
}, []);
|
||||
|
||||
if (itemStatus.isLoading || streamStatus.isLoading) {
|
||||
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
|
||||
return (
|
||||
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
||||
<Loader />
|
||||
@@ -422,7 +542,7 @@ export default function page() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!item || !stream || itemStatus.isError || streamStatus.isError)
|
||||
if (itemStatus.isError || streamStatus.isError)
|
||||
return (
|
||||
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
||||
<Text className='text-white'>{t("player.error")}</Text>
|
||||
@@ -448,7 +568,7 @@ export default function page() {
|
||||
source={{
|
||||
uri: stream?.url || "",
|
||||
autoplay: true,
|
||||
isNetwork: true,
|
||||
isNetwork: !offline,
|
||||
startPosition,
|
||||
externalSubtitles,
|
||||
initOptions,
|
||||
@@ -471,7 +591,7 @@ export default function page() {
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
{videoRef.current && !isPipStarted && isMounted === true ? (
|
||||
{videoRef.current && !isPipStarted && isMounted === true && item ? (
|
||||
<Controls
|
||||
mediaSource={stream?.mediaSource}
|
||||
item={item}
|
||||
|
||||
206
app/_layout.tsx
206
app/_layout.tsx
@@ -7,7 +7,6 @@ import {
|
||||
getOrSetDeviceId,
|
||||
getTokenFromStorage,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
writeToLog,
|
||||
} from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
@@ -137,16 +135,13 @@ if (!Platform.isTV) {
|
||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||
console.log("TaskManager ~ trigger");
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const settingsData = storage.getString("settings");
|
||||
|
||||
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||
const url = settings?.optimizedVersionsServerUrl;
|
||||
|
||||
if (!settings?.autoDownload || !url)
|
||||
if (!settings?.autoDownload)
|
||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const token = getTokenFromStorage();
|
||||
@@ -156,74 +151,6 @@ if (!Platform.isTV) {
|
||||
if (!token || !deviceId || !baseDirectory)
|
||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const jobs = await getAllJobsByDeviceId({
|
||||
deviceId,
|
||||
authHeader: token,
|
||||
url,
|
||||
});
|
||||
|
||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.status === "completed") {
|
||||
const downloadUrl = `${url}download/${job.id}`;
|
||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||
|
||||
if (tasks.find((task: { id: string }) => task.id === job.id)) {
|
||||
console.log("TaskManager ~ Download already in progress: ", job.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
BackGroundDownloader.download({
|
||||
id: job.id,
|
||||
url: downloadUrl,
|
||||
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
||||
headers: {
|
||||
Authorization: token,
|
||||
},
|
||||
})
|
||||
.begin(() => {
|
||||
console.log("TaskManager ~ Download started: ", job.id);
|
||||
})
|
||||
.done(() => {
|
||||
console.log("TaskManager ~ Download completed: ", job.id);
|
||||
saveDownloadedItemInfo(job.item);
|
||||
BackGroundDownloader.completeHandler(job.id);
|
||||
cancelJobById({
|
||||
authHeader: token,
|
||||
id: job.id,
|
||||
url: url,
|
||||
});
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: job.item.Name,
|
||||
body: "Download completed",
|
||||
data: {
|
||||
url: "/downloads",
|
||||
},
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
})
|
||||
.error((error: any) => {
|
||||
console.log("TaskManager ~ Download error: ", job.id, error);
|
||||
BackGroundDownloader.completeHandler(job.id);
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: job.item.Name,
|
||||
body: "Download failed",
|
||||
data: {
|
||||
url: "/downloads",
|
||||
},
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
||||
|
||||
// Be sure to return the successful result type!
|
||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||
});
|
||||
@@ -414,21 +341,32 @@ function Layout() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) return;
|
||||
if (segments.includes("direct-player" as never)) {
|
||||
if (Platform.isTV) {
|
||||
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) {
|
||||
ScreenOrientation.unlockAsync();
|
||||
} else {
|
||||
// If the user has auto rotate disabled, lock the orientation to portrait
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
);
|
||||
}
|
||||
}, [settings.followDeviceOrientation, segments]);
|
||||
}, [
|
||||
settings.followDeviceOrientation,
|
||||
settings.defaultVideoOrientation,
|
||||
segments,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener(
|
||||
@@ -453,64 +391,62 @@ function Layout() {
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JobQueueProvider>
|
||||
<JellyfinProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='login'
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='+not-found' />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
<JellyfinProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</JellyfinProvider>
|
||||
</JobQueueProvider>
|
||||
<Stack.Screen
|
||||
name='(auth)/player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='login'
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='+not-found' />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</JellyfinProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
BIN
assets/images/icon-ios-light.png
Normal file
BIN
assets/images/icon-ios-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 305 KiB |
BIN
assets/images/icon-ios-tinted.png
Normal file
BIN
assets/images/icon-ios-tinted.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
BIN
assets/images/icon-mono.png
Normal file
BIN
assets/images/icon-mono.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
BIN
assets/images/icon-plain.png
Normal file
BIN
assets/images/icon-plain.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 326 KiB |
20
biome.json
20
biome.json
@@ -1,16 +1,14 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.5/schema.json",
|
||||
"files": {
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
"ios",
|
||||
"android",
|
||||
"Streamyfin.app",
|
||||
"utils/jellyseerr",
|
||||
".expo"
|
||||
"includes": [
|
||||
"**/*",
|
||||
"!node_modules/**",
|
||||
"!ios/**",
|
||||
"!android/**",
|
||||
"!Streamyfin.app/**",
|
||||
"!utils/jellyseerr/**",
|
||||
"!.expo/**"
|
||||
]
|
||||
},
|
||||
"linter": {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { ProgressBar } from "./common/ProgressBar";
|
||||
import { WatchedIndicator } from "./WatchedIndicator";
|
||||
|
||||
type ContinueWatchingPosterProps = {
|
||||
@@ -62,18 +63,6 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}, [item]);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
if (item.Type === "Program") {
|
||||
const startDate = new Date(item.StartDate || "");
|
||||
const endDate = new Date(item.EndDate || "");
|
||||
const now = new Date();
|
||||
const total = endDate.getTime() - startDate.getTime();
|
||||
const elapsed = now.getTime() - startDate.getTime();
|
||||
return (elapsed / total) * 100;
|
||||
}
|
||||
return item.UserData?.PlayedPercentage || 0;
|
||||
}, [item]);
|
||||
|
||||
if (!url)
|
||||
return <View className='aspect-video border border-neutral-800 w-44' />;
|
||||
|
||||
@@ -101,22 +90,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{!progress && <WatchedIndicator item={item} />}
|
||||
{progress > 0 && (
|
||||
<>
|
||||
<View
|
||||
className={
|
||||
"absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full"
|
||||
}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
}}
|
||||
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!item.UserData?.Played && <WatchedIndicator item={item} />}
|
||||
<ProgressBar item={item} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
BottomSheetBackdrop,
|
||||
@@ -23,17 +14,23 @@ import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
||||
import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getDownloadUrl } from "@/utils/jellyfin/media/getDownloadUrl";
|
||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
import { type Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import { Loader } from "./Loader";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import ProgressCircle from "./ProgressCircle";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface DownloadProps extends ViewProps {
|
||||
items: BaseItemDto[];
|
||||
@@ -55,11 +52,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const [queue, _setQueue] = useAtom(queueAtom);
|
||||
const [settings] = useSettings();
|
||||
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
||||
|
||||
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
||||
//const { startRemuxing } = useRemuxHlsToMp4();
|
||||
|
||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||
MediaSourceInfo | undefined | null
|
||||
@@ -78,10 +75,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
() => user?.Policy?.EnableContentDownloading,
|
||||
[user],
|
||||
);
|
||||
const usingOptimizedServer = useMemo(
|
||||
() => settings?.downloadMethod === DownloadMethod.Optimized,
|
||||
[settings],
|
||||
);
|
||||
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
@@ -89,7 +82,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
bottomSheetModalRef.current?.present();
|
||||
}, []);
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {}, []);
|
||||
const handleSheetChanges = useCallback((_index: number) => { }, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
@@ -103,6 +96,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
[items, downloadedFiles],
|
||||
);
|
||||
|
||||
const itemsToDownload = useMemo(() => {
|
||||
if (downloadUnwatchedOnly) {
|
||||
return itemsNotDownloaded.filter((item) => !item.UserData?.Played);
|
||||
}
|
||||
return itemsNotDownloaded;
|
||||
}, [itemsNotDownloaded, downloadUnwatchedOnly]);
|
||||
|
||||
const allItemsDownloaded = useMemo(() => {
|
||||
if (items.length === 0) return false;
|
||||
return itemsNotDownloaded.length === 0;
|
||||
@@ -137,50 +137,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
firstItem.Type !== "Episode"
|
||||
? "/downloads"
|
||||
: ({
|
||||
pathname: `/downloads/${firstItem.SeriesId}`,
|
||||
params: {
|
||||
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
||||
},
|
||||
} as Href),
|
||||
pathname: `/downloads/${firstItem.SeriesId}`,
|
||||
params: {
|
||||
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
||||
},
|
||||
} as Href),
|
||||
);
|
||||
};
|
||||
|
||||
const acceptDownloadOptions = useCallback(() => {
|
||||
if (userCanDownload === true) {
|
||||
if (itemsNotDownloaded.some((i) => !i.Id)) {
|
||||
throw new Error("No item id");
|
||||
}
|
||||
closeModal();
|
||||
|
||||
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
|
||||
else {
|
||||
queueActions.enqueue(
|
||||
queue,
|
||||
setQueue,
|
||||
...itemsNotDownloaded.map((item) => ({
|
||||
id: item.Id!,
|
||||
execute: async () => await initiateDownload(item),
|
||||
item,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.error(
|
||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
||||
);
|
||||
}
|
||||
}, [
|
||||
queue,
|
||||
setQueue,
|
||||
itemsNotDownloaded,
|
||||
usingOptimizedServer,
|
||||
userCanDownload,
|
||||
maxBitrate,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
]);
|
||||
|
||||
const initiateDownload = useCallback(
|
||||
async (...items: BaseItemDto[]) => {
|
||||
if (
|
||||
@@ -193,49 +157,53 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
"DownloadItem ~ initiateDownload: No api or user or item",
|
||||
);
|
||||
}
|
||||
let mediaSource = selectedMediaSource;
|
||||
let audioIndex: number | undefined = selectedAudioStream;
|
||||
let subtitleIndex: number | undefined = selectedSubtitleStream;
|
||||
const downloadDetailsPromises = items.map(async (item) => {
|
||||
const { mediaSource, audioIndex, subtitleIndex } =
|
||||
itemsNotDownloaded.length > 1
|
||||
? getDefaultPlaySettings(item, settings!)
|
||||
: {
|
||||
mediaSource: selectedMediaSource,
|
||||
audioIndex: selectedAudioStream,
|
||||
subtitleIndex: selectedSubtitleStream,
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
if (itemsNotDownloaded.length > 1) {
|
||||
const defaults = getDefaultPlaySettings(item, settings!);
|
||||
mediaSource = defaults.mediaSource;
|
||||
audioIndex = defaults.audioIndex;
|
||||
subtitleIndex = defaults.subtitleIndex;
|
||||
// Keep using the selected bitrate for consistency across all downloads
|
||||
}
|
||||
|
||||
const res = await getStreamUrl({
|
||||
const downloadDetails = await getDownloadUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: 0,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
mediaSourceId: mediaSource?.Id,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: download,
|
||||
userId: user.Id!,
|
||||
mediaSource: mediaSource!,
|
||||
audioStreamIndex: audioIndex ?? -1,
|
||||
subtitleStreamIndex: subtitleIndex ?? -1,
|
||||
maxBitrate,
|
||||
deviceId: api.deviceInfo.id,
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
return {
|
||||
url: downloadDetails?.url,
|
||||
item,
|
||||
mediaSource: downloadDetails?.mediaSource,
|
||||
};
|
||||
});
|
||||
|
||||
const downloadDetails = await Promise.all(downloadDetailsPromises);
|
||||
for (const { url, item, mediaSource } of downloadDetails) {
|
||||
if (!url) {
|
||||
Alert.alert(
|
||||
t("home.downloads.something_went_wrong"),
|
||||
t("home.downloads.could_not_get_stream_url_from_jellyfin"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { mediaSource: source, url } = res;
|
||||
|
||||
if (!url || !source) throw new Error("No url");
|
||||
|
||||
if (usingOptimizedServer) {
|
||||
saveDownloadItemInfoToDiskTmp(item, source, url);
|
||||
await startBackgroundDownload(url, item, source);
|
||||
} else {
|
||||
//await startRemuxing(item, url, source);
|
||||
if (!mediaSource) {
|
||||
console.error(`Could not get download URL for ${item.Name}`);
|
||||
toast.error(
|
||||
t("Could not get download URL for {{itemName}}", {
|
||||
itemName: item.Name,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await startBackgroundDownload(url, item, mediaSource, maxBitrate);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -247,12 +215,25 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
selectedSubtitleStream,
|
||||
settings,
|
||||
maxBitrate,
|
||||
usingOptimizedServer,
|
||||
startBackgroundDownload,
|
||||
//startRemuxing,
|
||||
],
|
||||
);
|
||||
|
||||
const acceptDownloadOptions = useCallback(() => {
|
||||
if (userCanDownload === true) {
|
||||
if (itemsToDownload.some((i) => !i.Id)) {
|
||||
throw new Error("No item id");
|
||||
}
|
||||
closeModal();
|
||||
|
||||
initiateDownload(...itemsToDownload);
|
||||
} else {
|
||||
toast.error(
|
||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
||||
);
|
||||
}
|
||||
}, [closeModal, initiateDownload, itemsToDownload, userCanDownload]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
@@ -269,7 +250,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
if (itemsNotDownloaded.length !== 1) return;
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(items[0], settings);
|
||||
|
||||
setSelectedMediaSource(mediaSource ?? undefined);
|
||||
setSelectedAudioStream(audioIndex ?? 0);
|
||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||
@@ -343,7 +323,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
<Text className='text-neutral-300'>
|
||||
{subtitle ||
|
||||
t("item_card.download.download_x_item", {
|
||||
item_count: itemsNotDownloaded.length,
|
||||
item_count: itemsToDownload.length,
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -353,6 +333,15 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
onChange={setMaxBitrate}
|
||||
selected={maxBitrate}
|
||||
/>
|
||||
{itemsNotDownloaded.length > 1 && (
|
||||
<View className='flex flex-row items-center justify-between w-full py-2'>
|
||||
<Text>{t("item_card.download.download_unwatched_only")}</Text>
|
||||
<Switch
|
||||
onValueChange={setDownloadUnwatchedOnly}
|
||||
value={downloadUnwatchedOnly}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{itemsNotDownloaded.length === 1 && (
|
||||
<>
|
||||
<MediaSourceSelector
|
||||
@@ -377,6 +366,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
className='mt-auto'
|
||||
onPress={acceptDownloadOptions}
|
||||
@@ -384,13 +374,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
>
|
||||
{t("item_card.download.download_button")}
|
||||
</Button>
|
||||
<View className='opacity-70 text-center w-full flex items-center'>
|
||||
<Text className='text-xs'>
|
||||
{usingOptimizedServer
|
||||
? t("item_card.download.using_optimized_server")
|
||||
: t("item_card.download.using_default_method")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
|
||||
@@ -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 {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
@@ -29,11 +8,35 @@ import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { 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 { useItemQuery } from "@/hooks/useItemQuery";
|
||||
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 { ItemHeader } from "./ItemHeader";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||
|
||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||
|
||||
export type SelectedOptions = {
|
||||
@@ -43,13 +46,20 @@ export type SelectedOptions = {
|
||||
subtitleIndex: number;
|
||||
};
|
||||
|
||||
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
({ item }) => {
|
||||
interface ItemContentProps {
|
||||
item: BaseItemDto;
|
||||
isOffline: boolean;
|
||||
}
|
||||
|
||||
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
({ item, isOffline }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [settings] = useSettings();
|
||||
const { orientation } = useOrientation();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
useImageColors({ item });
|
||||
|
||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||
@@ -64,62 +74,75 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(item, settings);
|
||||
} = useDefaultPlaySettings(item!, settings);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||
[api, item],
|
||||
);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [loadingLogo, logoUrl]);
|
||||
|
||||
// Needs to automatically change the selected to the default values for default indexes.
|
||||
useEffect(() => {
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
}));
|
||||
if (item) {
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
}));
|
||||
}
|
||||
}, [
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultSubtitleIndex,
|
||||
defaultMediaSource,
|
||||
item,
|
||||
]);
|
||||
|
||||
if (!Platform.isTV) {
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<Chromecast.Chromecast
|
||||
background='blur'
|
||||
width={22}
|
||||
height={22}
|
||||
/>
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
{!Platform.isTV && (
|
||||
<DownloadSingleItem item={item} size='large' />
|
||||
)}
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [item]);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
return;
|
||||
}
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<Chromecast.Chromecast background='blur' width={22} height={22} />
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
{!Platform.isTV && !isOffline && (
|
||||
<DownloadSingleItem item={item} size='large' />
|
||||
)}
|
||||
{user?.Policy?.IsAdministrator && !isOffline && (
|
||||
<PlayInRemoteSessionButton item={item} size='large' />
|
||||
)}
|
||||
<PlayedStatus
|
||||
items={[item]}
|
||||
size='large'
|
||||
isOffline={isOffline}
|
||||
/>
|
||||
{!isOffline && <AddToFavorites item={item} />}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [item, navigation, isOffline, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||
setHeaderHeight(230);
|
||||
else if (item.Type === "Movie") setHeaderHeight(500);
|
||||
else setHeaderHeight(350);
|
||||
}, [item.Type, orientation]);
|
||||
if (item) {
|
||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||
setHeaderHeight(230);
|
||||
else if (item.Type === "Movie") setHeaderHeight(500);
|
||||
else setHeaderHeight(350);
|
||||
}
|
||||
}, [item, orientation]);
|
||||
|
||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [loadingLogo, logoUrl]);
|
||||
if (!selectedOptions) return null;
|
||||
if (!item || !selectedOptions) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -160,13 +183,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
onLoad={() => setLoadingLogo(false)}
|
||||
onError={() => setLoadingLogo(false)}
|
||||
/>
|
||||
) : null
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col bg-transparent shrink'>
|
||||
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
||||
<ItemHeader item={item} className='mb-4' />
|
||||
{item.Type !== "Program" && !Platform.isTV && (
|
||||
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
||||
<View className='flex flex-row items-center justify-start w-full h-16'>
|
||||
<BitrateSelector
|
||||
className='mr-1'
|
||||
@@ -225,25 +248,34 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
className='grow'
|
||||
selectedOptions={selectedOptions}
|
||||
item={item}
|
||||
isOffline={isOffline}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{item.Type === "Episode" && (
|
||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||
<SeasonEpisodesCarousel
|
||||
item={item}
|
||||
loading={loading}
|
||||
isOffline={isOffline}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
{!isOffline && (
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
)}
|
||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||
|
||||
{item.Type !== "Program" && (
|
||||
<>
|
||||
{item.Type === "Episode" && (
|
||||
{item.Type === "Episode" && !isOffline && (
|
||||
<CurrentSeries item={item} className='mb-4' />
|
||||
)}
|
||||
|
||||
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
||||
{!isOffline && (
|
||||
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
||||
)}
|
||||
|
||||
{item.People && item.People.length > 0 && (
|
||||
{item.People && item.People.length > 0 && !isOffline && (
|
||||
<View className='mb-4'>
|
||||
{item.People.slice(0, 3).map((person, idx) => (
|
||||
<MoreMoviesWithActor
|
||||
@@ -256,7 +288,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SimilarItems itemId={item.Id} />
|
||||
{!isOffline && <SimilarItems itemId={item.Id} />}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -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 { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
@@ -16,7 +5,6 @@ import { useRouter } from "expo-router";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, Pressable } from "react-native";
|
||||
import { Alert, TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
CastButton,
|
||||
@@ -34,12 +22,23 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} 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 { SelectedOptions } from "./ItemContent";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
selectedOptions: SelectedOptions;
|
||||
isOffline?: boolean;
|
||||
}
|
||||
|
||||
const ANIMATION_DURATION = 500;
|
||||
@@ -48,6 +47,7 @@ const MIN_PLAYBACK_WIDTH = 15;
|
||||
export const PlayButton: React.FC<Props> = ({
|
||||
item,
|
||||
selectedOptions,
|
||||
isOffline,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
@@ -67,14 +67,17 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const startColor = useSharedValue(colorAtom);
|
||||
const widthProgress = useSharedValue(0);
|
||||
const colorChangeProgress = useSharedValue(0);
|
||||
const [settings] = useSettings();
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const goToPlayer = useCallback(
|
||||
(q: string) => {
|
||||
if (settings.maxAutoPlayEpisodeCount.value !== -1) {
|
||||
updateSettings({ autoPlayEpisodeCount: 0 });
|
||||
}
|
||||
router.push(`/player/direct-player?${q}`);
|
||||
},
|
||||
[router],
|
||||
[router, isOffline],
|
||||
);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
@@ -89,6 +92,8 @@ export const PlayButton: React.FC<Props> = ({
|
||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
offline: isOffline ? "true" : "false",
|
||||
});
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
|
||||
194
components/PlayInRemoteSession.tsx
Normal file
194
components/PlayInRemoteSession.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useAllSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
type BaseItemDto,
|
||||
PlayCommand,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
FlatList,
|
||||
Modal,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Loader } from "./Loader";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
item: BaseItemDto;
|
||||
size?: "default" | "large";
|
||||
}
|
||||
|
||||
export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
||||
item,
|
||||
...props
|
||||
}) => {
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
||||
const handlePlayInSession = async (sessionId: string) => {
|
||||
if (!api || !item.Id) return;
|
||||
|
||||
try {
|
||||
console.log(`Playing ${item.Name} in session ${sessionId}`);
|
||||
getSessionApi(api).play({
|
||||
sessionId,
|
||||
itemIds: [item.Id],
|
||||
playCommand: PlayCommand.PlayNow,
|
||||
});
|
||||
|
||||
setModalVisible(false);
|
||||
} catch (error) {
|
||||
console.error("Error playing in remote session:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
icon='play-circle-outline'
|
||||
onPress={() => setModalVisible(true)}
|
||||
size={props.size}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
animationType='slide'
|
||||
transparent={true}
|
||||
visible={modalVisible}
|
||||
onRequestClose={() => setModalVisible(false)}
|
||||
>
|
||||
<View style={styles.centeredView}>
|
||||
<View style={styles.modalView}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Select Session</Text>
|
||||
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.modalContent}>
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Loader />
|
||||
</View>
|
||||
) : !sessions || sessions.length === 0 ? (
|
||||
<Text style={styles.noSessionsText}>
|
||||
No active sessions found
|
||||
</Text>
|
||||
) : (
|
||||
<FlatList
|
||||
data={sessions}
|
||||
keyExtractor={(session) => session.Id || "unknown"}
|
||||
renderItem={({ item: session }) => (
|
||||
<TouchableOpacity
|
||||
style={styles.sessionItem}
|
||||
onPress={() => handlePlayInSession(session.Id || "")}
|
||||
>
|
||||
<View style={styles.sessionInfo}>
|
||||
<Text style={styles.sessionName}>
|
||||
{session.DeviceName}
|
||||
</Text>
|
||||
<Text style={styles.sessionDetails}>
|
||||
{session.UserName} • {session.Client}
|
||||
</Text>
|
||||
{session.NowPlayingItem && (
|
||||
<Text style={styles.nowPlaying} numberOfLines={1}>
|
||||
Now playing:{" "}
|
||||
{session.NowPlayingItem.SeriesName
|
||||
? `${session.NowPlayingItem.SeriesName} :`
|
||||
: ""}
|
||||
{session.NowPlayingItem.Name}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Ionicons name='play-sharp' size={20} color='#888' />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
centeredView: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
},
|
||||
modalView: {
|
||||
width: "90%",
|
||||
maxHeight: "80%",
|
||||
backgroundColor: "#1c1c1c",
|
||||
borderRadius: 20,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
},
|
||||
modalContent: {
|
||||
flex: 1,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
},
|
||||
loadingContainer: {
|
||||
padding: 40,
|
||||
alignItems: "center",
|
||||
},
|
||||
noSessionsText: {
|
||||
padding: 40,
|
||||
textAlign: "center",
|
||||
color: "#888",
|
||||
},
|
||||
listContent: {
|
||||
paddingVertical: 8,
|
||||
},
|
||||
sessionItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
},
|
||||
sessionInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
sessionName: {
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
marginBottom: 4,
|
||||
},
|
||||
sessionDetails: {
|
||||
fontSize: 13,
|
||||
opacity: 0.7,
|
||||
marginBottom: 2,
|
||||
},
|
||||
nowPlaying: {
|
||||
fontSize: 12,
|
||||
opacity: 0.5,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
});
|
||||
@@ -1,50 +1,22 @@
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type React from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
items: BaseItemDto[];
|
||||
isOffline?: boolean;
|
||||
size?: "default" | "large";
|
||||
}
|
||||
|
||||
export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidateQueries = () => {
|
||||
items.forEach((item) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["item", item.Id],
|
||||
});
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["resumeItems"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["continueWatching"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp-all"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["episodes"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["seasons"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["home"],
|
||||
});
|
||||
};
|
||||
|
||||
export const PlayedStatus: React.FC<Props> = ({
|
||||
items,
|
||||
isOffline = false,
|
||||
...props
|
||||
}) => {
|
||||
const allPlayed = items.every((item) => item.UserData?.Played);
|
||||
|
||||
const markAsPlayedStatus = useMarkAsPlayed(items);
|
||||
const toggle = useMarkAsPlayed(items, isOffline);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
@@ -52,8 +24,7 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
||||
fillColor={allPlayed ? "primary" : undefined}
|
||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||
onPress={async () => {
|
||||
console.log(allPlayed);
|
||||
await markAsPlayedStatus(!allPlayed);
|
||||
await toggle(!allPlayed);
|
||||
}}
|
||||
size={props.size}
|
||||
/>
|
||||
|
||||
@@ -18,7 +18,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
if (Platform.isTV) return null;
|
||||
const { t } = useTranslation();
|
||||
const subtitleStreams = useMemo(() => {
|
||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||
}, [source]);
|
||||
@@ -28,9 +28,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
[subtitleStreams, selected],
|
||||
);
|
||||
|
||||
if (subtitleStreams?.length === 0) return null;
|
||||
|
||||
const { t } = useTranslation();
|
||||
if (Platform.isTV || subtitleStreams?.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
|
||||
47
components/common/ProgressBar.tsx
Normal file
47
components/common/ProgressBar.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React, { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
interface ProgressBarProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const ProgressBar: React.FC<ProgressBarProps> = ({ item }) => {
|
||||
const progress = useMemo(() => {
|
||||
if (item.Type === "Program") {
|
||||
if (!item.StartDate || !item.EndDate) {
|
||||
return 0;
|
||||
}
|
||||
const startDate = new Date(item.StartDate);
|
||||
const endDate = new Date(item.EndDate);
|
||||
const now = new Date();
|
||||
const total = endDate.getTime() - startDate.getTime();
|
||||
if (total <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const elapsed = now.getTime() - startDate.getTime();
|
||||
return (elapsed / total) * 100;
|
||||
}
|
||||
return item.UserData?.PlayedPercentage || 0;
|
||||
}, [item]);
|
||||
|
||||
if (progress <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
className={
|
||||
"absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full"
|
||||
}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
}}
|
||||
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
@@ -8,9 +6,12 @@ import type {
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { type PropsWithChildren, useCallback } from "react";
|
||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
isOffline?: boolean;
|
||||
}
|
||||
|
||||
export const itemRouter = (
|
||||
@@ -50,6 +51,7 @@ export const itemRouter = (
|
||||
|
||||
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
item,
|
||||
isOffline = false,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
@@ -105,7 +107,10 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
<TouchableOpacity
|
||||
onLongPress={showActionSheet}
|
||||
onPress={() => {
|
||||
const url = itemRouter(item, from);
|
||||
let url = itemRouter(item, from);
|
||||
if (isOffline) {
|
||||
url += `&offline=true`;
|
||||
}
|
||||
// @ts-expect-error
|
||||
router.push(url);
|
||||
}}
|
||||
@@ -114,4 +119,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import type { JobStatus } from "@/utils/optimize-server";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
@@ -19,15 +13,22 @@ import {
|
||||
type ViewProps,
|
||||
} from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { JobStatus } from "@/providers/Downloads/types";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
import { Button } from "../Button";
|
||||
|
||||
const BackGroundDownloader = !Platform.isTV
|
||||
? require("@kesha-antonov/react-native-background-downloader")
|
||||
: null;
|
||||
//const FFmpegKitProvider = !Platform.isTV
|
||||
// ? require("ffmpeg-kit-react-native")
|
||||
// : null;
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
interface Props extends ViewProps { }
|
||||
|
||||
const bytesToMB = (bytes: number) => {
|
||||
return bytes / 1024 / 1024;
|
||||
};
|
||||
|
||||
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
||||
const { processes } = useDownload();
|
||||
@@ -62,37 +63,18 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
||||
}
|
||||
|
||||
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
const { processes, startDownload } = useDownload();
|
||||
const { startDownload, removeProcess } = useDownload();
|
||||
const router = useRouter();
|
||||
const { removeProcess, setProcesses } = useDownload();
|
||||
const [settings] = useSettings();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const cancelJobMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
if (!process) throw new Error("No active download");
|
||||
|
||||
if (settings?.downloadMethod === DownloadMethod.Optimized) {
|
||||
try {
|
||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||
for (const task of tasks) {
|
||||
if (task.id === id) {
|
||||
task.stop();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await removeProcess(id);
|
||||
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
||||
}
|
||||
} else {
|
||||
//FFmpegKitProvider.FFmpegKit.cancel(Number(id));
|
||||
setProcesses((prev: any[]) =>
|
||||
prev.filter((p: { id: string }) => p.id !== id),
|
||||
);
|
||||
}
|
||||
removeProcess(id);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("home.downloads.toasts.download_cancelled"));
|
||||
queryClient.invalidateQueries({ queryKey: ["downloads"] });
|
||||
},
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
@@ -101,11 +83,14 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
});
|
||||
|
||||
const eta = (p: JobStatus) => {
|
||||
if (!p.speed || !p.progress) return null;
|
||||
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
|
||||
|
||||
const length = p?.item?.RunTimeTicks || 0;
|
||||
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
|
||||
return formatTimeString(timeLeft, "tick");
|
||||
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
|
||||
if (bytesRemaining <= 0) return null;
|
||||
|
||||
const secondsRemaining = bytesRemaining / p.speed;
|
||||
|
||||
return formatTimeString(secondsRemaining, "s");
|
||||
};
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
@@ -118,8 +103,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
|
||||
{...props}
|
||||
>
|
||||
{(process.status === "optimizing" ||
|
||||
process.status === "downloading") && (
|
||||
{process.status === "downloading" && (
|
||||
<View
|
||||
className={`
|
||||
bg-purple-600 h-1 absolute bottom-0 left-0
|
||||
@@ -159,8 +143,10 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
) : (
|
||||
<Text className='text-xs'>{process.progress.toFixed(0)}%</Text>
|
||||
)}
|
||||
{process.speed && (
|
||||
<Text className='text-xs'>{process.speed?.toFixed(2)}x</Text>
|
||||
{process.speed && process.speed > 0 && (
|
||||
<Text className='text-xs'>
|
||||
{bytesToMB(process.speed).toFixed(2)} MB/s
|
||||
</Text>
|
||||
)}
|
||||
{eta(process) && (
|
||||
<Text className='text-xs'>
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import {
|
||||
ActionSheetProvider,
|
||||
useActionSheet,
|
||||
} from "@expo/react-native-action-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models/base-item-dto";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
TouchableOpacity,
|
||||
type TouchableOpacityProps,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { type TouchableOpacityProps, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
|
||||
@@ -27,26 +18,17 @@ interface EpisodeCardProps extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
const { deleteFile } = useDownload();
|
||||
const { openFile } = useDownloadedFileOpener();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(item.Id!);
|
||||
}, [item]);
|
||||
|
||||
const handleOpenFile = useCallback(() => {
|
||||
openFile(item);
|
||||
}, [item, openFile]);
|
||||
|
||||
/**
|
||||
* Handles deleting the file with haptic feedback.
|
||||
*/
|
||||
const handleDeleteFile = useCallback(() => {
|
||||
if (item.Id) {
|
||||
deleteFile(item.Id);
|
||||
deleteFile(item.Id, "Episode");
|
||||
successHapticFeedback();
|
||||
}
|
||||
}, [deleteFile, item.Id]);
|
||||
@@ -77,10 +59,10 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenFile}
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
isOffline={true}
|
||||
onLongPress={showActionSheet}
|
||||
key={item.Id}
|
||||
className='flex flex-col mb-4'
|
||||
>
|
||||
<View className='flex flex-row items-start mb-2'>
|
||||
@@ -104,7 +86,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
||||
<Text numberOfLines={3} className='text-xs text-neutral-500 shrink'>
|
||||
{item.Overview}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</TouchableItemRouter>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import {
|
||||
ActionSheetProvider,
|
||||
useActionSheet,
|
||||
} from "@expo/react-native-action-sheet";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
|
||||
import { View } from "react-native";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { ProgressBar } from "../common/ProgressBar";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
|
||||
interface MovieCardProps {
|
||||
@@ -27,16 +26,10 @@ interface MovieCardProps {
|
||||
*/
|
||||
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
const { deleteFile } = useDownload();
|
||||
const { openFile } = useDownloadedFileOpener();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
|
||||
const handleOpenFile = useCallback(() => {
|
||||
openFile(item);
|
||||
}, [item, openFile]);
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(item.Id!);
|
||||
return storage.getString(item?.Id!);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@@ -44,8 +37,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
*/
|
||||
const handleDeleteFile = useCallback(() => {
|
||||
if (item.Id) {
|
||||
deleteFile(item.Id);
|
||||
successHapticFeedback();
|
||||
deleteFile(item.Id, "Movie");
|
||||
}
|
||||
}, [deleteFile, item.Id]);
|
||||
|
||||
@@ -75,9 +67,9 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}>
|
||||
<TouchableItemRouter onLongPress={showActionSheet} item={item} isOffline>
|
||||
{base64Image ? (
|
||||
<View className='w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
|
||||
<View className='relative w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
|
||||
<Image
|
||||
source={{
|
||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||
@@ -88,22 +80,24 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
resizeMode: "cover",
|
||||
}}
|
||||
/>
|
||||
<ProgressBar item={item} />
|
||||
</View>
|
||||
) : (
|
||||
<View className='w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center'>
|
||||
<View className='relative w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center'>
|
||||
<Ionicons
|
||||
name='image-outline'
|
||||
size={24}
|
||||
color='gray'
|
||||
className='self-center mt-16'
|
||||
/>
|
||||
<ProgressBar item={item} />
|
||||
</View>
|
||||
)}
|
||||
<View className='w-28'>
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
<DownloadSize items={[item]} />
|
||||
</TouchableOpacity>
|
||||
</TouchableItemRouter>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
type QueryFunction,
|
||||
@@ -8,9 +6,11 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import SeriesPoster from "../posters/SeriesPoster";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
@@ -20,6 +20,7 @@ interface Props extends ViewProps {
|
||||
queryKey: QueryKey;
|
||||
queryFn: QueryFunction<BaseItemDto[]>;
|
||||
hideIfEmpty?: boolean;
|
||||
isOffline?: boolean;
|
||||
}
|
||||
|
||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
@@ -29,6 +30,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
queryFn,
|
||||
queryKey,
|
||||
hideIfEmpty = false,
|
||||
isOffline = false,
|
||||
...props
|
||||
}) => {
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -90,6 +92,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
isOffline={isOffline}
|
||||
className={`mr-2
|
||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||
`}
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { PropsWithChildren } from "react";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
type SearchItemWrapperProps<T> = {
|
||||
ids?: string[] | null;
|
||||
items?: T[];
|
||||
renderItem: (item: any) => React.ReactNode;
|
||||
header?: string;
|
||||
@@ -17,7 +16,6 @@ type SearchItemWrapperProps<T> = {
|
||||
};
|
||||
|
||||
export const SearchItemWrapper = <T,>({
|
||||
ids,
|
||||
items,
|
||||
renderItem,
|
||||
header,
|
||||
@@ -26,33 +24,7 @@ export const SearchItemWrapper = <T,>({
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data, isLoading: l1 } = useQuery({
|
||||
queryKey: ["items", ids],
|
||||
queryFn: async () => {
|
||||
if (!user?.Id || !api || !ids || ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const itemPromises = ids.map((id) =>
|
||||
getUserItemData({
|
||||
api,
|
||||
userId: user.Id,
|
||||
itemId: id,
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await Promise.all(itemPromises);
|
||||
|
||||
// Filter out null items
|
||||
return results.filter(
|
||||
(item) => item !== null,
|
||||
) as unknown as BaseItemDto[];
|
||||
},
|
||||
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
|
||||
if (!data && (!items || items.length === 0)) return null;
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -67,7 +39,7 @@ export const SearchItemWrapper = <T,>({
|
||||
keyExtractor={(_, index) => index.toString()}
|
||||
estimatedItemSize={250}
|
||||
/*@ts-ignore */
|
||||
data={data || items}
|
||||
data={items}
|
||||
onEndReachedThreshold={1}
|
||||
onEndReached={onEndReached}
|
||||
//@ts-ignore
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
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 { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import {
|
||||
HorizontalScroll,
|
||||
type HorizontalScrollRef,
|
||||
} from "../common/HorrizontalScroll";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item?: BaseItemDto | null;
|
||||
loading?: boolean;
|
||||
isOffline?: boolean;
|
||||
}
|
||||
|
||||
export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
item,
|
||||
loading,
|
||||
isOffline,
|
||||
...props
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { downloadedFiles } = useDownload();
|
||||
|
||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||
|
||||
@@ -41,24 +46,28 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: ["episodes", seasonId],
|
||||
queryKey: ["episodes", seasonId, isOffline],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
const response = await api.axiosInstance.get(
|
||||
`${api.basePath}/Shows/${item?.Id}/Episodes`,
|
||||
{
|
||||
params: {
|
||||
userId: user?.Id,
|
||||
seasonId,
|
||||
Fields:
|
||||
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview",
|
||||
},
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (isOffline) {
|
||||
return downloadedFiles
|
||||
?.filter(
|
||||
(f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId,
|
||||
)
|
||||
.map((f) => f.item);
|
||||
}
|
||||
if (!api || !user?.Id || !item?.SeriesId) return [];
|
||||
const response = await getTvShowsApi(api).getEpisodes({
|
||||
userId: user.Id,
|
||||
seasonId: seasonId || undefined,
|
||||
seriesId: item.SeriesId,
|
||||
fields: [
|
||||
"ItemCounts",
|
||||
"PrimaryImageAspectRatio",
|
||||
"CanDelete",
|
||||
"MediaSourceCount",
|
||||
"Overview",
|
||||
],
|
||||
});
|
||||
return response.data.Items as BaseItemDto[];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!seasonId,
|
||||
@@ -123,7 +132,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
data={episodes}
|
||||
extraData={item}
|
||||
loading={loading || isLoading || isFetching}
|
||||
renderItem={(_item, idx) => (
|
||||
renderItem={(_item, _idx) => (
|
||||
<TouchableOpacity
|
||||
key={_item.Id}
|
||||
onPress={() => {
|
||||
|
||||
@@ -86,7 +86,8 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
userId: user.Id,
|
||||
seasonId: selectedSeasonId,
|
||||
enableUserData: true,
|
||||
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||
// Note: Including trick play is necessary to enable trick play downloads
|
||||
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||
});
|
||||
|
||||
if (res.data.TotalRecordCount === 0)
|
||||
@@ -97,6 +98,10 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
|
||||
return res.data.Items;
|
||||
},
|
||||
select: (data) =>
|
||||
[...(data || [])].sort(
|
||||
(a, b) => (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
||||
),
|
||||
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,32 +1,20 @@
|
||||
import { Stepper } from "@/components/inputs/Stepper";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import {
|
||||
DownloadMethod,
|
||||
type Settings,
|
||||
useSettings,
|
||||
} from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useMemo } from "react";
|
||||
import { Platform, Switch, TouchableOpacity } from "react-native";
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export default function DownloadSettings({ ...props }) {
|
||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||
const { setProcesses } = useDownload();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const allDisabled = useMemo(
|
||||
() =>
|
||||
pluginSettings?.downloadMethod?.locked === true &&
|
||||
pluginSettings?.remuxConcurrentLimit?.locked === true &&
|
||||
pluginSettings?.autoDownload.locked === true,
|
||||
[pluginSettings],
|
||||
@@ -37,69 +25,10 @@ export default function DownloadSettings({ ...props }) {
|
||||
return (
|
||||
<DisabledSetting disabled={allDisabled} {...props} className='mb-4'>
|
||||
<ListGroup title={t("home.settings.downloads.downloads_title")}>
|
||||
<ListItem
|
||||
title={t("home.settings.downloads.download_method")}
|
||||
disabled={pluginSettings?.downloadMethod?.locked}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{settings.downloadMethod === DownloadMethod.Remux
|
||||
? t("home.settings.downloads.default")
|
||||
: t("home.settings.downloads.optimized")}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side='bottom'
|
||||
align='start'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>
|
||||
{t("home.settings.downloads.download_method")}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key='1'
|
||||
onSelect={() => {
|
||||
updateSettings({ downloadMethod: DownloadMethod.Remux });
|
||||
setProcesses([]);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{t("home.settings.downloads.default")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key='2'
|
||||
onSelect={() => {
|
||||
updateSettings({ downloadMethod: DownloadMethod.Optimized });
|
||||
setProcesses([]);
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{t("home.settings.downloads.optimized")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.downloads.remux_max_download")}
|
||||
disabled={
|
||||
pluginSettings?.remuxConcurrentLimit?.locked ||
|
||||
settings.downloadMethod !== DownloadMethod.Remux
|
||||
pluginSettings?.remuxConcurrentLimit?.locked
|
||||
}
|
||||
>
|
||||
<Stepper
|
||||
@@ -114,33 +43,6 @@ export default function DownloadSettings({ ...props }) {
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.downloads.auto_download")}
|
||||
disabled={
|
||||
pluginSettings?.autoDownload?.locked ||
|
||||
settings.downloadMethod !== DownloadMethod.Optimized
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
disabled={
|
||||
pluginSettings?.autoDownload?.locked ||
|
||||
settings.downloadMethod !== DownloadMethod.Optimized
|
||||
}
|
||||
value={settings.autoDownload}
|
||||
onValueChange={(value) => updateSettings({ autoDownload: value })}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
disabled={
|
||||
pluginSettings?.optimizedVersionsServerUrl?.locked ||
|
||||
settings.downloadMethod !== DownloadMethod.Optimized
|
||||
}
|
||||
onPress={() => router.push("/settings/optimized-server/page")}
|
||||
showArrow
|
||||
title={t("home.settings.downloads.optimized_versions_server")}
|
||||
/>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type {
|
||||
@@ -25,23 +13,31 @@ import {
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
useNavigation,
|
||||
usePathname,
|
||||
useRouter,
|
||||
useSegments,
|
||||
} from "expo-router";
|
||||
import { useNavigation, useRouter, useSegments } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
|
||||
type ScrollingCollectionListSection = {
|
||||
type: "ScrollingCollectionList";
|
||||
@@ -70,9 +66,9 @@ export const HomeIndex = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [
|
||||
settings,
|
||||
updateSettings,
|
||||
pluginSettings,
|
||||
setPluginSettings,
|
||||
_updateSettings,
|
||||
_pluginSettings,
|
||||
_setPluginSettings,
|
||||
refreshStreamyfinPluginSettings,
|
||||
] = useSettings();
|
||||
|
||||
@@ -86,7 +82,24 @@ export const HomeIndex = () => {
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
const { downloadedFiles, cleanCacheDirectory } = useDownload();
|
||||
const prevIsConnected = useRef<boolean | null>(false);
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
useEffect(() => {
|
||||
// Only invalidate cache when transitioning from offline to online
|
||||
if (isConnected && !prevIsConnected.current) {
|
||||
invalidateCache();
|
||||
}
|
||||
// Update the ref to the current state for the next render
|
||||
prevIsConnected.current = isConnected;
|
||||
}, [isConnected, invalidateCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
navigation.setOptions({
|
||||
headerLeft: () => null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
|
||||
navigation.setOptions({
|
||||
headerLeft: () => (
|
||||
@@ -107,7 +120,7 @@ export const HomeIndex = () => {
|
||||
}, [downloadedFiles, navigation, router]);
|
||||
|
||||
useEffect(() => {
|
||||
cleanCacheDirectory().catch((e) =>
|
||||
cleanCacheDirectory().catch((_e) =>
|
||||
console.error("Something went wrong cleaning cache directory"),
|
||||
);
|
||||
}, []);
|
||||
@@ -142,10 +155,6 @@ export const HomeIndex = () => {
|
||||
setIsConnected(state.isConnected);
|
||||
});
|
||||
|
||||
// cleanCacheDirectory().catch((e) =>
|
||||
// console.error("Something went wrong cleaning cache directory")
|
||||
// );
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
@@ -186,8 +195,6 @@ export const HomeIndex = () => {
|
||||
);
|
||||
}, [userViews]);
|
||||
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
const refetch = async () => {
|
||||
setLoading(true);
|
||||
await refreshStreamyfinPluginSettings();
|
||||
@@ -225,166 +232,168 @@ export const HomeIndex = () => {
|
||||
[api, user?.Id],
|
||||
);
|
||||
|
||||
let sections: Section[] = [];
|
||||
if (!settings?.home || !settings?.home?.sections) {
|
||||
sections = useMemo(() => {
|
||||
if (!api || !user?.Id) return [];
|
||||
const defaultSections = useMemo(() => {
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
const latestMediaViews = collections.map((c) => {
|
||||
const includeItemTypes: BaseItemKind[] =
|
||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||
const queryKey = [
|
||||
"home",
|
||||
`recentlyAddedIn${c.CollectionType}`,
|
||||
user?.Id!,
|
||||
c.Id!,
|
||||
];
|
||||
return createCollectionConfig(
|
||||
title || "",
|
||||
queryKey,
|
||||
includeItemTypes,
|
||||
c.Id,
|
||||
);
|
||||
});
|
||||
|
||||
const ss: Section[] = [
|
||||
{
|
||||
title: t("home.continue_watching"),
|
||||
queryKey: ["home", "resumeItems"],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
},
|
||||
{
|
||||
title: t("home.next_up"),
|
||||
queryKey: ["home", "nextUp-all"],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount"],
|
||||
limit: 20,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
enableResumable: false,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
},
|
||||
...latestMediaViews,
|
||||
// ...(mediaListCollections?.map(
|
||||
// (ml) =>
|
||||
// ({
|
||||
// title: ml.Name,
|
||||
// queryKey: ["home", "mediaList", ml.Id!],
|
||||
// queryFn: async () => ml,
|
||||
// type: "MediaListSection",
|
||||
// orientation: "vertical",
|
||||
// } as Section)
|
||||
// ) || []),
|
||||
{
|
||||
title: t("home.suggested_movies"),
|
||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "vertical",
|
||||
},
|
||||
{
|
||||
title: t("home.suggested_episodes"),
|
||||
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const suggestions = await getSuggestions(api, user.Id);
|
||||
const nextUpPromises = suggestions.map((series) =>
|
||||
getNextUp(api, user.Id, series.Id),
|
||||
);
|
||||
const nextUpResults = await Promise.all(nextUpPromises);
|
||||
|
||||
return nextUpResults.filter((item) => item !== null) || [];
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
},
|
||||
const latestMediaViews = collections.map((c) => {
|
||||
const includeItemTypes: BaseItemKind[] =
|
||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||
const queryKey = [
|
||||
"home",
|
||||
`recentlyAddedIn${c.CollectionType}`,
|
||||
user?.Id!,
|
||||
c.Id!,
|
||||
];
|
||||
return ss;
|
||||
}, [api, user?.Id, collections]);
|
||||
} else {
|
||||
sections = useMemo(() => {
|
||||
if (!api || !user?.Id) return [];
|
||||
const ss: Section[] = [];
|
||||
return createCollectionConfig(
|
||||
title || "",
|
||||
queryKey,
|
||||
includeItemTypes,
|
||||
c.Id,
|
||||
);
|
||||
});
|
||||
|
||||
for (const key in settings.home?.sections) {
|
||||
// @ts-expect-error
|
||||
const section = settings.home?.sections[key];
|
||||
const id = section.title || key;
|
||||
ss.push({
|
||||
title: id,
|
||||
queryKey: ["home", id],
|
||||
queryFn: async () => {
|
||||
if (section.items) {
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
limit: section.items?.limit || 25,
|
||||
recursive: true,
|
||||
includeItemTypes: section.items?.includeItemTypes,
|
||||
sortBy: section.items?.sortBy,
|
||||
sortOrder: section.items?.sortOrder,
|
||||
filters: section.items?.filters,
|
||||
parentId: section.items?.parentId,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
}
|
||||
if (section.nextUp) {
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount"],
|
||||
limit: section.items?.limit || 25,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
enableResumable: section.items?.enableResumable,
|
||||
enableRewatching: section.items?.enableRewatching,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
}
|
||||
const ss: Section[] = [
|
||||
{
|
||||
title: t("home.continue_watching"),
|
||||
queryKey: ["home", "resumeItems"],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
},
|
||||
{
|
||||
title: t("home.next_up"),
|
||||
queryKey: ["home", "nextUp-all"],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount"],
|
||||
limit: 20,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
enableResumable: false,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
},
|
||||
...latestMediaViews,
|
||||
// ...(mediaListCollections?.map(
|
||||
// (ml) =>
|
||||
// ({
|
||||
// title: ml.Name,
|
||||
// queryKey: ["home", "mediaList", ml.Id!],
|
||||
// queryFn: async () => ml,
|
||||
// type: "MediaListSection",
|
||||
// orientation: "vertical",
|
||||
// } as Section)
|
||||
// ) || []),
|
||||
{
|
||||
title: t("home.suggested_movies"),
|
||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "vertical",
|
||||
},
|
||||
{
|
||||
title: t("home.suggested_episodes"),
|
||||
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const suggestions = await getSuggestions(api, user.Id);
|
||||
const nextUpPromises = suggestions.map((series) =>
|
||||
getNextUp(api, user.Id, series.Id),
|
||||
);
|
||||
const nextUpResults = await Promise.all(nextUpPromises);
|
||||
|
||||
if (section.latest) {
|
||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
includeItemTypes: section.latest?.includeItemTypes,
|
||||
limit: section.latest?.limit || 25,
|
||||
isPlayed: section.latest?.isPlayed,
|
||||
groupItems: section.latest?.groupItems,
|
||||
});
|
||||
return response.data || [];
|
||||
}
|
||||
return nextUpResults.filter((item) => item !== null) || [];
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
return [];
|
||||
},
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: section?.orientation || "vertical",
|
||||
});
|
||||
}
|
||||
return ss;
|
||||
}, [api, user?.Id, settings.home?.sections]);
|
||||
}
|
||||
}
|
||||
},
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
},
|
||||
];
|
||||
return ss;
|
||||
}, [api, user?.Id, collections, createCollectionConfig, t]);
|
||||
|
||||
const customSections = useMemo(() => {
|
||||
if (!api || !user?.Id) return [];
|
||||
const ss: Section[] = [];
|
||||
|
||||
for (const key in settings.home?.sections) {
|
||||
// @ts-expect-error
|
||||
const section = settings.home?.sections[key];
|
||||
const id = section.title || key;
|
||||
ss.push({
|
||||
title: id,
|
||||
queryKey: ["home", id],
|
||||
queryFn: async () => {
|
||||
if (section.items) {
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
limit: section.items?.limit || 25,
|
||||
recursive: true,
|
||||
includeItemTypes: section.items?.includeItemTypes,
|
||||
sortBy: section.items?.sortBy,
|
||||
sortOrder: section.items?.sortOrder,
|
||||
filters: section.items?.filters,
|
||||
parentId: section.items?.parentId,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
}
|
||||
if (section.nextUp) {
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount"],
|
||||
limit: section.items?.limit || 25,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
enableResumable: section.items?.enableResumable,
|
||||
enableRewatching: section.items?.enableRewatching,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
}
|
||||
|
||||
if (section.latest) {
|
||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
includeItemTypes: section.latest?.includeItemTypes,
|
||||
limit: section.latest?.limit || 25,
|
||||
isPlayed: section.latest?.isPlayed,
|
||||
groupItems: section.latest?.groupItems,
|
||||
});
|
||||
return response.data || [];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: section?.orientation || "vertical",
|
||||
});
|
||||
}
|
||||
return ss;
|
||||
}, [api, user?.Id, settings.home?.sections]);
|
||||
|
||||
const sections: Section[] =
|
||||
!settings?.home || !settings?.home?.sections
|
||||
? defaultSections
|
||||
: customSections;
|
||||
|
||||
if (isConnected === false) {
|
||||
return (
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Linking, TextInput, View } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChangeValue: (value: string) => void;
|
||||
}
|
||||
|
||||
export const OptimizedServerForm: React.FC<Props> = ({
|
||||
value,
|
||||
onChangeValue,
|
||||
}) => {
|
||||
const handleOpenLink = () => {
|
||||
Linking.openURL("https://github.com/streamyfin/optimized-versions-server");
|
||||
};
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View className='flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'>
|
||||
<View className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}>
|
||||
<Text className='mr-4'>{t("home.settings.downloads.url")}</Text>
|
||||
<TextInput
|
||||
className='text-white'
|
||||
placeholder={t("home.settings.downloads.server_url_placeholder")}
|
||||
value={value}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
onChangeText={(text) => onChangeValue(text)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.downloads.optimized_version_hint")}{" "}
|
||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||
{t("home.settings.downloads.read_more_about_optimized_server")}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/utils/background-tasks";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import i18n, { TFunction } from "i18next";
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -251,7 +252,46 @@ export const OtherSettings: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
|
||||
<Dropdown
|
||||
data={AUTOPLAY_EPISODES_COUNT(t)}
|
||||
keyExtractor={(item) => item.key}
|
||||
titleExtractor={(item) => item.key}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(settings?.maxAutoPlayEpisodeCount.key)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
label={t("home.settings.other.max_auto_play_episode_count")}
|
||||
onSelected={(maxAutoPlayEpisodeCount) =>
|
||||
updateSettings({ maxAutoPlayEpisodeCount })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
};
|
||||
|
||||
const AUTOPLAY_EPISODES_COUNT = (
|
||||
t: TFunction<"translation", undefined>,
|
||||
): {
|
||||
key: string;
|
||||
value: number;
|
||||
}[] => [
|
||||
{ key: t("home.settings.other.disabled"), value: -1 },
|
||||
{ key: "1", value: 1 },
|
||||
{ key: "2", value: 2 },
|
||||
{ key: "3", value: 3 },
|
||||
{ key: "4", value: 4 },
|
||||
{ key: "5", value: 5 },
|
||||
{ key: "6", value: 6 },
|
||||
{ key: "7", value: 7 },
|
||||
];
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
@@ -17,22 +16,15 @@ export const StorageSettings = () => {
|
||||
const errorHapticFeedback = useHaptic("error");
|
||||
|
||||
const { data: size, isLoading: appSizeLoading } = useQuery({
|
||||
queryKey: ["appSize", appSizeUsage],
|
||||
queryFn: async () => {
|
||||
const app = await appSizeUsage;
|
||||
|
||||
const remaining = await FileSystem.getFreeDiskStorageAsync();
|
||||
const total = await FileSystem.getTotalDiskCapacityAsync();
|
||||
|
||||
return { app, remaining, total, used: (total - remaining) / total };
|
||||
},
|
||||
queryKey: ["appSize"],
|
||||
queryFn: appSizeUsage,
|
||||
});
|
||||
|
||||
const onDeleteClicked = async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
successHapticFeedback();
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
}
|
||||
@@ -67,10 +59,7 @@ export const StorageSettings = () => {
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
width: `${
|
||||
((size.total - size.remaining - size.app) / size.total) *
|
||||
100
|
||||
}%`,
|
||||
width: `${((size.total - size.remaining - size.app) / size.total) * 100}%`,
|
||||
backgroundColor: Colors.primaryLightRGB,
|
||||
}}
|
||||
/>
|
||||
|
||||
49
components/video-player/controls/ContinueWatchingOverlay.tsx
Normal file
49
components/video-player/controls/ContinueWatchingOverlay.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
export interface ContinueWatchingOverlayProps {
|
||||
goToNextItem: (options: {
|
||||
isAutoPlay: boolean;
|
||||
resetWatchCount: boolean;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
|
||||
goToNextItem,
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
const router = useRouter();
|
||||
|
||||
return settings.autoPlayEpisodeCount >=
|
||||
settings.maxAutoPlayEpisodeCount.value ? (
|
||||
<View
|
||||
className={
|
||||
"absolute top-0 bottom-0 left-0 right-0 flex flex-col px-4 items-center justify-center bg-[#000000B3]"
|
||||
}
|
||||
>
|
||||
<Text className='text-2xl font-bold text-white py-4 '>
|
||||
Are you still watching ?
|
||||
</Text>
|
||||
<Button
|
||||
onPress={() => {
|
||||
goToNextItem({ isAutoPlay: false, resetWatchCount: true });
|
||||
}}
|
||||
color={"purple"}
|
||||
className='my-4 w-2/3'
|
||||
>
|
||||
{t("player.continue_watching")}
|
||||
</Button>
|
||||
|
||||
<Button onPress={router.back} color={"transparent"} className='w-2/3'>
|
||||
{t("player.go_back")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default ContinueWatchingOverlay;
|
||||
@@ -1,24 +1,3 @@
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { VideoPlayer, useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import {
|
||||
formatTimeString,
|
||||
msToTicks,
|
||||
secondsToMs,
|
||||
ticksToMs,
|
||||
ticksToSeconds,
|
||||
} from "@/utils/time";
|
||||
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
@@ -41,27 +20,48 @@ import {
|
||||
import {
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import {
|
||||
type SharedValue,
|
||||
runOnJS,
|
||||
type SharedValue,
|
||||
useAnimatedReaction,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import {
|
||||
formatTimeString,
|
||||
msToTicks,
|
||||
secondsToMs,
|
||||
ticksToMs,
|
||||
ticksToSeconds,
|
||||
} from "@/utils/time";
|
||||
import AudioSlider from "./AudioSlider";
|
||||
import BrightnessSlider from "./BrightnessSlider";
|
||||
import { EpisodeList } from "./EpisodeList";
|
||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||
import SkipButton from "./SkipButton";
|
||||
import { VideoTouchOverlay } from "./VideoTouchOverlay";
|
||||
import { ControlProvider } from "./contexts/ControlContext";
|
||||
import { VideoProvider } from "./contexts/VideoContext";
|
||||
import DropdownView from "./dropdown/DropdownView";
|
||||
import { EpisodeList } from "./EpisodeList";
|
||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||
import SkipButton from "./SkipButton";
|
||||
import { useControlsTimeout } from "./useControlsTimeout";
|
||||
import { VideoTouchOverlay } from "./VideoTouchOverlay";
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
@@ -118,10 +118,9 @@ export const Controls: FC<Props> = ({
|
||||
setSubtitleTrack,
|
||||
setAudioTrack,
|
||||
offline = false,
|
||||
enableTrickplay = true,
|
||||
isVlc = false,
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -133,13 +132,16 @@ export const Controls: FC<Props> = ({
|
||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||
|
||||
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||
const { previousItem, nextItem } = useAdjacentItems({ item });
|
||||
const { previousItem, nextItem } = useAdjacentItems({
|
||||
item,
|
||||
isOffline: offline,
|
||||
});
|
||||
const {
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
trickplayInfo,
|
||||
prefetchAllTrickplayImages,
|
||||
} = useTrickplay(item, !offline && enableTrickplay);
|
||||
} = useTrickplay(item);
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
|
||||
@@ -174,19 +176,21 @@ export const Controls: FC<Props> = ({
|
||||
}>();
|
||||
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
offline ? undefined : item.Id,
|
||||
item?.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
isVlc,
|
||||
offline,
|
||||
);
|
||||
|
||||
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
||||
offline ? undefined : item.Id,
|
||||
item?.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
isVlc,
|
||||
offline,
|
||||
);
|
||||
|
||||
const goToItemCommon = useCallback(
|
||||
@@ -194,9 +198,7 @@ export const Controls: FC<Props> = ({
|
||||
if (!item || !settings) {
|
||||
return;
|
||||
}
|
||||
|
||||
lightHapticFeedback();
|
||||
|
||||
const previousIndexes = {
|
||||
subtitleIndex: subtitleIndex
|
||||
? Number.parseInt(subtitleIndex)
|
||||
@@ -214,15 +216,18 @@ export const Controls: FC<Props> = ({
|
||||
previousIndexes,
|
||||
mediaSource ?? undefined,
|
||||
);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id ?? "",
|
||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: newMediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue?.toString(),
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
}).toString();
|
||||
|
||||
console.log("queryParams", queryParams);
|
||||
|
||||
// @ts-expect-error
|
||||
router.replace(`player/direct-player?${queryParams}`);
|
||||
},
|
||||
@@ -236,13 +241,85 @@ export const Controls: FC<Props> = ({
|
||||
goToItemCommon(previousItem);
|
||||
}, [previousItem, goToItemCommon]);
|
||||
|
||||
const goToNextItem = useCallback(() => {
|
||||
if (!nextItem) return;
|
||||
goToItemCommon(nextItem);
|
||||
}, [nextItem, goToItemCommon]);
|
||||
const goToNextItem = useCallback(
|
||||
({
|
||||
isAutoPlay,
|
||||
resetWatchCount,
|
||||
}: {
|
||||
isAutoPlay?: boolean;
|
||||
resetWatchCount?: boolean;
|
||||
}) => {
|
||||
if (!nextItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAutoPlay) {
|
||||
// if we are not autoplaying, we won't update anything, we just go to the next item
|
||||
goToItemCommon(nextItem);
|
||||
if (resetWatchCount) {
|
||||
updateSettings({
|
||||
autoPlayEpisodeCount: 0,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip autoplay logic if maxAutoPlayEpisodeCount is -1
|
||||
if (settings.maxAutoPlayEpisodeCount.value === -1) {
|
||||
goToItemCommon(nextItem);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
settings.autoPlayEpisodeCount + 1 <
|
||||
settings.maxAutoPlayEpisodeCount.value
|
||||
) {
|
||||
goToItemCommon(nextItem);
|
||||
}
|
||||
|
||||
// Check if the autoPlayEpisodeCount is less than maxAutoPlayEpisodeCount for the autoPlay
|
||||
if (
|
||||
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value
|
||||
) {
|
||||
// update the autoPlayEpisodeCount in settings
|
||||
updateSettings({
|
||||
autoPlayEpisodeCount: settings.autoPlayEpisodeCount + 1,
|
||||
});
|
||||
}
|
||||
},
|
||||
[nextItem, goToItemCommon],
|
||||
);
|
||||
|
||||
// Add a memoized handler for autoplay next episode
|
||||
const handleNextEpisodeAutoPlay = useCallback(() => {
|
||||
goToNextItem({ isAutoPlay: true });
|
||||
}, [goToNextItem]);
|
||||
|
||||
// Add a memoized handler for manual next episode
|
||||
const handleNextEpisodeManual = useCallback(() => {
|
||||
goToNextItem({ isAutoPlay: false });
|
||||
}, [goToNextItem]);
|
||||
|
||||
// Add a memoized handler for ContinueWatchingOverlay
|
||||
const handleContinueWatching = useCallback(
|
||||
(options: { isAutoPlay?: boolean; resetWatchCount?: boolean }) => {
|
||||
goToNextItem(options);
|
||||
},
|
||||
[goToNextItem],
|
||||
);
|
||||
|
||||
const goToItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (offline) {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: itemId,
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
}).toString();
|
||||
// @ts-expect-error
|
||||
router.replace(`player/direct-player?${queryParams}`);
|
||||
return;
|
||||
}
|
||||
const gotoItem = await getItemById(api, itemId);
|
||||
if (!gotoItem) return;
|
||||
goToItemCommon(gotoItem);
|
||||
@@ -300,7 +377,9 @@ export const Controls: FC<Props> = ({
|
||||
};
|
||||
|
||||
const handleSliderStart = useCallback(() => {
|
||||
if (!showControls) return;
|
||||
if (!showControls) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSliding(true);
|
||||
wasPlayingRef.current = isPlaying;
|
||||
@@ -339,7 +418,9 @@ export const Controls: FC<Props> = ({
|
||||
);
|
||||
|
||||
const handleSkipBackward = useCallback(async () => {
|
||||
if (!settings?.rewindSkipTime) return;
|
||||
if (!settings?.rewindSkipTime) {
|
||||
return;
|
||||
}
|
||||
wasPlayingRef.current = isPlaying;
|
||||
lightHapticFeedback();
|
||||
try {
|
||||
@@ -371,7 +452,9 @@ export const Controls: FC<Props> = ({
|
||||
? curr + secondsToMs(settings.forwardSkipTime)
|
||||
: ticksToSeconds(curr) + settings.forwardSkipTime;
|
||||
seek(Math.max(0, newTime));
|
||||
if (wasPlayingRef.current) play();
|
||||
if (wasPlayingRef.current) {
|
||||
play();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||
@@ -454,9 +537,6 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
const onClose = async () => {
|
||||
lightHapticFeedback();
|
||||
await ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
);
|
||||
router.back();
|
||||
};
|
||||
|
||||
@@ -495,8 +575,8 @@ export const Controls: FC<Props> = ({
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={"flex flex-row w-full pt-2"}
|
||||
>
|
||||
{!Platform.isTV && (
|
||||
<View className='mr-auto'>
|
||||
<View className='mr-auto'>
|
||||
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
|
||||
<VideoProvider
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
@@ -506,26 +586,25 @@ export const Controls: FC<Props> = ({
|
||||
>
|
||||
<DropdownView />
|
||||
</VideoProvider>
|
||||
</View>
|
||||
)}
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='flex flex-row items-center space-x-2 '>
|
||||
{!Platform.isTV &&
|
||||
settings.defaultPlayer === VideoPlayer.VLC_4 && (
|
||||
<TouchableOpacity
|
||||
onPress={startPictureInPicture}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
>
|
||||
<MaterialIcons
|
||||
name='picture-in-picture'
|
||||
size={24}
|
||||
color='white'
|
||||
style={{ opacity: showControls ? 1 : 0 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{false && (
|
||||
<TouchableOpacity
|
||||
onPress={startPictureInPicture}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
>
|
||||
<MaterialIcons
|
||||
name='picture-in-picture'
|
||||
size={24}
|
||||
color='white'
|
||||
style={{ opacity: showControls ? 1 : 0 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{item?.Type === "Episode" && !offline && (
|
||||
{item?.Type === "Episode" && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
switchOnEpisodeMode();
|
||||
@@ -546,7 +625,7 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
{nextItem && !offline && (
|
||||
<TouchableOpacity
|
||||
onPress={goToNextItem}
|
||||
onPress={() => goToNextItem({ isAutoPlay: false })}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
>
|
||||
<Ionicons name='play-skip-forward' size={24} color='white' />
|
||||
@@ -564,16 +643,14 @@ export const Controls: FC<Props> = ({
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
{/* )} */}
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
className='aspect-square flex flex-col l items-center justify-center p-2'
|
||||
>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -741,17 +818,21 @@ export const Controls: FC<Props> = ({
|
||||
onPress={skipCredit}
|
||||
buttonText='Skip Credits'
|
||||
/>
|
||||
<NextEpisodeCountDownButton
|
||||
show={
|
||||
!nextItem
|
||||
? false
|
||||
: isVlc
|
||||
? remainingTime < 10000
|
||||
: remainingTime < 10
|
||||
}
|
||||
onFinish={goToNextItem}
|
||||
onPress={goToNextItem}
|
||||
/>
|
||||
{(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
settings.autoPlayEpisodeCount <
|
||||
settings.maxAutoPlayEpisodeCount.value) && (
|
||||
<NextEpisodeCountDownButton
|
||||
show={
|
||||
!nextItem
|
||||
? false
|
||||
: isVlc
|
||||
? remainingTime < 10000
|
||||
: remainingTime < 10
|
||||
}
|
||||
onFinish={handleNextEpisodeAutoPlay}
|
||||
onPress={handleNextEpisodeManual}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
@@ -799,6 +880,9 @@ export const Controls: FC<Props> = ({
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
||||
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} />
|
||||
)}
|
||||
</ControlProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useGlobalSearchParams } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import {
|
||||
HorizontalScroll,
|
||||
type HorizontalScrollRef,
|
||||
} from "@/components/common/HorrizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import {
|
||||
SeasonDropdown,
|
||||
type SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import type { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
@@ -33,12 +37,15 @@ export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||
export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets(); // Get safe area insets
|
||||
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||
const scrollViewRef = useRef<HorizontalScrollRef>(null); // Reference to the HorizontalScroll
|
||||
const scrollToIndex = (index: number) => {
|
||||
scrollViewRef.current?.scrollToIndex(index, 100);
|
||||
};
|
||||
const { offline } = useGlobalSearchParams<{
|
||||
offline: string;
|
||||
}>();
|
||||
const isOffline = offline === "true";
|
||||
|
||||
// Set the initial season index
|
||||
useEffect(() => {
|
||||
@@ -50,23 +57,35 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const seasonIndex = seasonIndexState[item.SeriesId ?? ""];
|
||||
const [seriesItem, setSeriesItem] = useState<BaseItemDto | null>(null);
|
||||
const { downloadedFiles } = useDownload();
|
||||
|
||||
// This effect fetches the series item data/
|
||||
useEffect(() => {
|
||||
if (item.SeriesId) {
|
||||
getUserItemData({ api, userId: user?.Id, itemId: item.SeriesId }).then(
|
||||
(res) => {
|
||||
setSeriesItem(res);
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [item.SeriesId]);
|
||||
const seasonIndex = seasonIndexState[item.SeriesId ?? ""];
|
||||
const { data: seriesItem } = useItemQuery(item.SeriesId!, isOffline);
|
||||
|
||||
const { data: seasons } = useQuery({
|
||||
queryKey: ["seasons", item.SeriesId],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
if (!item.SeriesId) return [];
|
||||
const seriesEpisodes = downloadedFiles?.filter(
|
||||
(f: DownloadedItem) => f.item.SeriesId === item.SeriesId,
|
||||
);
|
||||
const seasonNumbers = [
|
||||
...new Set(
|
||||
seriesEpisodes
|
||||
?.map((f: DownloadedItem) => f.item.ParentIndexNumber)
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
// Create fake season objects
|
||||
return seasonNumbers.map((seasonNumber) => ({
|
||||
Id: seasonNumber,
|
||||
IndexNumber: seasonNumber,
|
||||
Name: `Season ${seasonNumber}`,
|
||||
SeriesId: item.SeriesId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (!api || !user?.Id || !item.SeriesId) return [];
|
||||
const response = await api.axiosInstance.get(
|
||||
`${api.basePath}/Shows/${item.SeriesId}/Seasons`,
|
||||
@@ -93,9 +112,19 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
[seasons, seasonIndex],
|
||||
);
|
||||
|
||||
const { data: episodes, isFetching } = useQuery({
|
||||
const { data: episodes } = useQuery({
|
||||
queryKey: ["episodes", item.SeriesId, selectedSeasonId],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
if (!item.SeriesId) return [];
|
||||
return downloadedFiles
|
||||
?.filter(
|
||||
(f: DownloadedItem) =>
|
||||
f.item.SeriesId === item.SeriesId &&
|
||||
f.item.ParentIndexNumber === seasonIndex,
|
||||
)
|
||||
.map((f: DownloadedItem) => f.item);
|
||||
}
|
||||
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
||||
const res = await getTvShowsApi(api).getEpisodes({
|
||||
seriesId: item.SeriesId || "",
|
||||
@@ -112,7 +141,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (item?.Type === "Episode" && item.Id) {
|
||||
const index = episodes?.findIndex((ep) => ep.Id === item.Id);
|
||||
const index = episodes?.findIndex((ep: BaseItemDto) => ep.Id === item.Id);
|
||||
if (index !== undefined && index !== -1) {
|
||||
setTimeout(() => {
|
||||
scrollToIndex(index);
|
||||
@@ -155,7 +184,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
<SafeAreaView
|
||||
style={{
|
||||
position: "absolute",
|
||||
backgroundColor: "black",
|
||||
@@ -163,92 +192,81 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
className={"flex flex-row items-center space-x-2 z-10 p-4"}
|
||||
>
|
||||
{seriesItem && (
|
||||
<SeasonDropdown
|
||||
item={seriesItem}
|
||||
seasons={seasons}
|
||||
state={seasonIndexState}
|
||||
onSelect={(season) => {
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[item.SeriesId ?? ""]: season.IndexNumber,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
close();
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
className={"flex flex-row items-center space-x-2 z-10 p-4"}
|
||||
>
|
||||
{seriesItem && (
|
||||
<SeasonDropdown
|
||||
item={seriesItem}
|
||||
seasons={seasons}
|
||||
state={seasonIndexState}
|
||||
onSelect={(season) => {
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[item.SeriesId ?? ""]: season.IndexNumber,
|
||||
}));
|
||||
}}
|
||||
className='aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2'
|
||||
>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
/>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
onPress={close}
|
||||
className='aspect-square flex flex-col l items-center justify-center p-2'
|
||||
>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<HorizontalScroll
|
||||
ref={scrollViewRef}
|
||||
data={episodes}
|
||||
extraData={item}
|
||||
renderItem={(_item, idx) => (
|
||||
<View
|
||||
key={_item.Id}
|
||||
style={{}}
|
||||
className={`flex flex-col w-44 ${
|
||||
item.Id !== _item.Id ? "opacity-75" : ""
|
||||
<HorizontalScroll
|
||||
ref={scrollViewRef}
|
||||
data={episodes}
|
||||
extraData={item}
|
||||
renderItem={(_item, _idx) => (
|
||||
<View
|
||||
key={_item.Id}
|
||||
style={{}}
|
||||
className={`flex flex-col w-44 ${item.Id !== _item.Id ? "opacity-75" : ""
|
||||
}`}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
goToItem(_item.Id);
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
goToItem(_item.Id);
|
||||
<ContinueWatchingPoster
|
||||
item={_item}
|
||||
useEpisodePoster
|
||||
showPlayButton={_item.Id !== item.Id}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<View className='shrink'>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{
|
||||
lineHeight: 18, // Adjust this value based on your text size
|
||||
height: 36, // lineHeight * 2 for consistent two-line space
|
||||
}}
|
||||
>
|
||||
<ContinueWatchingPoster
|
||||
item={_item}
|
||||
useEpisodePoster
|
||||
showPlayButton={_item.Id !== item.Id}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<View className='shrink'>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{
|
||||
lineHeight: 18, // Adjust this value based on your text size
|
||||
height: 36, // lineHeight * 2 for consistent two-line space
|
||||
}}
|
||||
>
|
||||
{_item.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-xs text-neutral-475'>
|
||||
{`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`}
|
||||
</Text>
|
||||
<Text className='text-xs text-neutral-500'>
|
||||
{runtimeTicksToSeconds(_item.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='self-start mt-2'>
|
||||
<DownloadSingleItem item={_item} />
|
||||
</View>
|
||||
<Text
|
||||
numberOfLines={5}
|
||||
className='text-xs text-neutral-500 shrink'
|
||||
>
|
||||
{_item.Overview}
|
||||
{_item.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-xs text-neutral-475'>
|
||||
{`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`}
|
||||
</Text>
|
||||
<Text className='text-xs text-neutral-500'>
|
||||
{runtimeTicksToSeconds(_item.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(e: BaseItemDto) => e.Id ?? ""}
|
||||
estimatedItemSize={200}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
/>
|
||||
</>
|
||||
</View>
|
||||
<Text numberOfLines={5} className='text-xs text-neutral-500 shrink'>
|
||||
{_item.Overview}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(e: BaseItemDto) => e.Id ?? ""}
|
||||
estimatedItemSize={200}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -79,7 +79,7 @@ const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
|
||||
>
|
||||
<Animated.View style={animatedStyle} />
|
||||
<View className='px-3 py-3'>
|
||||
<Text className='text-center font-bold'>
|
||||
<Text numberOfLines={1} className='text-center font-bold'>
|
||||
{t("player.next_episode")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import type { TrackInfo } from "@/modules/VlcPlayer.types";
|
||||
import { VideoPlayer, useSettings } from "@/utils/atoms/settings";
|
||||
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import type React from "react";
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { TrackInfo } from "@/modules/VlcPlayer.types";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { Track } from "../types";
|
||||
import { useControlContext } from "./ControlContext";
|
||||
|
||||
@@ -48,7 +49,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
}) => {
|
||||
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
||||
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
||||
const [settings] = useSettings();
|
||||
const [_settings] = useSettings();
|
||||
|
||||
const ControlContext = useControlContext();
|
||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
||||
@@ -57,22 +58,27 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
const allSubs =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
||||
|
||||
const { itemId, audioIndex, bitrateValue, subtitleIndex } =
|
||||
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
||||
useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
playbackPosition: string;
|
||||
}>();
|
||||
|
||||
const onTextBasedSubtitle = useMemo(
|
||||
() =>
|
||||
const onTextBasedSubtitle = useMemo(() => {
|
||||
return (
|
||||
allSubs.find(
|
||||
(s) => s.Index?.toString() === subtitleIndex && s.IsTextSubtitleStream,
|
||||
) || subtitleIndex === "-1",
|
||||
[allSubs, subtitleIndex],
|
||||
);
|
||||
(s) =>
|
||||
s.Index?.toString() === subtitleIndex &&
|
||||
(s.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
||||
s.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
||||
s.DeliveryMethod === SubtitleDeliveryMethod.External),
|
||||
) || subtitleIndex === "-1"
|
||||
);
|
||||
}, [allSubs, subtitleIndex]);
|
||||
|
||||
const setPlayerParams = ({
|
||||
chosenAudioIndex = audioIndex,
|
||||
@@ -88,6 +94,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
subtitleIndex: chosenSubtitleIndex,
|
||||
mediaSourceId: mediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue,
|
||||
playbackPosition: playbackPosition,
|
||||
}).toString();
|
||||
|
||||
//@ts-ignore
|
||||
@@ -126,30 +133,32 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
useEffect(() => {
|
||||
const fetchTracks = async () => {
|
||||
if (getSubtitleTracks) {
|
||||
const subtitleData = await getSubtitleTracks();
|
||||
let subtitleData = await getSubtitleTracks();
|
||||
// Only FOR VLC 3, If we're transcoding, we need to reverse the subtitle data, because VLC reverses the HLS subtitles.
|
||||
if (
|
||||
mediaSource?.TranscodingUrl &&
|
||||
subtitleData &&
|
||||
subtitleData.length > 1
|
||||
) {
|
||||
subtitleData = [subtitleData[0], ...subtitleData.slice(1).reverse()];
|
||||
}
|
||||
|
||||
// Step 1: Move external subs to the end, because VLC puts external subs at the end
|
||||
const sortedSubs = allSubs.sort(
|
||||
(a, b) => Number(a.IsExternal) - Number(b.IsExternal),
|
||||
);
|
||||
|
||||
// Step 2: Apply VLC indexing logic
|
||||
let textSubIndex = settings.defaultPlayer === VideoPlayer.VLC_4 ? 0 : 1;
|
||||
const processedSubs: Track[] = sortedSubs?.map((sub) => {
|
||||
// Always increment for non-transcoding subtitles
|
||||
// Only increment for text-based subtitles when transcoding
|
||||
let embedSubIndex = 1;
|
||||
const processedSubs: Track[] = allSubs?.map((sub) => {
|
||||
/** A boolean value determining if we should increment the embedSubIndex, currently only Embed and Hls subtitles are automatically added into VLC Player */
|
||||
const shouldIncrement =
|
||||
!mediaSource?.TranscodingUrl || sub.IsTextSubtitleStream;
|
||||
const vlcIndex = subtitleData?.at(textSubIndex)?.index ?? -1;
|
||||
const finalIndex = shouldIncrement ? vlcIndex : (sub.Index ?? -1);
|
||||
|
||||
if (shouldIncrement) textSubIndex++;
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
|
||||
/** The index of subtitle inside VLC Player Itself */
|
||||
const vlcIndex = subtitleData?.at(embedSubIndex)?.index ?? -1;
|
||||
if (shouldIncrement) embedSubIndex++;
|
||||
return {
|
||||
name: sub.DisplayTitle || "Undefined Subtitle",
|
||||
index: sub.Index ?? -1,
|
||||
setTrack: () =>
|
||||
shouldIncrement
|
||||
? setTrackParams("subtitle", finalIndex, sub.Index ?? -1)
|
||||
? setTrackParams("subtitle", vlcIndex, sub.Index ?? -1)
|
||||
: setPlayerParams({
|
||||
chosenSubtitleIndex: sub.Index?.toString(),
|
||||
}),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { Platform, TouchableOpacity } from "react-native";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { useControlContext } from "../contexts/ControlContext";
|
||||
import { useVideoContext } from "../contexts/VideoContext";
|
||||
|
||||
@@ -17,13 +19,18 @@ const DropdownView = () => {
|
||||
];
|
||||
const router = useRouter();
|
||||
|
||||
const { subtitleIndex, audioIndex, bitrateValue } = useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
}>();
|
||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } =
|
||||
useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
playbackPosition: string;
|
||||
offline: string;
|
||||
}>();
|
||||
|
||||
const isOffline = offline === "true";
|
||||
|
||||
const changeBitrate = useCallback(
|
||||
(bitrate: string) => {
|
||||
@@ -33,11 +40,12 @@ const DropdownView = () => {
|
||||
subtitleIndex: subtitleIndex.toString() ?? "",
|
||||
mediaSourceId: mediaSource?.Id ?? "",
|
||||
bitrateValue: bitrate.toString(),
|
||||
playbackPosition: playbackPosition,
|
||||
}).toString();
|
||||
// @ts-expect-error
|
||||
router.replace(`player/direct-player?${queryParams}`);
|
||||
},
|
||||
[item, mediaSource, subtitleIndex, audioIndex],
|
||||
[item, mediaSource, subtitleIndex, audioIndex, playbackPosition],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -56,32 +64,34 @@ const DropdownView = () => {
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key='qualitytrigger'>
|
||||
Quality
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{BITRATES?.map((bitrate, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`quality-item-${idx}`}
|
||||
value={bitrateValue === (bitrate.value?.toString() ?? "")}
|
||||
onValueChange={() =>
|
||||
changeBitrate(bitrate.value?.toString() ?? "")
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||
{bitrate.key}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
{!isOffline && (
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key='qualitytrigger'>
|
||||
Quality
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{BITRATES?.map((bitrate, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`quality-item-${idx}`}
|
||||
value={bitrateValue === (bitrate.value?.toString() ?? "")}
|
||||
onValueChange={() =>
|
||||
changeBitrate(bitrate.value?.toString() ?? "")
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||
{bitrate.key}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
)}
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key='subtitle-trigger'>
|
||||
Subtitle
|
||||
|
||||
6
eas.json
6
eas.json
@@ -47,14 +47,14 @@
|
||||
},
|
||||
"production": {
|
||||
"environment": "production",
|
||||
"channel": "0.28.0",
|
||||
"channel": "0.28.1",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"environment": "production",
|
||||
"channel": "0.28.0",
|
||||
"channel": "0.28.1",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
@@ -62,7 +62,7 @@
|
||||
},
|
||||
"production-apk-tv": {
|
||||
"environment": "production",
|
||||
"channel": "0.28.0",
|
||||
"channel": "0.28.1",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
@@ -1,21 +1,63 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
interface AdjacentEpisodesProps {
|
||||
item?: BaseItemDto | null;
|
||||
isOffline?: boolean;
|
||||
}
|
||||
|
||||
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
|
||||
export const useAdjacentItems = ({
|
||||
item,
|
||||
isOffline = false,
|
||||
}: AdjacentEpisodesProps) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { downloadedFiles } = useDownload();
|
||||
|
||||
const { data: adjacentItems } = useQuery({
|
||||
queryKey: ["adjacentItems", item?.Id, item?.SeriesId],
|
||||
queryKey: ["adjacentItems", item?.Id, item?.SeriesId, isOffline],
|
||||
queryFn: async (): Promise<BaseItemDto[] | null> => {
|
||||
if (!api || !item || !item.SeriesId) {
|
||||
if (!item || !item.SeriesId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isOffline) {
|
||||
if (!downloadedFiles) return null;
|
||||
const seriesEpisodes = downloadedFiles
|
||||
.filter((f) => f.item.SeriesId === item.SeriesId)
|
||||
.map((f) => f.item);
|
||||
|
||||
seriesEpisodes.sort((a, b) => {
|
||||
if (a.ParentIndexNumber !== b.ParentIndexNumber) {
|
||||
return (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0);
|
||||
}
|
||||
return (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0);
|
||||
});
|
||||
|
||||
const currentIndex = seriesEpisodes.findIndex(
|
||||
(ep) => ep.Id === item.Id,
|
||||
);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: BaseItemDto[] = [];
|
||||
if (currentIndex > 0) {
|
||||
result.push(seriesEpisodes[currentIndex - 1]);
|
||||
}
|
||||
result.push(seriesEpisodes[currentIndex]);
|
||||
if (currentIndex < seriesEpisodes.length - 1) {
|
||||
result.push(seriesEpisodes[currentIndex + 1]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!api) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -29,7 +71,7 @@ export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
|
||||
return res.data.Items || null;
|
||||
},
|
||||
enabled:
|
||||
!!api &&
|
||||
(isOffline || !!api) &&
|
||||
!!item?.Id &&
|
||||
!!item?.SeriesId &&
|
||||
(item?.Type === "Episode" || item?.Type === "Audio"),
|
||||
|
||||
@@ -1,33 +1,16 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||
import { useHaptic } from "./useHaptic";
|
||||
|
||||
interface CreditTimestamps {
|
||||
Introduction: {
|
||||
Start: number;
|
||||
End: number;
|
||||
Valid: boolean;
|
||||
};
|
||||
Credits: {
|
||||
Start: number;
|
||||
End: number;
|
||||
Valid: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const useCreditSkipper = (
|
||||
itemId: string | undefined,
|
||||
itemId: string,
|
||||
currentTime: number,
|
||||
seek: (time: number) => void,
|
||||
play: () => void,
|
||||
isVlc = false,
|
||||
isOffline = false,
|
||||
) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
@@ -43,50 +26,28 @@ export const useCreditSkipper = (
|
||||
seek(seconds);
|
||||
};
|
||||
|
||||
const { data: creditTimestamps } = useQuery<CreditTimestamps | null>({
|
||||
queryKey: ["creditTimestamps", itemId],
|
||||
queryFn: async () => {
|
||||
if (!itemId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await api?.axiosInstance.get(
|
||||
`${api.basePath}/Episode/${itemId}/Timestamps`,
|
||||
{
|
||||
headers: getAuthHeaders(api),
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return res?.data;
|
||||
},
|
||||
enabled: !!itemId,
|
||||
retry: false,
|
||||
});
|
||||
const { data: segments } = useSegments(itemId, isOffline);
|
||||
const creditTimestamps = segments?.creditSegments?.[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (creditTimestamps) {
|
||||
setShowSkipCreditButton(
|
||||
currentTime > creditTimestamps.Credits.Start &&
|
||||
currentTime < creditTimestamps.Credits.End,
|
||||
currentTime > creditTimestamps.startTime &&
|
||||
currentTime < creditTimestamps.endTime,
|
||||
);
|
||||
}
|
||||
}, [creditTimestamps, currentTime]);
|
||||
|
||||
const skipCredit = useCallback(() => {
|
||||
if (!creditTimestamps) return;
|
||||
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
|
||||
try {
|
||||
lightHapticFeedback();
|
||||
wrappedSeek(creditTimestamps.Credits.End);
|
||||
wrappedSeek(creditTimestamps.endTime);
|
||||
setTimeout(() => {
|
||||
play();
|
||||
}, 200);
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error skipping intro", error);
|
||||
console.error("Error skipping credit", error);
|
||||
}
|
||||
}, [creditTimestamps]);
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { BITRATES, Bitrate } from "@/components/BitrateSelector";
|
||||
import type { Settings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
type BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useMemo } from "react";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import type { Settings } from "@/utils/atoms/settings";
|
||||
|
||||
// Used only for initial play settings.
|
||||
const useDefaultPlaySettings = (
|
||||
@@ -33,10 +30,10 @@ const useDefaultPlaySettings = (
|
||||
|
||||
return {
|
||||
defaultAudioIndex:
|
||||
preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined,
|
||||
defaultSubtitleIndex: mediaSource?.DefaultSubtitleStreamIndex || -1,
|
||||
defaultMediaSource: mediaSource || undefined,
|
||||
defaultBitrate: bitrate || undefined,
|
||||
preferedAudioIndex ?? defaultAudioIndex ?? firstAudioIndex ?? undefined,
|
||||
defaultSubtitleIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1,
|
||||
defaultMediaSource: mediaSource ?? undefined,
|
||||
defaultBitrate: bitrate ?? undefined,
|
||||
};
|
||||
}, [
|
||||
item.MediaSources,
|
||||
|
||||
@@ -1,31 +1,8 @@
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export const getDownloadedFileUrl = async (itemId: string): Promise<string> => {
|
||||
const directory = FileSystem.documentDirectory;
|
||||
|
||||
if (!directory) {
|
||||
throw new Error("Document directory is not available");
|
||||
}
|
||||
|
||||
if (!itemId) {
|
||||
throw new Error("Item ID is not available");
|
||||
}
|
||||
|
||||
const files = await FileSystem.readDirectoryAsync(directory);
|
||||
const path = itemId!;
|
||||
const matchingFile = files.find((file) => file.startsWith(path));
|
||||
|
||||
if (!matchingFile) {
|
||||
throw new Error(`No file found for item ${path}`);
|
||||
}
|
||||
|
||||
return `${directory}${matchingFile}`;
|
||||
};
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
export const useDownloadedFileOpener = () => {
|
||||
const router = useRouter();
|
||||
@@ -33,9 +10,19 @@ export const useDownloadedFileOpener = () => {
|
||||
|
||||
const openFile = useCallback(
|
||||
async (item: BaseItemDto) => {
|
||||
if (!item.Id) {
|
||||
writeToLog("ERROR", "Attempted to open a file without an ID.");
|
||||
console.error("Attempted to open a file without an ID.");
|
||||
return;
|
||||
}
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
try {
|
||||
// @ts-expect-error
|
||||
router.push(`/player/direct-player?offline=true&itemId=${item.Id}`);
|
||||
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error opening file", error);
|
||||
console.error("Error opening file:", error);
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSegments } from "@/utils/segments";
|
||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||
import { useHaptic } from "./useHaptic";
|
||||
|
||||
interface IntroTimestamps {
|
||||
EpisodeId: string;
|
||||
HideSkipPromptAt: number;
|
||||
IntroEnd: number;
|
||||
IntroStart: number;
|
||||
ShowSkipPromptAt: number;
|
||||
Valid: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to handle skipping intros in a media player.
|
||||
*
|
||||
* @param {number} currentTime - The current playback time in seconds.
|
||||
*/
|
||||
export const useIntroSkipper = (
|
||||
itemId: string | undefined,
|
||||
itemId: string,
|
||||
currentTime: number,
|
||||
seek: (ticks: number) => void,
|
||||
play: () => void,
|
||||
isVlc = false,
|
||||
isOffline = false,
|
||||
) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||
if (isVlc) {
|
||||
currentTime = msToSeconds(currentTime);
|
||||
@@ -43,35 +30,14 @@ export const useIntroSkipper = (
|
||||
seek(seconds);
|
||||
};
|
||||
|
||||
const { data: introTimestamps } = useQuery<IntroTimestamps | null>({
|
||||
queryKey: ["introTimestamps", itemId],
|
||||
queryFn: async () => {
|
||||
if (!itemId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await api?.axiosInstance.get(
|
||||
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
|
||||
{
|
||||
headers: getAuthHeaders(api),
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return res?.data;
|
||||
},
|
||||
enabled: !!itemId,
|
||||
retry: false,
|
||||
});
|
||||
const { data: segments } = useSegments(itemId, isOffline);
|
||||
const introTimestamps = segments?.introSegments?.[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (introTimestamps) {
|
||||
setShowSkipButton(
|
||||
currentTime > introTimestamps.ShowSkipPromptAt &&
|
||||
currentTime < introTimestamps.HideSkipPromptAt,
|
||||
currentTime > introTimestamps.startTime &&
|
||||
currentTime < introTimestamps.endTime,
|
||||
);
|
||||
}
|
||||
}, [introTimestamps, currentTime]);
|
||||
@@ -80,12 +46,12 @@ export const useIntroSkipper = (
|
||||
if (!introTimestamps) return;
|
||||
try {
|
||||
lightHapticFeedback();
|
||||
wrappedSeek(introTimestamps.IntroEnd);
|
||||
wrappedSeek(introTimestamps.endTime);
|
||||
setTimeout(() => {
|
||||
play();
|
||||
}, 200);
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error skipping intro", error);
|
||||
console.error("Error skipping intro", error);
|
||||
}
|
||||
}, [introTimestamps]);
|
||||
|
||||
|
||||
30
hooks/useItemQuery.ts
Normal file
30
hooks/useItemQuery.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
export const useItemQuery = (itemId: string, isOffline: boolean) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { downloadedFiles } = useDownload();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["item", itemId],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
const downloadedItem = downloadedFiles?.find((item) => item.item.Id === itemId);
|
||||
if (downloadedItem) return downloadedItem.item;
|
||||
return null;
|
||||
}
|
||||
if (!api || !user || !itemId) return null;
|
||||
const res = await getUserLibraryApi(api).getItem({ itemId: itemId, userId: user?.Id });
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
networkMode: "always",
|
||||
});
|
||||
};
|
||||
@@ -1,102 +1,39 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
|
||||
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { QueryKey, useQueryClient } from "@tanstack/react-query";
|
||||
import { useHaptic } from "./useHaptic";
|
||||
import { usePlaybackManager } from "./usePlaybackManager";
|
||||
import { useInvalidatePlaybackProgressCache } from "./useRevalidatePlaybackProgressCache";
|
||||
|
||||
export const useMarkAsPlayed = (items: BaseItemDto[]) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const invalidateQueries = () => {
|
||||
const queriesToInvalidate = [
|
||||
["resumeItems"],
|
||||
["continueWatching"],
|
||||
["nextUp-all"],
|
||||
["nextUp"],
|
||||
["episodes"],
|
||||
["seasons"],
|
||||
["home"],
|
||||
];
|
||||
|
||||
const { markItemPlayed, markItemUnplayed } = usePlaybackManager();
|
||||
const invalidatePlaybackProgressCache = useInvalidatePlaybackProgressCache();
|
||||
const invalidateQueries = async () => {
|
||||
const queriesToInvalidate: QueryKey[] = [];
|
||||
items.forEach((item) => {
|
||||
if (!item.Id) return;
|
||||
queriesToInvalidate.push(["item", item.Id]);
|
||||
});
|
||||
|
||||
queriesToInvalidate.forEach((queryKey) => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
});
|
||||
await Promise.all(
|
||||
queriesToInvalidate.map((queryKey) =>
|
||||
queryClient.invalidateQueries({ queryKey }),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const markAsPlayedStatus = async (played: boolean) => {
|
||||
const toggle = async (played: boolean) => {
|
||||
lightHapticFeedback();
|
||||
|
||||
items.forEach((item) => {
|
||||
// Optimistic update
|
||||
queryClient.setQueryData(
|
||||
["item", item.Id],
|
||||
(oldData: BaseItemDto | undefined) => {
|
||||
if (oldData) {
|
||||
return {
|
||||
...oldData,
|
||||
UserData: {
|
||||
...oldData.UserData,
|
||||
Played: played,
|
||||
},
|
||||
};
|
||||
}
|
||||
return oldData;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
// Process all items
|
||||
await Promise.all(
|
||||
items.map((item) =>
|
||||
played
|
||||
? markAsPlayed({ api, item, userId: user?.Id })
|
||||
: markAsNotPlayed({ api, itemId: item?.Id, userId: user?.Id }),
|
||||
),
|
||||
);
|
||||
|
||||
// Bulk invalidate
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
"resumeItems",
|
||||
"continueWatching",
|
||||
"nextUp-all",
|
||||
"nextUp",
|
||||
"episodes",
|
||||
"seasons",
|
||||
"home",
|
||||
...items.map((item) => ["item", item.Id]),
|
||||
].flat(),
|
||||
});
|
||||
} catch (error) {
|
||||
// Revert all optimistic updates on any failure
|
||||
items.forEach((item) => {
|
||||
queryClient.setQueryData(
|
||||
["item", item.Id],
|
||||
(oldData: BaseItemDto | undefined) =>
|
||||
oldData
|
||||
? {
|
||||
...oldData,
|
||||
UserData: { ...oldData.UserData, Played: played },
|
||||
}
|
||||
: oldData,
|
||||
);
|
||||
});
|
||||
console.error("Error updating played status:", error);
|
||||
}
|
||||
|
||||
// Process all items
|
||||
await Promise.all(
|
||||
items.map((item) => {
|
||||
if (!item.Id) return Promise.resolve();
|
||||
return played ? markItemPlayed(item.Id) : markItemUnplayed(item.Id);
|
||||
}),
|
||||
);
|
||||
invalidatePlaybackProgressCache();
|
||||
invalidateQueries();
|
||||
};
|
||||
|
||||
return markAsPlayedStatus;
|
||||
return toggle;
|
||||
};
|
||||
|
||||
213
hooks/usePlaybackManager.ts
Normal file
213
hooks/usePlaybackManager.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api/user-library-api";
|
||||
import { useNetInfo } from "@react-native-community/netinfo";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
/**
|
||||
* A hook to manage playback state, abstracting away the complexities of
|
||||
* online/offline and local/remote state management.
|
||||
*
|
||||
* This provides a simple facade for player components to report playback
|
||||
* without needing to know the underlying details of data syncing.
|
||||
*/
|
||||
export const usePlaybackManager = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const netInfo = useNetInfo();
|
||||
const { getDownloadedItemById, updateDownloadedItem } = useDownload();
|
||||
|
||||
/** Whether the device is online. actually it's connected to the internet. */
|
||||
const isOnline = netInfo.isConnected;
|
||||
|
||||
/**
|
||||
* Fetches the latest state of an item from the server and updates the local
|
||||
* downloaded version to match. This ensures the local item has the
|
||||
* canonical state from the server.
|
||||
*/
|
||||
const _syncRemoteToLocal = async (localItem: DownloadedItem) => {
|
||||
if (!isOnline || !api || !user) return;
|
||||
|
||||
try {
|
||||
const remoteItem = (
|
||||
await getUserLibraryApi(api).getItem({
|
||||
itemId: localItem.item.Id!,
|
||||
userId: user.Id,
|
||||
})
|
||||
).data;
|
||||
if (remoteItem) {
|
||||
updateDownloadedItem(localItem.item.Id!, {
|
||||
...localItem,
|
||||
item: {
|
||||
...localItem.item,
|
||||
UserData: { ...remoteItem.UserData },
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to sync remote item state to local", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reports playback progress.
|
||||
*
|
||||
* - If offline and the item is downloaded, updates are saved locally.
|
||||
* - If online and the item is downloaded, it updates locally and syncs with the server.
|
||||
* - If online and streaming, it reports directly to the server.
|
||||
*
|
||||
* @param itemId The ID of the item.
|
||||
* @param positionTicks The current playback position in ticks.
|
||||
*/
|
||||
const reportPlaybackProgress = async (
|
||||
itemId: string,
|
||||
positionTicks: number,
|
||||
metadata?: {
|
||||
AudioStreamIndex: number;
|
||||
SubtitleStreamIndex: number;
|
||||
},
|
||||
) => {
|
||||
const localItem = getDownloadedItemById(itemId);
|
||||
|
||||
// Handle local state update for downloaded items
|
||||
if (localItem) {
|
||||
const isItemConsideredPlayed =
|
||||
(localItem.item.UserData?.PlayedPercentage ?? 0) > 90;
|
||||
updateDownloadedItem(itemId, {
|
||||
...localItem,
|
||||
item: {
|
||||
...localItem.item,
|
||||
UserData: {
|
||||
...localItem.item.UserData,
|
||||
PlaybackPositionTicks: isItemConsideredPlayed ? 0 : positionTicks,
|
||||
Played: isItemConsideredPlayed,
|
||||
LastPlayedDate: new Date().toISOString(),
|
||||
PlayedPercentage: isItemConsideredPlayed
|
||||
? 0
|
||||
: (positionTicks / localItem.item.RunTimeTicks!) * 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Handle remote state update if online
|
||||
if (isOnline && api) {
|
||||
try {
|
||||
await getPlaystateApi(api).reportPlaybackProgress({
|
||||
playbackProgressInfo: {
|
||||
ItemId: itemId,
|
||||
PositionTicks: positionTicks,
|
||||
...(metadata && { AudioStreamIndex: metadata.AudioStreamIndex }),
|
||||
...(metadata && {
|
||||
SubtitleStreamIndex: metadata.SubtitleStreamIndex,
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to report playback progress on server", error);
|
||||
}
|
||||
// If it was a downloaded item, re-sync with the server for the latest state.
|
||||
// This is crucial because the server might have marked the item as "Played"
|
||||
// based on its own rules (e.g., >95% progress).
|
||||
if (localItem) {
|
||||
await _syncRemoteToLocal(localItem);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks an item as played.
|
||||
*
|
||||
* - If offline and downloaded, it marks as played locally.
|
||||
* - If online, it marks as played on the server and syncs the state back to the local item if it exists.
|
||||
*
|
||||
* @param itemId The ID of the item.
|
||||
*/
|
||||
const markItemPlayed = async (itemId: string) => {
|
||||
const localItem = getDownloadedItemById(itemId);
|
||||
|
||||
// Handle local state update for downloaded items
|
||||
if (localItem) {
|
||||
updateDownloadedItem(itemId, {
|
||||
...localItem,
|
||||
item: {
|
||||
...localItem.item,
|
||||
UserData: {
|
||||
...localItem.item.UserData,
|
||||
Played: true,
|
||||
PlaybackPositionTicks: 0,
|
||||
PlayedPercentage: 0,
|
||||
LastPlayedDate: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Handle remote state update if online
|
||||
if (isOnline && api && user) {
|
||||
try {
|
||||
await getPlaystateApi(api).markPlayedItem({
|
||||
itemId,
|
||||
userId: user.Id,
|
||||
});
|
||||
|
||||
// If it was a downloaded item, re-sync with server for the latest state
|
||||
if (localItem) {
|
||||
await _syncRemoteToLocal(localItem);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to mark item as played on server", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks an item as unplayed.
|
||||
*
|
||||
* - If offline and downloaded, it marks as unplayed locally.
|
||||
* - If online, it marks as unplayed on the server and syncs the state back to the local item if it exists.
|
||||
*
|
||||
* @param itemId The ID of the item.
|
||||
*/
|
||||
const markItemUnplayed = async (itemId: string) => {
|
||||
const localItem = getDownloadedItemById(itemId);
|
||||
|
||||
// Handle local state update for downloaded items
|
||||
if (localItem) {
|
||||
updateDownloadedItem(itemId, {
|
||||
...localItem,
|
||||
item: {
|
||||
...localItem.item,
|
||||
UserData: {
|
||||
...localItem.item.UserData,
|
||||
Played: false,
|
||||
PlaybackPositionTicks: 0,
|
||||
PlayedPercentage: 0,
|
||||
LastPlayedDate: new Date().toISOString(), // Keep track of when it was marked unplayed
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Handle remote state update if online
|
||||
if (isOnline && api && user) {
|
||||
try {
|
||||
await getPlaystateApi(api).markUnplayedItem({
|
||||
itemId,
|
||||
userId: user.Id,
|
||||
});
|
||||
|
||||
// If it was a downloaded item, re-sync with server for the latest state
|
||||
if (localItem) {
|
||||
await _syncRemoteToLocal(localItem);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to mark item as unplayed on server", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { reportPlaybackProgress, markItemPlayed, markItemUnplayed };
|
||||
};
|
||||
@@ -1,231 +0,0 @@
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { writeErrorLog, writeInfoLog } from "@/utils/log";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
// import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
|
||||
const FFMPEGKitReactNative = !Platform.isTV
|
||||
? require("ffmpeg-kit-react-native")
|
||||
: null;
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import useDownloadHelper from "@/utils/download";
|
||||
import type { JobStatus } from "@/utils/optimize-server";
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import useImageStorage from "./useImageStorage";
|
||||
|
||||
type FFmpegSession = typeof FFMPEGKitReactNative.FFmpegSession;
|
||||
type Statistics = typeof FFMPEGKitReactNative.Statistics;
|
||||
const FFmpegKit = Platform.isTV
|
||||
? null
|
||||
: (FFMPEGKitReactNative.FFmpegKit as typeof FFMPEGKitReactNative.FFmpegKit);
|
||||
const createFFmpegCommand = (url: string, output: string) => [
|
||||
"-y", // overwrite output files without asking
|
||||
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
|
||||
|
||||
// region ffmpeg protocol commands // https://ffmpeg.org/ffmpeg-protocols.html
|
||||
"-protocol_whitelist file,http,https,tcp,tls,crypto", // whitelist
|
||||
"-multiple_requests 1", // http
|
||||
"-tcp_nodelay 1", // http
|
||||
// endregion ffmpeg protocol commands
|
||||
|
||||
"-fflags +genpts", // format flags
|
||||
`-i ${url}`, // infile
|
||||
"-map 0:v -map 0:a", // select all streams for video & audio
|
||||
"-c copy", // streamcopy, preventing transcoding
|
||||
"-bufsize 25M", // amount of data processed before calculating current bitrate
|
||||
"-max_muxing_queue_size 4096", // sets the size of stream buffer in packets for output
|
||||
output,
|
||||
];
|
||||
|
||||
/**
|
||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||
*
|
||||
* @param url - The URL of the HLS stream
|
||||
* @param item - The BaseItemDto object representing the media item
|
||||
* @returns An object with remuxing-related functions
|
||||
*/
|
||||
export const useRemuxHlsToMp4 = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [settings] = useSettings();
|
||||
const { saveImage } = useImageStorage();
|
||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||
const {
|
||||
saveDownloadedItemInfo,
|
||||
setProcesses,
|
||||
processes,
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
||||
} = useDownload();
|
||||
|
||||
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
|
||||
await saveSeriesPrimaryImage(item);
|
||||
const itemImage = getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 90,
|
||||
width: 500,
|
||||
});
|
||||
|
||||
await saveImage(item.Id, itemImage?.uri);
|
||||
};
|
||||
|
||||
const completeCallback = useCallback(
|
||||
async (session: FFmpegSession, item: BaseItemDto) => {
|
||||
try {
|
||||
console.log("completeCallback");
|
||||
const returnCode = await session.getReturnCode();
|
||||
|
||||
if (returnCode.isValueSuccess()) {
|
||||
const stat = await session.getLastReceivedStatistics();
|
||||
await FileSystem.moveAsync({
|
||||
from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`,
|
||||
to: `${FileSystem.documentDirectory}${item.Id}.mp4`,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["downloadedItems"],
|
||||
});
|
||||
saveDownloadedItemInfo(item, stat.getSize());
|
||||
toast.success(t("home.downloads.toasts.download_completed"));
|
||||
}
|
||||
|
||||
setProcesses((prev: any[]) => {
|
||||
return prev.filter(
|
||||
(process: { itemId: string | undefined }) =>
|
||||
process.itemId !== item.Id,
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
console.log("completeCallback ~ end");
|
||||
},
|
||||
[processes, setProcesses],
|
||||
);
|
||||
|
||||
const statisticsCallback = useCallback(
|
||||
(statistics: Statistics, item: BaseItemDto) => {
|
||||
const videoLength =
|
||||
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
|
||||
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
|
||||
const totalFrames = videoLength * fps;
|
||||
const processedFrames = statistics.getVideoFrameNumber();
|
||||
const speed = statistics.getSpeed();
|
||||
|
||||
const percentage =
|
||||
totalFrames > 0 ? Math.floor((processedFrames / totalFrames) * 100) : 0;
|
||||
|
||||
if (!item.Id) throw new Error("Item is undefined");
|
||||
setProcesses((prev: JobStatus[]) => {
|
||||
return prev.map((process: JobStatus) => {
|
||||
if (process.itemId === item.Id) {
|
||||
return {
|
||||
...process,
|
||||
id: statistics.getSessionId().toString(),
|
||||
progress: percentage,
|
||||
speed: Math.max(speed, 0),
|
||||
};
|
||||
}
|
||||
return process;
|
||||
});
|
||||
});
|
||||
},
|
||||
[setProcesses, completeCallback],
|
||||
);
|
||||
|
||||
const startRemuxing = useCallback(
|
||||
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
||||
const cacheDir = await FileSystem.getInfoAsync(
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
||||
);
|
||||
if (!cacheDir.exists) {
|
||||
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {
|
||||
intermediates: true,
|
||||
});
|
||||
}
|
||||
|
||||
const output = `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`;
|
||||
|
||||
if (!api) throw new Error("API is not defined");
|
||||
if (!item.Id) throw new Error("Item must have an Id");
|
||||
|
||||
// First lets save any important assets we want to present to the user offline
|
||||
await onSaveAssets(api, item);
|
||||
|
||||
toast.success(
|
||||
t("home.downloads.toasts.download_started_for", { item: item.Name }),
|
||||
{
|
||||
action: {
|
||||
label: "Go to download",
|
||||
onClick: () => {
|
||||
router.push("/downloads");
|
||||
toast.dismiss();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const job: JobStatus = {
|
||||
id: "",
|
||||
deviceId: "",
|
||||
inputUrl: url,
|
||||
item: item,
|
||||
itemId: item.Id!,
|
||||
outputPath: output,
|
||||
progress: 0,
|
||||
status: "downloading",
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
|
||||
setProcesses((prev: any) => [...prev, job]);
|
||||
|
||||
await FFmpegKit.executeAsync(
|
||||
createFFmpegCommand(url, output).join(" "),
|
||||
(session: any) => completeCallback(session, item),
|
||||
undefined,
|
||||
(s: any) => statisticsCallback(s, item),
|
||||
);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
console.error("Failed to remux:", error);
|
||||
writeErrorLog(
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
|
||||
Error: ${error.message}, Stack: ${error.stack}`,
|
||||
);
|
||||
setProcesses((prev: any[]) => {
|
||||
return prev.filter(
|
||||
(process: { itemId: string | undefined }) =>
|
||||
process.itemId !== item.Id,
|
||||
);
|
||||
});
|
||||
throw error; // Re-throw the error to propagate it to the caller
|
||||
}
|
||||
},
|
||||
[settings, processes, setProcesses, completeCallback, statisticsCallback],
|
||||
);
|
||||
|
||||
const cancelRemuxing = useCallback(() => {
|
||||
FFmpegKit.cancel();
|
||||
setProcesses([]);
|
||||
}, []);
|
||||
|
||||
return { startRemuxing, cancelRemuxing };
|
||||
};
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useTwoWaySync } from "./useTwoWaySync";
|
||||
|
||||
/**
|
||||
* useRevalidatePlaybackProgressCache invalidates queries related to playback progress.
|
||||
*/
|
||||
export function useInvalidatePlaybackProgressCache() {
|
||||
const queryClient = useQueryClient();
|
||||
const { downloadedFiles } = useDownload();
|
||||
const { syncPlaybackState } = useTwoWaySync();
|
||||
|
||||
const revalidate = async () => {
|
||||
// List of all the queries to invalidate
|
||||
@@ -17,11 +21,33 @@ export function useInvalidatePlaybackProgressCache() {
|
||||
["episodes"],
|
||||
["seasons"],
|
||||
["home"],
|
||||
["downloadedItems"],
|
||||
];
|
||||
|
||||
// Invalidate each query
|
||||
for (const queryKey of queriesToInvalidate) {
|
||||
await queryClient.invalidateQueries({ queryKey });
|
||||
// We Invalidate all the queries to the latest server versions
|
||||
await Promise.all(
|
||||
queriesToInvalidate.map((queryKey) =>
|
||||
queryClient.invalidateQueries({ queryKey }),
|
||||
),
|
||||
);
|
||||
|
||||
// Sync playback state for downloaded items
|
||||
if (downloadedFiles) {
|
||||
// We sync the playback state for the downloaded items
|
||||
const syncResults = await Promise.all(
|
||||
downloadedFiles.map((downloadedItem) =>
|
||||
syncPlaybackState(downloadedItem.item.Id!),
|
||||
),
|
||||
);
|
||||
// We invalidate the queries again in case we have updated a server's playback progress.
|
||||
const shouldInvalidate = syncResults.some((result) => result);
|
||||
|
||||
console.log("shouldInvalidate", shouldInvalidate);
|
||||
if (shouldInvalidate) {
|
||||
queriesToInvalidate.map((queryKey) =>
|
||||
queryClient.invalidateQueries({ queryKey }),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -44,3 +44,27 @@ export const useSessions = ({
|
||||
|
||||
return { sessions: data, isLoading };
|
||||
};
|
||||
|
||||
export const useAllSessions = ({
|
||||
refetchInterval = 5 * 1000,
|
||||
activeWithinSeconds = 360,
|
||||
}: useSessionsProps) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["allSessions"],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || !user.Policy?.IsAdministrator) {
|
||||
return [];
|
||||
}
|
||||
const response = await getSessionApi(api).getSessions({
|
||||
activeWithinSeconds: activeWithinSeconds,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
refetchInterval: refetchInterval,
|
||||
});
|
||||
|
||||
return { sessions: data, isLoading };
|
||||
};
|
||||
|
||||
@@ -1,11 +1,69 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { ticksToMs } from "@/utils/time";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { store } from "@/utils/store";
|
||||
import { ticksToMs } from "@/utils/time";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useGlobalSearchParams } from "expo-router";
|
||||
|
||||
interface TrickplayData {
|
||||
interface TrickplayUrl {
|
||||
x: number;
|
||||
y: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** Hook to handle trickplay logic for a given item. */
|
||||
export const useTrickplay = (item: BaseItemDto) => {
|
||||
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
||||
const { getDownloadedItemById } = useDownload();
|
||||
const lastCalculationTime = useRef(0);
|
||||
const throttleDelay = 200;
|
||||
const isOffline = useGlobalSearchParams().offline === "true";
|
||||
const trickplayInfo = useMemo(() => getTrickplayInfo(item), [item]);
|
||||
|
||||
/** Generates the trickplay URL for the given item and sheet index.
|
||||
* We change between offline and online trickplay URLs depending on the state of the app. */
|
||||
const getTrickplayUrl = useCallback((item: BaseItemDto, sheetIndex: number) => {
|
||||
// If we are offline, we can use the downloaded item's trickplay data path
|
||||
const downloadedItem = getDownloadedItemById(item.Id!);
|
||||
if (isOffline && downloadedItem?.trickPlayData?.path) {
|
||||
return `${downloadedItem.trickPlayData.path}${sheetIndex}.jpg`;
|
||||
}
|
||||
return generateTrickplayUrl(item, sheetIndex);
|
||||
}, [trickplayInfo]);
|
||||
|
||||
/** Calculates the trickplay URL for the current progress. */
|
||||
const calculateTrickplayUrl = useCallback(
|
||||
(progress: number) => {
|
||||
const now = Date.now();
|
||||
if (!trickplayInfo || !item.Id || now - lastCalculationTime.current < throttleDelay) return;
|
||||
lastCalculationTime.current = now;
|
||||
const { sheetIndex, x, y } = calculateTrickplayTile(progress, trickplayInfo);
|
||||
const url = getTrickplayUrl(item, sheetIndex);
|
||||
if (url) setTrickPlayUrl({ x, y, url });
|
||||
},
|
||||
[trickplayInfo, item, throttleDelay, getTrickplayUrl],
|
||||
);
|
||||
|
||||
/** Prefetches all the trickplay images for the item. */
|
||||
const prefetchAllTrickplayImages = useCallback(() => {
|
||||
if (!trickplayInfo || !item.Id) return;
|
||||
for (let index = 0; index < trickplayInfo.totalImageSheets; index++) {
|
||||
const url = getTrickplayUrl(item, index);
|
||||
if (url) Image.prefetch(url);
|
||||
}
|
||||
}, [trickplayInfo, item, getTrickplayUrl]);
|
||||
|
||||
return {
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
prefetchAllTrickplayImages,
|
||||
trickplayInfo,
|
||||
};
|
||||
};
|
||||
|
||||
export interface TrickplayData {
|
||||
Interval?: number;
|
||||
TileWidth?: number;
|
||||
TileHeight?: number;
|
||||
@@ -14,141 +72,93 @@ interface TrickplayData {
|
||||
ThumbnailCount?: number;
|
||||
}
|
||||
|
||||
interface TrickplayInfo {
|
||||
export interface TrickplayInfo {
|
||||
resolution: string;
|
||||
aspectRatio: number;
|
||||
data: TrickplayData;
|
||||
totalImageSheets: number;
|
||||
}
|
||||
|
||||
interface TrickplayUrl {
|
||||
x: number;
|
||||
y: number;
|
||||
url: string;
|
||||
}
|
||||
/** Generates a trickplay URL based on the item, resolution, and sheet index. */
|
||||
export const generateTrickplayUrl = (item: BaseItemDto, sheetIndex: number) => {
|
||||
const api = store.get(apiAtom);
|
||||
const resolution = getTrickplayInfo(item)?.resolution;
|
||||
if (!resolution || !api) return null;
|
||||
return `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${sheetIndex}.jpg?api_key=${api.accessToken}`;
|
||||
};
|
||||
|
||||
export const useTrickplay = (item: BaseItemDto, enabled = true) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
||||
const lastCalculationTime = useRef(0);
|
||||
const throttleDelay = 200; // 200ms throttle
|
||||
/**
|
||||
* Parses the trickplay metadata from a BaseItemDto.
|
||||
* @param item The Jellyfin media item.
|
||||
* @returns Parsed trickplay information or null if not available.
|
||||
*/
|
||||
export const getTrickplayInfo = (item: BaseItemDto): TrickplayInfo | null => {
|
||||
if (!item.Id || !item.Trickplay) return null;
|
||||
|
||||
const trickplayInfo = useMemo(() => {
|
||||
if (!enabled || !item.Id || !item.Trickplay) {
|
||||
return null;
|
||||
}
|
||||
const mediaSourceId = item.Id;
|
||||
const trickplayDataForSource = item.Trickplay[mediaSourceId];
|
||||
|
||||
const mediaSourceId = item.Id;
|
||||
const trickplayData = item.Trickplay[mediaSourceId];
|
||||
if (!trickplayDataForSource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!trickplayData) {
|
||||
return null;
|
||||
}
|
||||
const firstResolution = Object.keys(trickplayDataForSource)[0];
|
||||
if (!firstResolution) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the first available resolution
|
||||
const firstResolution = Object.keys(trickplayData)[0];
|
||||
return firstResolution
|
||||
? {
|
||||
resolution: firstResolution,
|
||||
aspectRatio:
|
||||
trickplayData[firstResolution].Width! /
|
||||
trickplayData[firstResolution].Height!,
|
||||
data: trickplayData[firstResolution],
|
||||
}
|
||||
: null;
|
||||
}, [item, enabled]);
|
||||
const data = trickplayDataForSource[firstResolution];
|
||||
const { Interval, TileWidth, TileHeight, Width, Height } = data;
|
||||
|
||||
// Takes in ticks.
|
||||
const calculateTrickplayUrl = useCallback(
|
||||
(progress: number) => {
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!Interval ||
|
||||
!TileWidth ||
|
||||
!TileHeight ||
|
||||
!Width ||
|
||||
!Height ||
|
||||
!item.RunTimeTicks
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastCalculationTime.current < throttleDelay) {
|
||||
return null;
|
||||
}
|
||||
lastCalculationTime.current = now;
|
||||
|
||||
if (!trickplayInfo || !api || !item.Id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data, resolution } = trickplayInfo;
|
||||
const { Interval, TileWidth, TileHeight, Width, Height } = data;
|
||||
|
||||
if (
|
||||
!Interval ||
|
||||
!TileWidth ||
|
||||
!TileHeight ||
|
||||
!resolution ||
|
||||
!Width ||
|
||||
!Height
|
||||
) {
|
||||
throw new Error("Invalid trickplay data");
|
||||
}
|
||||
|
||||
const currentTimeMs = Math.max(0, ticksToMs(progress));
|
||||
const currentTile = Math.floor(currentTimeMs / Interval);
|
||||
|
||||
const tileSize = TileWidth * TileHeight;
|
||||
const tileOffset = currentTile % tileSize;
|
||||
const index = Math.floor(currentTile / tileSize);
|
||||
|
||||
const tileOffsetX = tileOffset % TileWidth;
|
||||
const tileOffsetY = Math.floor(tileOffset / TileWidth);
|
||||
|
||||
const newTrickPlayUrl = {
|
||||
x: tileOffsetX,
|
||||
y: tileOffsetY,
|
||||
url: `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${index}.jpg?api_key=${api.accessToken}`,
|
||||
};
|
||||
|
||||
setTrickPlayUrl(newTrickPlayUrl);
|
||||
return newTrickPlayUrl;
|
||||
},
|
||||
[trickplayInfo, item, api, enabled],
|
||||
);
|
||||
|
||||
const prefetchAllTrickplayImages = useCallback(() => {
|
||||
if (!api || !enabled || !trickplayInfo || !item.Id || !item.RunTimeTicks) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, resolution } = trickplayInfo;
|
||||
const { Interval, TileWidth, TileHeight, Width, Height } = data;
|
||||
|
||||
if (
|
||||
!Interval ||
|
||||
!TileWidth ||
|
||||
!TileHeight ||
|
||||
!resolution ||
|
||||
!Width ||
|
||||
!Height
|
||||
) {
|
||||
throw new Error("Invalid trickplay data");
|
||||
}
|
||||
|
||||
// Calculate tiles per sheet
|
||||
const tilesPerRow = TileWidth;
|
||||
const tilesPerColumn = TileHeight;
|
||||
const tilesPerSheet = tilesPerRow * tilesPerColumn;
|
||||
const totalTiles = Math.ceil(ticksToMs(item.RunTimeTicks) / Interval);
|
||||
const totalIndexes = Math.ceil(totalTiles / tilesPerSheet);
|
||||
|
||||
// Prefetch all trickplay images
|
||||
for (let index = 0; index < totalIndexes; index++) {
|
||||
const url = `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${index}.jpg?api_key=${api.accessToken}`;
|
||||
Image.prefetch(url);
|
||||
}
|
||||
}, [trickplayInfo, item, api, enabled]);
|
||||
const tilesPerSheet = TileWidth * TileHeight;
|
||||
const totalTiles = Math.ceil(ticksToMs(item.RunTimeTicks) / Interval);
|
||||
const totalImageSheets = Math.ceil(totalTiles / tilesPerSheet);
|
||||
|
||||
return {
|
||||
trickPlayUrl: enabled ? trickPlayUrl : null,
|
||||
calculateTrickplayUrl: enabled ? calculateTrickplayUrl : () => null,
|
||||
prefetchAllTrickplayImages: enabled
|
||||
? prefetchAllTrickplayImages
|
||||
: () => null,
|
||||
trickplayInfo: enabled ? trickplayInfo : null,
|
||||
resolution: firstResolution,
|
||||
aspectRatio: Width / Height,
|
||||
data,
|
||||
totalImageSheets,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the specific image sheet and tile offset for a given time.
|
||||
* @param progressTicks The current playback time in ticks.
|
||||
* @param trickplayInfo The parsed trickplay information object.
|
||||
* @returns An object with the image sheet index, and the X/Y coordinates for the tile.
|
||||
*/
|
||||
const calculateTrickplayTile = (
|
||||
progressTicks: number,
|
||||
trickplayInfo: TrickplayInfo,
|
||||
) => {
|
||||
const { data } = trickplayInfo;
|
||||
const { Interval, TileWidth, TileHeight } = data;
|
||||
|
||||
if (!Interval || !TileWidth || !TileHeight) {
|
||||
throw new Error("Invalid trickplay data provided to calculateTile");
|
||||
}
|
||||
|
||||
const currentTimeMs = Math.max(0, ticksToMs(progressTicks));
|
||||
const currentTile = Math.floor(currentTimeMs / Interval);
|
||||
|
||||
const tilesPerSheet = TileWidth * TileHeight;
|
||||
const sheetIndex = Math.floor(currentTile / tilesPerSheet);
|
||||
const tileIndexInSheet = currentTile % tilesPerSheet;
|
||||
|
||||
const x = tileIndexInSheet % TileWidth;
|
||||
const y = Math.floor(tileIndexInSheet / TileWidth);
|
||||
|
||||
return { sheetIndex, x, y };
|
||||
};
|
||||
|
||||
81
hooks/useTwoWaySync.ts
Normal file
81
hooks/useTwoWaySync.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useNetInfo } from "@react-native-community/netinfo";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "../providers/JellyfinProvider";
|
||||
import { usePlaybackManager } from "./usePlaybackManager";
|
||||
|
||||
/**
|
||||
* This hook is used to sync the playback state of a downloaded item with the server
|
||||
* when the application comes back online after being used offline.
|
||||
*/
|
||||
export const useTwoWaySync = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const netInfo = useNetInfo();
|
||||
const { getDownloadedItemById, updateDownloadedItem } = useDownload();
|
||||
const { reportPlaybackProgress, markItemUnplayed, markItemPlayed } =
|
||||
usePlaybackManager();
|
||||
|
||||
/**
|
||||
* Syncs the playback state of an offline item with the server.
|
||||
* It determines if the local or remote state is more recent and applies the necessary update.
|
||||
*
|
||||
* @returns A Promise<boolean> indicating whether a server update was made (true) or not (false).
|
||||
*/
|
||||
const syncPlaybackState = async (itemId: string): Promise<boolean> => {
|
||||
if (!api || !user || !netInfo.isConnected) {
|
||||
// Cannot sync if offline or not logged in
|
||||
return false;
|
||||
}
|
||||
|
||||
const localItem = getDownloadedItemById(itemId);
|
||||
if (!localItem) return false;
|
||||
|
||||
const remoteItem = (
|
||||
await getUserLibraryApi(api).getItem({ itemId, userId: user.Id })
|
||||
).data;
|
||||
if (!remoteItem) return false;
|
||||
|
||||
const localLastPlayed = localItem.item.UserData?.LastPlayedDate
|
||||
? new Date(localItem.item.UserData.LastPlayedDate)
|
||||
: new Date(0);
|
||||
const remoteLastPlayed = remoteItem.UserData?.LastPlayedDate
|
||||
? new Date(remoteItem.UserData.LastPlayedDate)
|
||||
: new Date(0);
|
||||
|
||||
// If the remote item has been played more recently, we take the server's version as the source of truth.
|
||||
if (remoteLastPlayed > localLastPlayed) {
|
||||
updateDownloadedItem(itemId, {
|
||||
...localItem,
|
||||
item: {
|
||||
...localItem.item,
|
||||
UserData: {
|
||||
...localItem.item.UserData,
|
||||
LastPlayedDate: remoteItem.UserData?.LastPlayedDate,
|
||||
PlaybackPositionTicks: remoteItem.UserData?.PlaybackPositionTicks,
|
||||
Played: remoteItem.UserData?.Played,
|
||||
PlayedPercentage: remoteItem.UserData?.PlayedPercentage,
|
||||
},
|
||||
},
|
||||
});
|
||||
return false;
|
||||
} else if (remoteLastPlayed < localLastPlayed) {
|
||||
// Since we're this is the source of truth, essentially need to make sure the played status matches the local item.
|
||||
await getItemsApi(api).updateItemUserData({
|
||||
itemId: localItem.item.Id!,
|
||||
userId: user.Id,
|
||||
updateUserItemDataDto: {
|
||||
Played: localItem.item.UserData?.Played,
|
||||
PlaybackPositionTicks: localItem.item.UserData?.PlaybackPositionTicks,
|
||||
PlayedPercentage: localItem.item.UserData?.PlayedPercentage,
|
||||
LastPlayedDate: localItem.item.UserData?.LastPlayedDate,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return { syncPlaybackState };
|
||||
};
|
||||
@@ -9,6 +9,38 @@ interface UseWebSocketProps {
|
||||
togglePlay: () => void;
|
||||
stopPlayback: () => void;
|
||||
offline: boolean;
|
||||
|
||||
nextTrack?: () => void;
|
||||
previousTrack?: () => void;
|
||||
rewindPlayback?: () => void;
|
||||
fastForwardPlayback?: () => void;
|
||||
seekPlayback?: (positionTicks: number) => void;
|
||||
volumeUp?: () => void;
|
||||
volumeDown?: () => void;
|
||||
toggleMute?: () => void;
|
||||
toggleOsd?: () => void;
|
||||
toggleFullscreen?: () => void;
|
||||
goHome?: () => void;
|
||||
goToSettings?: () => void;
|
||||
setAudioStreamIndex?: (index: number) => void;
|
||||
setSubtitleStreamIndex?: (index: number) => void;
|
||||
|
||||
moveUp?: () => void;
|
||||
moveDown?: () => void;
|
||||
moveLeft?: () => void;
|
||||
moveRight?: () => void;
|
||||
select?: () => void;
|
||||
pageUp?: () => void;
|
||||
pageDown?: () => void;
|
||||
setVolume?: (volume: number) => void;
|
||||
setRepeatMode?: (mode: string) => void;
|
||||
setShuffleMode?: (mode: string) => void;
|
||||
togglePictureInPicture?: () => void;
|
||||
takeScreenshot?: () => void;
|
||||
sendString?: (text: string) => void;
|
||||
sendKey?: (key: string) => void;
|
||||
playMediaSource?: (itemIds: string[], startPositionTicks?: number) => void;
|
||||
playTrailers?: (itemId: string) => void;
|
||||
}
|
||||
|
||||
export const useWebSocket = ({
|
||||
@@ -16,38 +48,270 @@ export const useWebSocket = ({
|
||||
togglePlay,
|
||||
stopPlayback,
|
||||
offline,
|
||||
nextTrack,
|
||||
previousTrack,
|
||||
rewindPlayback,
|
||||
fastForwardPlayback,
|
||||
seekPlayback,
|
||||
volumeUp,
|
||||
volumeDown,
|
||||
toggleMute,
|
||||
toggleOsd,
|
||||
toggleFullscreen,
|
||||
goHome,
|
||||
goToSettings,
|
||||
setAudioStreamIndex,
|
||||
setSubtitleStreamIndex,
|
||||
moveUp,
|
||||
moveDown,
|
||||
moveLeft,
|
||||
moveRight,
|
||||
select,
|
||||
pageUp,
|
||||
pageDown,
|
||||
setVolume,
|
||||
setRepeatMode,
|
||||
setShuffleMode,
|
||||
togglePictureInPicture,
|
||||
takeScreenshot,
|
||||
sendString,
|
||||
sendKey,
|
||||
playMediaSource,
|
||||
playTrailers,
|
||||
}: UseWebSocketProps) => {
|
||||
const router = useRouter();
|
||||
const { ws } = useWebSocketContext();
|
||||
const { lastMessage } = useWebSocketContext();
|
||||
const { t } = useTranslation();
|
||||
const { clearLastMessage } = useWebSocketContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!ws) return;
|
||||
if (!lastMessage) return;
|
||||
if (offline) return;
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
const json = JSON.parse(e.data);
|
||||
const command = json?.Data?.Command;
|
||||
const messageType = lastMessage.MessageType;
|
||||
const command: string | undefined =
|
||||
lastMessage?.Data?.Command || lastMessage?.Data?.Name;
|
||||
|
||||
console.log("[WS] ~ ", json);
|
||||
const args = lastMessage?.Data?.Arguments as
|
||||
| Record<string, string>
|
||||
| undefined; // Arguments are Dictionary<string, string>
|
||||
|
||||
if (command === "PlayPause") {
|
||||
console.log("Command ~ PlayPause");
|
||||
console.log("[WS] ~ ", lastMessage);
|
||||
|
||||
if (command === "PlayPause") {
|
||||
console.log("Command ~ PlayPause");
|
||||
togglePlay();
|
||||
} else if (command === "Stop") {
|
||||
console.log("Command ~ Stop");
|
||||
stopPlayback();
|
||||
router.canGoBack() && router.back();
|
||||
} else if (command === "Pause") {
|
||||
console.log("Command ~ Pause");
|
||||
if (isPlaying) {
|
||||
togglePlay();
|
||||
} else if (command === "Stop") {
|
||||
console.log("Command ~ Stop");
|
||||
stopPlayback();
|
||||
router.canGoBack() && router.back();
|
||||
} else if (json?.Data?.Name === "DisplayMessage") {
|
||||
console.log("Command ~ DisplayMessage");
|
||||
const title = json?.Data?.Arguments?.Header;
|
||||
const body = json?.Data?.Arguments?.Text;
|
||||
Alert.alert(t("player.message_from_server", { message: title }), body);
|
||||
}
|
||||
};
|
||||
} else if (command === "Unpause") {
|
||||
console.log("Command ~ Unpause");
|
||||
if (!isPlaying) {
|
||||
togglePlay();
|
||||
}
|
||||
} else if (command === "NextTrack") {
|
||||
console.log("Command ~ NextTrack");
|
||||
nextTrack?.();
|
||||
} else if (command === "PreviousTrack") {
|
||||
console.log("Command ~ PreviousTrack");
|
||||
previousTrack?.();
|
||||
} else if (command === "Rewind") {
|
||||
console.log("Command ~ Rewind");
|
||||
rewindPlayback?.();
|
||||
} else if (command === "FastForward") {
|
||||
console.log("Command ~ FastForward");
|
||||
fastForwardPlayback?.();
|
||||
} else if (command === "Seek") {
|
||||
const positionStr = args?.SeekPositionTicks;
|
||||
console.log("Command ~ Seek", { positionStr });
|
||||
if (positionStr) {
|
||||
const position = Number.parseInt(positionStr, 10);
|
||||
if (!Number.isNaN(position)) {
|
||||
seekPlayback?.(position);
|
||||
}
|
||||
}
|
||||
} else if (command === "Back") {
|
||||
console.log("Command ~ Back");
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
}
|
||||
} else if (command === "GoHome") {
|
||||
console.log("Command ~ GoHome");
|
||||
goHome ? goHome() : router.push("/");
|
||||
} else if (command === "GoToSettings") {
|
||||
console.log("Command ~ GoToSettings");
|
||||
goToSettings ? goToSettings() : router.push("/settings");
|
||||
} else if (command === "VolumeUp") {
|
||||
console.log("Command ~ VolumeUp");
|
||||
volumeUp?.();
|
||||
} else if (command === "VolumeDown") {
|
||||
console.log("Command ~ VolumeDown");
|
||||
volumeDown?.();
|
||||
} else if (command === "ToggleMute") {
|
||||
console.log("Command ~ ToggleMute");
|
||||
|
||||
return () => {
|
||||
ws.onmessage = null;
|
||||
};
|
||||
}, [ws, stopPlayback, togglePlay, isPlaying, router]);
|
||||
toggleMute?.();
|
||||
} else if (command === "ToggleOsd") {
|
||||
console.log("Command ~ ToggleOsd");
|
||||
toggleOsd?.();
|
||||
} else if (command === "ToggleFullscreen") {
|
||||
console.log("Command ~ ToggleFullscreen");
|
||||
toggleFullscreen?.();
|
||||
} else if (command === "SetAudioStreamIndex") {
|
||||
const indexStr = args?.Index;
|
||||
console.log("Command ~ SetAudioStreamIndex", { indexStr });
|
||||
if (indexStr) {
|
||||
const index = Number.parseInt(indexStr, 10);
|
||||
if (!Number.isNaN(index)) {
|
||||
setAudioStreamIndex?.(index);
|
||||
}
|
||||
}
|
||||
} else if (command === "SetSubtitleStreamIndex") {
|
||||
const indexStr = args?.Index;
|
||||
console.log("Command ~ SetSubtitleStreamIndex", { indexStr });
|
||||
if (indexStr) {
|
||||
const index = Number.parseInt(indexStr, 10);
|
||||
if (!Number.isNaN(index)) {
|
||||
setSubtitleStreamIndex?.(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Neue Befehle hier implementieren
|
||||
else if (command === "MoveUp") {
|
||||
console.log("Command ~ MoveUp");
|
||||
moveUp?.();
|
||||
} else if (command === "MoveDown") {
|
||||
console.log("Command ~ MoveDown");
|
||||
moveDown?.();
|
||||
} else if (command === "MoveLeft") {
|
||||
console.log("Command ~ MoveLeft");
|
||||
moveLeft?.();
|
||||
} else if (command === "MoveRight") {
|
||||
console.log("Command ~ MoveRight");
|
||||
moveRight?.();
|
||||
} else if (command === "Select") {
|
||||
console.log("Command ~ Select");
|
||||
select?.();
|
||||
} else if (command === "PageUp") {
|
||||
console.log("Command ~ PageUp");
|
||||
pageUp?.();
|
||||
} else if (command === "PageDown") {
|
||||
console.log("Command ~ PageDown");
|
||||
pageDown?.();
|
||||
} else if (command === "SetVolume") {
|
||||
const volumeStr = args?.Volume;
|
||||
console.log("Command ~ SetVolume", { volumeStr });
|
||||
if (volumeStr) {
|
||||
const volumeValue = Number.parseInt(volumeStr, 10);
|
||||
if (!Number.isNaN(volumeValue)) {
|
||||
setVolume?.(volumeValue);
|
||||
}
|
||||
}
|
||||
} else if (command === "SetRepeatMode") {
|
||||
const mode = args?.Mode;
|
||||
console.log("Command ~ SetRepeatMode", { mode });
|
||||
if (mode) {
|
||||
setRepeatMode?.(mode);
|
||||
}
|
||||
} else if (command === "SetShuffleMode") {
|
||||
const mode = args?.Mode;
|
||||
console.log("Command ~ SetShuffleMode", { mode });
|
||||
if (mode) {
|
||||
setShuffleMode?.(mode);
|
||||
}
|
||||
} else if (command === "TogglePictureInPicture") {
|
||||
console.log("Command ~ TogglePictureInPicture");
|
||||
togglePictureInPicture?.();
|
||||
} else if (command === "TakeScreenshot") {
|
||||
console.log("Command ~ TakeScreenshot");
|
||||
takeScreenshot?.();
|
||||
} else if (command === "SendString") {
|
||||
const text = args?.Text;
|
||||
console.log("Command ~ SendString", { text });
|
||||
if (text) {
|
||||
sendString?.(text);
|
||||
}
|
||||
} else if (command === "SendKey") {
|
||||
const key = args?.Key;
|
||||
console.log("Command ~ SendKey", { key });
|
||||
if (key) {
|
||||
sendKey?.(key);
|
||||
}
|
||||
} else if (command === "PlayMediaSource") {
|
||||
const itemIdsStr = args?.ItemIds;
|
||||
const startPositionTicksStr = args?.StartPositionTicks;
|
||||
console.log("Command ~ PlayMediaSource", {
|
||||
itemIdsStr,
|
||||
startPositionTicksStr,
|
||||
});
|
||||
if (itemIdsStr) {
|
||||
const itemIds = itemIdsStr.split(",");
|
||||
let startPositionTicks: number | undefined = undefined;
|
||||
if (startPositionTicksStr) {
|
||||
const parsedTicks = Number.parseInt(startPositionTicksStr, 10);
|
||||
if (!Number.isNaN(parsedTicks)) {
|
||||
startPositionTicks = parsedTicks;
|
||||
}
|
||||
}
|
||||
playMediaSource?.(itemIds, startPositionTicks);
|
||||
}
|
||||
} else if (command === "PlayTrailers") {
|
||||
const itemId = args?.ItemId;
|
||||
console.log("Command ~ PlayTrailers", { itemId });
|
||||
if (itemId) {
|
||||
playTrailers?.(itemId);
|
||||
}
|
||||
} else if (command === "DisplayMessage") {
|
||||
console.log("Command ~ DisplayMessage");
|
||||
const title = args?.Header;
|
||||
const body = args?.Text;
|
||||
Alert.alert(t("player.message_from_server", { message: title }), body);
|
||||
}
|
||||
clearLastMessage();
|
||||
}, [
|
||||
lastMessage,
|
||||
offline,
|
||||
isPlaying,
|
||||
togglePlay,
|
||||
stopPlayback,
|
||||
router,
|
||||
nextTrack,
|
||||
previousTrack,
|
||||
rewindPlayback,
|
||||
fastForwardPlayback,
|
||||
seekPlayback,
|
||||
volumeUp,
|
||||
volumeDown,
|
||||
toggleMute,
|
||||
toggleOsd,
|
||||
toggleFullscreen,
|
||||
goHome,
|
||||
goToSettings,
|
||||
setAudioStreamIndex,
|
||||
setSubtitleStreamIndex,
|
||||
moveUp,
|
||||
moveDown,
|
||||
moveLeft,
|
||||
moveRight,
|
||||
select,
|
||||
pageUp,
|
||||
pageDown,
|
||||
setVolume,
|
||||
setRepeatMode,
|
||||
setShuffleMode,
|
||||
togglePictureInPicture,
|
||||
takeScreenshot,
|
||||
sendString,
|
||||
sendKey,
|
||||
playMediaSource,
|
||||
playTrailers,
|
||||
t,
|
||||
clearLastMessage,
|
||||
]);
|
||||
};
|
||||
|
||||
18
i18n.ts
18
i18n.ts
@@ -1,9 +1,9 @@
|
||||
import { getLocales } from "expo-localization";
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import { getLocales } from "expo-localization";
|
||||
import de from "./translations/de.json";
|
||||
import en from "./translations/en.json";
|
||||
import eo from "./translations/eo.json";
|
||||
import es from "./translations/es.json";
|
||||
import fr from "./translations/fr.json";
|
||||
import it from "./translations/it.json";
|
||||
@@ -11,10 +11,11 @@ import ja from "./translations/ja.json";
|
||||
import nl from "./translations/nl.json";
|
||||
import pl from "./translations/pl.json";
|
||||
import ptBR from "./translations/pt-BR.json";
|
||||
import sv from "./translations/sv.json";
|
||||
import ru from "./translations/ru.json";
|
||||
import sv from "./translations/sv.json";
|
||||
import tlh from "./translations/tlh.json";
|
||||
import tr from "./translations/tr.json";
|
||||
import ua from "./translations/ua.json";
|
||||
import uk from "./translations/uk.json";
|
||||
import zhCN from "./translations/zh-CN.json";
|
||||
import zhTW from "./translations/zh-TW.json";
|
||||
|
||||
@@ -22,16 +23,19 @@ export const APP_LANGUAGES = [
|
||||
{ label: "Deutsch", value: "de" },
|
||||
{ label: "English", value: "en" },
|
||||
{ label: "Español", value: "es" },
|
||||
{ label: "Esperanto", value: "eo" },
|
||||
{ label: "Français", value: "fr" },
|
||||
{ label: "Italiano", value: "it" },
|
||||
{ label: "日本語", value: "ja" },
|
||||
{ label: "Klingon", value: "tlh" },
|
||||
{ label: "Türkçe", value: "tr" },
|
||||
{ label: "Nederlands", value: "nl" },
|
||||
{ label: "Polski", value: "pl" },
|
||||
{ label: "Português (Brasil)", value: "pt-BR" },
|
||||
{ label: "Svenska", value: "sv" },
|
||||
{ label: "Русский", value: "ru" },
|
||||
{ label: "Українська", value: "ua" },
|
||||
{ label: "Українська", value: "uk" },
|
||||
{ label: "Українська", value: "uk" },
|
||||
{ label: "简体中文", value: "zh-CN" },
|
||||
{ label: "繁體中文", value: "zh-TW" },
|
||||
];
|
||||
@@ -42,6 +46,7 @@ i18n.use(initReactI18next).init({
|
||||
de: { translation: de },
|
||||
en: { translation: en },
|
||||
es: { translation: es },
|
||||
eo: { translation: eo },
|
||||
fr: { translation: fr },
|
||||
it: { translation: it },
|
||||
ja: { translation: ja },
|
||||
@@ -51,7 +56,8 @@ i18n.use(initReactI18next).init({
|
||||
sv: { translation: sv },
|
||||
ru: { translation: ru },
|
||||
tr: { translation: tr },
|
||||
ua: { translation: ua },
|
||||
tlh: { translation: tlh },
|
||||
uk: { translation: uk },
|
||||
"zh-CN": { translation: zhCN },
|
||||
"zh-TW": { translation: zhTW },
|
||||
},
|
||||
|
||||
@@ -41,10 +41,10 @@ export type VlcPlayerSource = {
|
||||
type?: string;
|
||||
isNetwork?: boolean;
|
||||
autoplay?: boolean;
|
||||
externalSubtitles: { name: string; DeliveryUrl: string }[];
|
||||
startPosition?: number;
|
||||
externalSubtitles?: { name: string; DeliveryUrl: string }[];
|
||||
initOptions?: any[];
|
||||
mediaOptions?: { [key: string]: any };
|
||||
startPosition?: number;
|
||||
};
|
||||
|
||||
export type TrackInfo = {
|
||||
@@ -94,5 +94,5 @@ export interface VlcPlayerViewRef {
|
||||
getChapters: () => Promise<ChapterInfo[] | null>;
|
||||
setVideoCropGeometry: (geometry: string | null) => Promise<void>;
|
||||
getVideoCropGeometry: () => Promise<string | null>;
|
||||
setSubtitleURL: (url: string, name: string) => Promise<void>;
|
||||
setSubtitleURL: (url: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { requireNativeViewManager } from "expo-modules-core";
|
||||
import * as React from "react";
|
||||
|
||||
import { VideoPlayer, useSettings } from "@/utils/atoms/settings";
|
||||
import { Platform, ViewStyle } from "react-native";
|
||||
import { ViewStyle } from "react-native";
|
||||
import type {
|
||||
VlcPlayerSource,
|
||||
VlcPlayerViewProps,
|
||||
@@ -13,22 +11,12 @@ interface NativeViewRef extends VlcPlayerViewRef {
|
||||
setNativeProps?: (props: Partial<VlcPlayerViewProps>) => void;
|
||||
}
|
||||
|
||||
const VLCViewManager = requireNativeViewManager("VlcPlayer");
|
||||
const VLC3ViewManager = requireNativeViewManager("VlcPlayer3");
|
||||
|
||||
// Create a forwarded ref version of the native view
|
||||
const NativeView = React.forwardRef<NativeViewRef, VlcPlayerViewProps>(
|
||||
(props, ref) => {
|
||||
const [settings] = useSettings();
|
||||
|
||||
if (Platform.OS === "ios" || Platform.isTVOS) {
|
||||
if (settings.defaultPlayer === VideoPlayer.VLC_3) {
|
||||
console.log("[Apple] Using Vlc Player 3");
|
||||
return <VLC3ViewManager {...props} ref={ref} />;
|
||||
}
|
||||
}
|
||||
console.log("Using default Vlc Player");
|
||||
return <VLCViewManager {...props} ref={ref} />;
|
||||
return <VLC3ViewManager {...props} ref={ref} />;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -95,8 +83,8 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
||||
const geometry = await nativeRef.current?.getVideoCropGeometry();
|
||||
return geometry ?? null;
|
||||
},
|
||||
setSubtitleURL: async (url: string, name: string) => {
|
||||
await nativeRef.current?.setSubtitleURL(url, name);
|
||||
setSubtitleURL: async (url: string) => {
|
||||
await nativeRef.current?.setSubtitleURL(url);
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -54,6 +54,10 @@ public class VlcPlayer3Module: Module {
|
||||
return view.getAudioTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleURL") { (view: VlcPlayer3View, url: String, name: String) in
|
||||
view.setSubtitleURL(url, name: name)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleTrack") { (view: VlcPlayer3View, trackIndex: Int) in
|
||||
view.setSubtitleTrack(trackIndex)
|
||||
}
|
||||
@@ -61,11 +65,6 @@ public class VlcPlayer3Module: Module {
|
||||
AsyncFunction("getSubtitleTracks") { (view: VlcPlayer3View) -> [[String: Any]]? in
|
||||
return view.getSubtitleTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleURL") {
|
||||
(view: VlcPlayer3View, url: String, name: String) in
|
||||
view.setSubtitleURL(url, name: name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ class VlcPlayer3View: ExpoView {
|
||||
private var isStopping: Bool = false // Define isStopping here
|
||||
private var lastProgressCall = Date().timeIntervalSince1970
|
||||
var hasSource = false
|
||||
var isTranscoding = false
|
||||
private var initialSeekPerformed: Bool = false
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
@@ -88,7 +90,6 @@ class VlcPlayer3View: ExpoView {
|
||||
// If the specified time is greater than the duration, seek to the end
|
||||
let seekTime = time > duration ? duration - 1000 : time
|
||||
player.time = VLCTime(int: seekTime)
|
||||
|
||||
if wasPlaying {
|
||||
self.play()
|
||||
}
|
||||
@@ -110,13 +111,18 @@ class VlcPlayer3View: ExpoView {
|
||||
var initOptions = source["initOptions"] as? [Any] ?? []
|
||||
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
||||
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
|
||||
initOptions.append("--start-time=\(self.startPosition)")
|
||||
|
||||
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
||||
print("Error: Invalid or empty URI")
|
||||
self.onVideoError?(["error": "Invalid or empty URI"])
|
||||
return
|
||||
}
|
||||
|
||||
self.isTranscoding = uri.contains("m3u8")
|
||||
|
||||
if !self.isTranscoding, self.startPosition > 0 {
|
||||
initOptions.append("--start-time=\(self.startPosition)")
|
||||
}
|
||||
|
||||
let autoplay = source["autoplay"] as? Bool ?? false
|
||||
let isNetwork = source["isNetwork"] as? Bool ?? false
|
||||
@@ -126,6 +132,7 @@ class VlcPlayer3View: ExpoView {
|
||||
self.mediaPlayer?.delegate = self
|
||||
self.mediaPlayer?.drawable = self.videoView
|
||||
self.mediaPlayer?.scaleFactor = 0
|
||||
self.initialSeekPerformed = false
|
||||
|
||||
let media: VLCMedia
|
||||
if isNetwork {
|
||||
@@ -287,9 +294,14 @@ class VlcPlayer3View: ExpoView {
|
||||
|
||||
let currentTimeMs = player.time.intValue
|
||||
let durationMs = player.media?.length.intValue ?? 0
|
||||
|
||||
|
||||
print("Debug: Current time: \(currentTimeMs)")
|
||||
if currentTimeMs >= 0 && currentTimeMs < durationMs {
|
||||
if self.isTranscoding, !self.initialSeekPerformed, self.startPosition > 0 {
|
||||
player.time = VLCTime(int: self.startPosition * 1000)
|
||||
self.initialSeekPerformed = true
|
||||
}
|
||||
self.onVideoProgress?([
|
||||
"currentTime": currentTimeMs,
|
||||
"duration": durationMs,
|
||||
|
||||
@@ -29,7 +29,7 @@ if (useManagedAndroidSdkVersions) {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath "com.android.tools.build:gradle:7.1.3"
|
||||
classpath "com.android.tools.build:gradle:8.11.0"
|
||||
}
|
||||
}
|
||||
project.android {
|
||||
|
||||
@@ -12,7 +12,6 @@ Pod::Spec.new do |s|
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.ios.dependency 'VLCKit', s.version
|
||||
s.tvos.dependency 'VLCKit', s.version
|
||||
s.dependency 'Alamofire', '~> 5.10'
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
|
||||
@@ -137,10 +137,7 @@ extension VLCPlayerWrapper: VLCMediaPlayerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VLCMediaDelegate
|
||||
extension VLCPlayerWrapper: VLCMediaDelegate {
|
||||
// Implement VLCMediaDelegate methods if needed
|
||||
}
|
||||
|
||||
|
||||
class VlcPlayerView: ExpoView {
|
||||
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayerView")
|
||||
@@ -154,6 +151,10 @@ class VlcPlayerView: ExpoView {
|
||||
private var isStopping: Bool = false // Define isStopping here
|
||||
private var externalSubtitles: [[String: String]]?
|
||||
var hasSource = false
|
||||
var initialSeekPerformed = false
|
||||
// A flag variable determinging if we should perform the initial seek. Its either transcoding or offline playback. that makes
|
||||
var shouldPerformInitialSeek: Bool = false
|
||||
|
||||
|
||||
// MARK: - Initialization
|
||||
required init(appContext: AppContext? = nil) {
|
||||
@@ -172,6 +173,19 @@ class VlcPlayerView: ExpoView {
|
||||
)
|
||||
}
|
||||
|
||||
// Workaround: When playing an HLS video for the first time, seeking to a specific time immediately can cause a crash.
|
||||
// To avoid this, we wait until the video has started playing before performing the initial seek.
|
||||
func performInitialSeek() {
|
||||
guard !initialSeekPerformed,
|
||||
startPosition > 0,
|
||||
shouldPerformInitialSeek,
|
||||
vlc.player.isSeekable else { return }
|
||||
|
||||
initialSeekPerformed = true
|
||||
logger.debug("First time update, performing initial seek to \(self.startPosition) seconds")
|
||||
vlc.player.time = VLCTime(int: startPosition * 1000)
|
||||
}
|
||||
|
||||
private func setupNotifications() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(applicationWillResignActive),
|
||||
@@ -254,6 +268,8 @@ class VlcPlayerView: ExpoView {
|
||||
let autoplay = source["autoplay"] as? Bool ?? false
|
||||
let isNetwork = source["isNetwork"] as? Bool ?? false
|
||||
|
||||
// Set shouldPeformIntial based on isTranscoding and is not a network stream
|
||||
self.shouldPerformInitialSeek = uri.contains("m3u8") || !isNetwork
|
||||
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
|
||||
|
||||
let media: VLCMedia!
|
||||
@@ -277,8 +293,11 @@ class VlcPlayerView: ExpoView {
|
||||
self.hasSource = true
|
||||
if autoplay {
|
||||
logger.info("Playing...")
|
||||
// The Video is not transcoding so it its safe to seek to the start position.
|
||||
if !self.shouldPerformInitialSeek {
|
||||
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
|
||||
}
|
||||
self.play()
|
||||
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -415,6 +434,9 @@ class VlcPlayerView: ExpoView {
|
||||
|
||||
private func updatePlayerState() {
|
||||
let player = self.vlc.player
|
||||
if player.isPlaying {
|
||||
performInitialSeek()
|
||||
}
|
||||
self.onVideoStateChange?([
|
||||
"target": self.reactTag ?? NSNull(),
|
||||
"currentTime": player.time.intValue,
|
||||
|
||||
87
package.json
87
package.json
@@ -6,21 +6,21 @@
|
||||
"submodule-reload": "git submodule update --init --remote --recursive",
|
||||
"clean": "echo y | expo prebuild --clean",
|
||||
"start": "bun run submodule-reload && expo start",
|
||||
"ios": "EXPO_TV=0 expo run:ios",
|
||||
"ios:tv": "EXPO_TV=1 expo run:ios",
|
||||
"android": "EXPO_TV=0 expo run:android",
|
||||
"android:tv": "EXPO_TV=1 expo run:android",
|
||||
"prebuild": "EXPO_TV=0 bun run clean",
|
||||
"prebuild:tv": "EXPO_TV=1 bun run clean",
|
||||
"ios": "cross-env EXPO_TV=0 expo run:ios",
|
||||
"ios:tv": "cross-env EXPO_TV=1 expo run:ios",
|
||||
"android": "cross-env EXPO_TV=0 expo run:android",
|
||||
"android:tv": "cross-env EXPO_TV=1 expo run:android",
|
||||
"prebuild": "cross-env EXPO_TV=0 bun run clean",
|
||||
"prebuild:tv": "cross-env EXPO_TV=1 bun run clean",
|
||||
"build:android:local": "cd android && cross-env NODE_ENV=production ./gradlew assembleRelease",
|
||||
"prepare": "husky",
|
||||
"check": "biome check .",
|
||||
"lint": "biome check --write --unsafe"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bottom-tabs/react-navigation": "0.8.6",
|
||||
"@config-plugins/ffmpeg-kit-react-native": "^9.0.0",
|
||||
"@bottom-tabs/react-navigation": "0.9.2",
|
||||
"@expo/config-plugins": "~9.0.15",
|
||||
"@expo/react-native-action-sheet": "^4.1.0",
|
||||
"@expo/react-native-action-sheet": "^4.1.1",
|
||||
"@expo/vector-icons": "^14.0.4",
|
||||
"@futurejj/react-native-visibility-sensor": "^1.3.10",
|
||||
"@gorhom/bottom-sheet": "^5.1.0",
|
||||
@@ -35,65 +35,65 @@
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"add": "^2.0.6",
|
||||
"axios": "^1.7.9",
|
||||
"expo": "^52.0.31",
|
||||
"expo-asset": "~11.0.3",
|
||||
"expo-background-fetch": "~13.0.5",
|
||||
"expo": "~52.0.47",
|
||||
"expo-asset": "~11.0.5",
|
||||
"expo-background-fetch": "~13.0.6",
|
||||
"expo-blur": "~14.0.3",
|
||||
"expo-brightness": "~13.0.3",
|
||||
"expo-build-properties": "~0.13.2",
|
||||
"expo-constants": "~17.0.5",
|
||||
"expo-build-properties": "~0.13.3",
|
||||
"expo-constants": "~17.0.8",
|
||||
"expo-crypto": "~14.0.2",
|
||||
"expo-dev-client": "~5.0.11",
|
||||
"expo-device": "~7.0.2",
|
||||
"expo-dev-client": "~5.0.20",
|
||||
"expo-device": "~7.0.3",
|
||||
"expo-font": "~13.0.3",
|
||||
"expo-haptics": "~14.0.1",
|
||||
"expo-image": "~2.0.4",
|
||||
"expo-image": "~2.0.7",
|
||||
"expo-keep-awake": "~14.0.2",
|
||||
"expo-linear-gradient": "~14.0.2",
|
||||
"expo-linking": "~7.0.5",
|
||||
"expo-localization": "~16.0.1",
|
||||
"expo-network": "~7.0.5",
|
||||
"expo-notifications": "~0.29.13",
|
||||
"expo-router": "~4.0.17",
|
||||
"expo-notifications": "~0.29.14",
|
||||
"expo-router": "~4.0.21",
|
||||
"expo-screen-orientation": "~8.0.4",
|
||||
"expo-sensors": "~14.0.2",
|
||||
"expo-sharing": "~13.1.0",
|
||||
"expo-splash-screen": "~0.29.22",
|
||||
"expo-sharing": "~13.0.1",
|
||||
"expo-splash-screen": "~0.29.24",
|
||||
"expo-status-bar": "~2.0.1",
|
||||
"expo-system-ui": "~4.0.8",
|
||||
"expo-task-manager": "~12.0.5",
|
||||
"expo-updates": "~0.26.17",
|
||||
"expo-system-ui": "~4.0.9",
|
||||
"expo-task-manager": "~12.0.6",
|
||||
"expo-updates": "~0.27.4",
|
||||
"expo-web-browser": "~14.0.2",
|
||||
"i18next": "^24.2.2",
|
||||
"i18next": "^25.0.0",
|
||||
"jotai": "^2.11.3",
|
||||
"lodash": "^4.17.21",
|
||||
"nativewind": "^2.0.11",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "^15.4.0",
|
||||
"react-native": "npm:react-native-tvos@~0.77.0-0",
|
||||
"react-native": "npm:react-native-tvos@~0.77.2-0",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
"react-native-bottom-tabs": "0.8.6",
|
||||
"react-native-bottom-tabs": "0.9.2",
|
||||
"react-native-circular-progress": "^1.4.1",
|
||||
"react-native-collapsible": "^1.6.2",
|
||||
"react-native-compressor": "^1.10.3",
|
||||
"react-native-country-flag": "^2.0.2",
|
||||
"react-native-device-info": "^14.0.4",
|
||||
"react-native-edge-to-edge": "^1.4.3",
|
||||
"react-native-gesture-handler": "2.22.0",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react-native-get-random-values": "^1.11.0",
|
||||
"react-native-google-cast": "^4.8.3",
|
||||
"react-native-image-colors": "^2.4.0",
|
||||
"react-native-ios-context-menu": "^3.1.0",
|
||||
"react-native-ios-utilities": "5.1.1",
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
"react-native-pager-view": "6.5.1",
|
||||
"react-native-pager-view": "6.6.0",
|
||||
"react-native-progress": "^5.0.1",
|
||||
"react-native-reanimated": "~3.16.7",
|
||||
"react-native-reanimated-carousel": "3.5.1",
|
||||
"react-native-safe-area-context": "5.1.0",
|
||||
"react-native-screens": "~4.5.0",
|
||||
"react-native-svg": "15.11.1",
|
||||
"react-native-safe-area-context": "5.2.0",
|
||||
"react-native-screens": "4.10.0",
|
||||
"react-native-svg": "15.11.2",
|
||||
"react-native-tab-view": "^4.0.5",
|
||||
"react-native-udp": "^4.1.7",
|
||||
"react-native-uitextview": "^1.4.0",
|
||||
@@ -102,7 +102,7 @@
|
||||
"react-native-video": "6.10.0",
|
||||
"react-native-volume-manager": "^2.0.8",
|
||||
"react-native-web": "~0.19.13",
|
||||
"react-native-webview": "13.13.2",
|
||||
"react-native-webview": "13.13.0",
|
||||
"sonner-native": "^0.17.0",
|
||||
"tailwindcss": "3.3.2",
|
||||
"use-debounce": "^10.0.4",
|
||||
@@ -112,17 +112,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.8",
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@react-native-community/cli": "15.1.3",
|
||||
"@biomejs/biome": "^2.0.0",
|
||||
"@react-native-community/cli": "18.0.0",
|
||||
"@react-native-tvos/config-tv": "^0.1.1",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/react": "~18.3.12",
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"@types/react-test-renderer": "^19.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.5.0",
|
||||
"lint-staged": "^16.1.2",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"react-test-renderer": "19.0.0",
|
||||
"typescript": "~5.7.3"
|
||||
@@ -130,11 +131,17 @@
|
||||
"private": true,
|
||||
"expo": {
|
||||
"install": {
|
||||
"exclude": ["react-native"]
|
||||
"exclude": [
|
||||
"react-native"
|
||||
]
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": ["biome check --write --unsafe"],
|
||||
"*.{json,md}": ["biome format --write"]
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"biome check --write --unsafe --no-errors-on-unmatched"
|
||||
],
|
||||
"*.json": [
|
||||
"biome format --write"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,108 +0,0 @@
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import type { JobStatus } from "@/utils/optimize-server";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import * as Application from "expo-application";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { createContext, useCallback, useContext, useMemo } from "react";
|
||||
|
||||
export type DownloadedItem = {
|
||||
item: Partial<BaseItemDto>;
|
||||
mediaSource: MediaSourceInfo;
|
||||
};
|
||||
|
||||
export const processesAtom = atom<JobStatus[]>([]);
|
||||
|
||||
const DownloadContext = createContext<ReturnType<
|
||||
typeof useDownloadProvider
|
||||
> | null>(null);
|
||||
|
||||
/**
|
||||
* Dummy download provider for tvOS
|
||||
*/
|
||||
function useDownloadProvider() {
|
||||
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
|
||||
|
||||
const downloadedFiles: DownloadedItem[] = [];
|
||||
|
||||
const removeProcess = useCallback(async (id: string) => {}, []);
|
||||
|
||||
const startDownload = useCallback(async (process: JobStatus) => {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const startBackgroundDownload = useCallback(
|
||||
async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => {
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteAllFiles = async (): Promise<void> => {};
|
||||
|
||||
const deleteFile = async (id: string): Promise<void> => {};
|
||||
|
||||
const deleteItems = async (items: BaseItemDto[]) => {};
|
||||
|
||||
const cleanCacheDirectory = async () => {};
|
||||
|
||||
const deleteFileByType = async (type: BaseItemDto["Type"]) => {};
|
||||
|
||||
const appSizeUsage = useMemo(async () => {
|
||||
return 0;
|
||||
}, []);
|
||||
|
||||
function getDownloadedItem(itemId: string): DownloadedItem | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveDownloadedItemInfo(item: BaseItemDto, size = 0) {}
|
||||
|
||||
function getDownloadedItemSize(itemId: string): number {
|
||||
const size = storage.getString(`downloadedItemSize-${itemId}`);
|
||||
return size ? Number.parseInt(size) : 0;
|
||||
}
|
||||
|
||||
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`;
|
||||
|
||||
return {
|
||||
processes,
|
||||
startBackgroundDownload,
|
||||
downloadedFiles,
|
||||
deleteAllFiles,
|
||||
deleteFile,
|
||||
deleteItems,
|
||||
saveDownloadedItemInfo,
|
||||
removeProcess,
|
||||
setProcesses,
|
||||
startDownload,
|
||||
getDownloadedItem,
|
||||
deleteFileByType,
|
||||
appSizeUsage,
|
||||
getDownloadedItemSize,
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
||||
cleanCacheDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
||||
const downloadProviderValue = useDownloadProvider();
|
||||
|
||||
return (
|
||||
<DownloadContext.Provider value={downloadProviderValue}>
|
||||
{children}
|
||||
</DownloadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDownload() {
|
||||
const context = useContext(DownloadContext);
|
||||
if (context === null) {
|
||||
throw new Error("useDownload must be used within a DownloadProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
117
providers/Downloads/types.ts
Normal file
117
providers/Downloads/types.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Bitrate } from "@/components/BitrateSelector";
|
||||
|
||||
/**
|
||||
* Represents the data for downloaded trickplay files.
|
||||
*/
|
||||
export interface TrickPlayData {
|
||||
/** The local directory path where trickplay image sheets are stored. */
|
||||
path: string;
|
||||
/** The total size of all trickplay images in bytes. */
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the user data for a downloaded item.
|
||||
*/
|
||||
interface UserData {
|
||||
subtitleStreamIndex: number;
|
||||
/** The last known audio stream index. */
|
||||
audioStreamIndex: number;
|
||||
}
|
||||
|
||||
/** Represents a segment of time in a media item, used for intro/credit skipping. */
|
||||
export interface MediaTimeSegment {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface Segment {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
|
||||
export interface DownloadedItem {
|
||||
/** The Jellyfin item DTO. */
|
||||
item: BaseItemDto;
|
||||
/** The media source information. */
|
||||
mediaSource: MediaSourceInfo;
|
||||
/** The local file path of the downloaded video. */
|
||||
videoFilePath: string;
|
||||
/** The size of the video file in bytes. */
|
||||
videoFileSize: number;
|
||||
/** The local file path of the downloaded trickplay images. */
|
||||
trickPlayData?: TrickPlayData;
|
||||
/** The intro segments for the item. */
|
||||
introSegments?: MediaTimeSegment[];
|
||||
/** The credit segments for the item. */
|
||||
creditSegments?: MediaTimeSegment[];
|
||||
/** The user data for the item. */
|
||||
userData: UserData;
|
||||
}
|
||||
/**
|
||||
* Represents a downloaded Season, containing a map of its episodes.
|
||||
*/
|
||||
export interface DownloadedSeason {
|
||||
/** A map of episode numbers to their downloaded item data. */
|
||||
episodes: Record<number, DownloadedItem>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a downloaded series, containing seasons and their episodes.
|
||||
*/
|
||||
export interface DownloadedSeries {
|
||||
/** The Jellyfin item DTO for the series. */
|
||||
seriesInfo: BaseItemDto;
|
||||
/** A map of season numbers to their downloaded season data. */
|
||||
seasons: Record<
|
||||
number,
|
||||
{
|
||||
/** A map of episode numbers to their downloaded episode data. */
|
||||
episodes: Record<number, DownloadedItem>;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The main structure for all downloaded content stored locally.
|
||||
* This object is what will be saved to your local storage.
|
||||
*/
|
||||
export interface DownloadsDatabase {
|
||||
/** A map of movie IDs to their downloaded movie data. */
|
||||
movies: Record<string, DownloadedItem>;
|
||||
/** A map of series IDs to their downloaded series data. */
|
||||
series: Record<string, DownloadedSeries>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the status of a download job.
|
||||
*/
|
||||
export type JobStatus = {
|
||||
id: string;
|
||||
inputUrl: string;
|
||||
item: BaseItemDto;
|
||||
itemId: string;
|
||||
deviceId: string;
|
||||
progress: number;
|
||||
status:
|
||||
| "downloading"
|
||||
| "paused"
|
||||
| "error"
|
||||
| "pending"
|
||||
| "completed"
|
||||
| "queued";
|
||||
timestamp: Date;
|
||||
mediaSource: MediaSourceInfo;
|
||||
maxBitrate: Bitrate;
|
||||
bytesDownloaded?: number;
|
||||
lastProgressUpdateTime?: Date;
|
||||
speed?: number;
|
||||
estimatedTotalSizeBytes?: number;
|
||||
};
|
||||
@@ -1,22 +1,16 @@
|
||||
import "@/augmentations";
|
||||
import { useInterval } from "@/hooks/useInterval";
|
||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { writeErrorLog, writeInfoLog } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { store } from "@/utils/store";
|
||||
import { type Api, Jellyfin } from "@jellyfin/sdk";
|
||||
import type { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { router, useSegments } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
@@ -27,6 +21,12 @@ import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import { getDeviceName } from "react-native-device-info";
|
||||
import uuid from "react-native-uuid";
|
||||
import { useInterval } from "@/hooks/useInterval";
|
||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { writeErrorLog, writeInfoLog } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
interface Server {
|
||||
address: string;
|
||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.28.0" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.28.1" },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
@@ -80,9 +80,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const [isPolling, setIsPolling] = useState<boolean>(false);
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
const [
|
||||
settings,
|
||||
updateSettings,
|
||||
pluginSettings,
|
||||
_settings,
|
||||
_updateSettings,
|
||||
_pluginSettings,
|
||||
setPluginSettings,
|
||||
refreshStreamyfinPluginSettings,
|
||||
] = useSettings();
|
||||
@@ -91,9 +91,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const headers = useMemo(() => {
|
||||
if (!deviceId) return {};
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.28.0"`,
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.28.1"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
@@ -287,8 +286,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
mutationFn: async () => {
|
||||
api
|
||||
?.delete(`/Streamyfin/device/${deviceId}`)
|
||||
.then((r) => writeInfoLog("Deleted expo push token for device"))
|
||||
.catch((e) =>
|
||||
.then((_r) => writeInfoLog("Deleted expo push token for device"))
|
||||
.catch((_e) =>
|
||||
writeErrorLog("Failed to delete expo push token for device"),
|
||||
);
|
||||
|
||||
@@ -380,8 +379,6 @@ function useProtectedRoute(user: UserDto | null, loaded = false) {
|
||||
useEffect(() => {
|
||||
if (loaded === false) return;
|
||||
|
||||
console.log("Loaded", user);
|
||||
|
||||
const inAuthGroup = segments[0] === "(auth)";
|
||||
|
||||
if (!user?.Id && inAuthGroup) {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useJobProcessor } from "@/utils/atoms/queue";
|
||||
import type React from "react";
|
||||
import { createContext } from "react";
|
||||
|
||||
const JobQueueContext = createContext(null);
|
||||
|
||||
export const JobQueueProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
useJobProcessor();
|
||||
|
||||
return (
|
||||
<JobQueueContext.Provider value={null}>{children}</JobQueueContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +1,14 @@
|
||||
import type { Bitrate } from "@/components/BitrateSelector";
|
||||
import { settingsAtom } from "@/utils/atoms/settings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import native from "@/utils/profiles/native";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type React from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createContext, useCallback, useContext, useState } from "react";
|
||||
import type { Bitrate } from "@/components/BitrateSelector";
|
||||
import { settingsAtom } from "@/utils/atoms/settings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||
|
||||
export type PlaybackType = {
|
||||
@@ -84,6 +77,7 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const native = await generateDeviceProfile();
|
||||
const data = await getStreamUrl({
|
||||
api,
|
||||
deviceProfile: native,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
createContext,
|
||||
@@ -12,6 +13,12 @@ import React, {
|
||||
} from "react";
|
||||
import { AppState, type AppStateStatus } from "react-native";
|
||||
|
||||
interface WebSocketMessage {
|
||||
MessageType: string;
|
||||
Data: any;
|
||||
// Add other fields as needed
|
||||
}
|
||||
|
||||
interface WebSocketProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -19,6 +26,9 @@ interface WebSocketProviderProps {
|
||||
interface WebSocketContextType {
|
||||
ws: WebSocket | null;
|
||||
isConnected: boolean;
|
||||
lastMessage: WebSocketMessage | null;
|
||||
sendMessage: (message: any) => void;
|
||||
clearLastMessage: () => void;
|
||||
}
|
||||
|
||||
const WebSocketContext = createContext<WebSocketContextType | null>(null);
|
||||
@@ -27,7 +37,8 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
||||
const router = useRouter();
|
||||
const deviceId = useMemo(() => {
|
||||
return getOrSetDeviceId();
|
||||
}, []);
|
||||
@@ -48,6 +59,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
let keepAliveInterval: number | null = null;
|
||||
|
||||
newWebSocket.onopen = () => {
|
||||
console.log("WebSocket connection opened");
|
||||
setIsConnected(true);
|
||||
keepAliveInterval = setInterval(() => {
|
||||
if (newWebSocket.readyState === WebSocket.OPEN) {
|
||||
@@ -56,9 +68,23 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
let reconnectAttempts = 0;
|
||||
const maxReconnectAttempts = 5;
|
||||
const reconnectDelay = 10000;
|
||||
|
||||
newWebSocket.onerror = (e) => {
|
||||
console.error("WebSocket error:", e);
|
||||
setIsConnected(false);
|
||||
|
||||
if (reconnectAttempts < maxReconnectAttempts) {
|
||||
reconnectAttempts++;
|
||||
setTimeout(() => {
|
||||
console.log(`WebSocket reconnect attempt ${reconnectAttempts}`);
|
||||
connectWebSocket();
|
||||
}, reconnectDelay);
|
||||
} else {
|
||||
console.warn("Max WebSocket reconnect attempts reached.");
|
||||
}
|
||||
};
|
||||
|
||||
newWebSocket.onclose = () => {
|
||||
@@ -67,7 +93,15 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
}
|
||||
setIsConnected(false);
|
||||
};
|
||||
|
||||
newWebSocket.onmessage = (e) => {
|
||||
try {
|
||||
const message = JSON.parse(e.data);
|
||||
console.log("[WS] Received message:", message);
|
||||
setLastMessage(message); // Store the last message in context
|
||||
} catch (error) {
|
||||
console.error("Error parsing WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
setWs(newWebSocket);
|
||||
|
||||
return () => {
|
||||
@@ -78,6 +112,41 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
};
|
||||
}, [api, deviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastMessage) {
|
||||
return;
|
||||
}
|
||||
if (lastMessage.MessageType === "Play") {
|
||||
handlePlayCommand(lastMessage.Data);
|
||||
}
|
||||
}, [lastMessage, router]);
|
||||
|
||||
const handlePlayCommand = useCallback(
|
||||
(data: any) => {
|
||||
if (!data || !data.ItemIds || !data.ItemIds.length) {
|
||||
console.warn("[WS] Received Play command with no items");
|
||||
return;
|
||||
}
|
||||
|
||||
const itemId = data.ItemIds[0];
|
||||
console.log(`[WS] Handling Play command for item: ${itemId}`);
|
||||
|
||||
router.push({
|
||||
pathname: "/(auth)/player/direct-player",
|
||||
params: {
|
||||
itemId: itemId,
|
||||
playCommand: data.PlayCommand || "PlayNow",
|
||||
audioIndex: data.AudioStreamIndex?.toString(),
|
||||
subtitleIndex: data.SubtitleStreamIndex?.toString(),
|
||||
mediaSourceId: data.MediaSourceId || "",
|
||||
bitrateValue: "",
|
||||
offline: "false",
|
||||
},
|
||||
});
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = connectWebSocket();
|
||||
return cleanup;
|
||||
@@ -126,9 +195,23 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
ws?.close();
|
||||
};
|
||||
}, [ws, connectWebSocket]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(message: any) => {
|
||||
if (ws && isConnected) {
|
||||
ws.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.warn("Cannot send message: WebSocket is not connected");
|
||||
}
|
||||
},
|
||||
[ws, isConnected],
|
||||
);
|
||||
const clearLastMessage = useCallback(() => {
|
||||
setLastMessage(null);
|
||||
}, []);
|
||||
return (
|
||||
<WebSocketContext.Provider value={{ ws, isConnected }}>
|
||||
<WebSocketContext.Provider
|
||||
value={{ ws, isConnected, lastMessage, sendMessage, clearLastMessage }}
|
||||
>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
);
|
||||
|
||||
48
renovate.json
Normal file
48
renovate.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"description": "Default Renovate preset for Streamyfin repositories",
|
||||
"extends": [
|
||||
"config:base",
|
||||
":dependencyDashboard",
|
||||
":enableVulnerabilityAlertsWithLabel(security)",
|
||||
":semanticCommits",
|
||||
":timezone(Etc/UTC)",
|
||||
"docker:enableMajor",
|
||||
"group:testNonMajor",
|
||||
"group:monorepos",
|
||||
"helpers:pinGitHubActionDigests"
|
||||
],
|
||||
"addLabels": ["dependencies"],
|
||||
"rebaseWhen": "conflicted",
|
||||
"ignorePaths": ["**/node_modules/**", "**/bower_components/**"],
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true,
|
||||
"groupName": "lockfiles",
|
||||
"schedule": ["every month"]
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Add 'ci' and 'github-actions' labels to GitHub Action update PRs",
|
||||
"matchManagers": ["github-actions"],
|
||||
"addLabels": ["ci", "github-actions"]
|
||||
},
|
||||
{
|
||||
"description": "Group minor and patch GitHub Action updates into a single PR",
|
||||
"matchManagers": ["github-actions"],
|
||||
"groupName": "CI dependencies",
|
||||
"groupSlug": "ci-deps",
|
||||
"matchUpdateTypes": ["minor", "patch"]
|
||||
},
|
||||
{
|
||||
"description": "Group lock file maintenance updates",
|
||||
"matchUpdateTypes": ["lockFileMaintenance"],
|
||||
"groupName": "lockfiles",
|
||||
"dependencyDashboardApproval": true
|
||||
},
|
||||
{
|
||||
"description": "Add specific labels for Expo and React Native dependencies",
|
||||
"matchPackagePatterns": ["expo", "react-native"],
|
||||
"addLabels": ["expo", "react-native"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -138,7 +138,8 @@
|
||||
"hide_libraries": "Bibliotheken ausblenden",
|
||||
"select_liraries_you_want_to_hide": "Wähl die Bibliotheken aus, die du im Bibliothekstab und auf der Startseite ausblenden möchtest.",
|
||||
"disable_haptic_feedback": "Haptisches Feedback deaktivieren",
|
||||
"default_quality": "Standardqualität"
|
||||
"default_quality": "Standardqualität",
|
||||
"disabled": "Deaktiviert"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
@@ -370,7 +371,9 @@
|
||||
"audio_tracks": "Audiospuren:",
|
||||
"playback_state": "Wiedergabestatus:",
|
||||
"no_data_available": "Keine Daten verfügbar",
|
||||
"index": "Index:"
|
||||
"index": "Index:",
|
||||
"continue_watching": "Weiterschauen",
|
||||
"go_back": "Zurück"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Als Nächstes",
|
||||
|
||||
@@ -138,7 +138,9 @@
|
||||
"hide_libraries": "Hide Libraries",
|
||||
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
||||
"disable_haptic_feedback": "Disable Haptic Feedback",
|
||||
"default_quality": "Default quality"
|
||||
"default_quality": "Default quality",
|
||||
"max_auto_play_episode_count": "Max auto play episode count",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
@@ -374,7 +376,9 @@
|
||||
"audio_tracks": "Audio Tracks:",
|
||||
"playback_state": "Playback State:",
|
||||
"no_data_available": "No data available",
|
||||
"index": "Index:"
|
||||
"index": "Index:",
|
||||
"continue_watching": "Continue Watching",
|
||||
"go_back": "Go back"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Next up",
|
||||
@@ -404,6 +408,7 @@
|
||||
"download_episode": "Download Episode",
|
||||
"download_movie": "Download Movie",
|
||||
"download_x_item": "Download {{item_count}} items",
|
||||
"download_unwatched_only": "Unwatched Only",
|
||||
"download_button": "Download",
|
||||
"using_optimized_server": "Using optimized server",
|
||||
"using_default_method": "Using default method"
|
||||
|
||||
480
translations/eo.json
Normal file
480
translations/eo.json
Normal file
@@ -0,0 +1,480 @@
|
||||
{
|
||||
"login": {
|
||||
"username_required": "Uzantnomo estas deviga",
|
||||
"error_title": "Eraro",
|
||||
"login_title": "Ensaluti",
|
||||
"login_to_title": "Ensaluti al",
|
||||
"username_placeholder": "Uzantnomo",
|
||||
"password_placeholder": "Pasvorto",
|
||||
"login_button": "Ensaluti",
|
||||
"quick_connect": "Rapida Konekto",
|
||||
"enter_code_to_login": "Enigu kodon {{code}} por ensaluti",
|
||||
"failed_to_initiate_quick_connect": "Malsukcesis iniciati Rapidan Konekton",
|
||||
"got_it": "Komprenita",
|
||||
"connection_failed": "Konekto malsukcesis",
|
||||
"could_not_connect_to_server": "Ne povis konekti al la servilo. Bonvolu kontroli la URL-on kaj vian retan konekton.",
|
||||
"an_unexpected_error_occured": "Neatendita eraro okazis",
|
||||
"change_server": "Ŝanĝi servilon",
|
||||
"invalid_username_or_password": "Nevalida uzantnomo aŭ pasvorto",
|
||||
"user_does_not_have_permission_to_log_in": "Uzanto ne havas permeson ensaluti",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "Servilo respondas tro malrapide, provu denove poste",
|
||||
"server_received_too_many_requests_try_again_later": "Servilo ricevis tro multajn petojn, provu denove poste.",
|
||||
"there_is_a_server_error": "Estas servila eraro",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Neatendita eraro okazis. Ĉu vi enigis la ĝustan servilan URL-on?"
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "Enigu la URL-on al via Jellyfin-servilo",
|
||||
"server_url_placeholder": "http(s)://via-servilo.com",
|
||||
"connect_button": "Konekti",
|
||||
"previous_servers": "antaŭaj serviloj",
|
||||
"clear_button": "Forviŝi",
|
||||
"search_for_local_servers": "Serĉi lokajn servilojn",
|
||||
"searching": "Serĉante...",
|
||||
"servers": "Serviloj"
|
||||
},
|
||||
"home": {
|
||||
"no_internet": "Neniu Interreto",
|
||||
"no_items": "Neniuj eroj",
|
||||
"no_internet_message": "Ne zorgu, vi ankoraŭ povas spekti\nelsŝutitan enhavon.",
|
||||
"go_to_downloads": "Iri al elŝutoj",
|
||||
"oops": "Ho ve!",
|
||||
"error_message": "Io misfunkciis.\nBonvolu elsaluti kaj reensaluti.",
|
||||
"continue_watching": "Daŭrigi Spektadon",
|
||||
"next_up": "Sekva",
|
||||
"recently_added_in": "Ĵus Aldonita en {{libraryName}}",
|
||||
"suggested_movies": "Sugestitaj Filmoj",
|
||||
"suggested_episodes": "Sugestitaj Epizodoj",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Bonvenon al Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Senpaga kaj malfermfonta kliento por Jellyfin.",
|
||||
"features_title": "Trajtoj",
|
||||
"features_description": "Streamyfin havas multajn trajtojn kaj integriĝas kun vasta gamo de programaroj, kiujn vi povas trovi en la agorda menuo, tiuj inkluzivas:",
|
||||
"jellyseerr_feature_description": "Konekti al via Jellyseerr-instanco kaj peti filmojn rekte en la aplikaĵo.",
|
||||
"downloads_feature_title": "Elŝutoj",
|
||||
"downloads_feature_description": "Elŝutu filmojn kaj televidajn seriojn por vidi senkonekte. Uzu aŭ la defaŭltan metodon aŭ instalu la optimumigan servilon por elŝuti dosierojn en la fono.",
|
||||
"chromecast_feature_description": "Ĵetu filmojn kaj televidajn seriojn al viaj Chromecast-aparatoj.",
|
||||
"centralised_settings_plugin_title": "Centralizita Agorda Kromprogramo",
|
||||
"centralised_settings_plugin_description": "Agordu agordojn de centralizita loko sur via Jellyfin-servilo. Ĉiuj klientaj agordoj por ĉiuj uzantoj estos sinkronigitaj aŭtomate.",
|
||||
"done_button": "Farite",
|
||||
"go_to_settings_button": "Iri al agordoj",
|
||||
"read_more": "Legu pli"
|
||||
},
|
||||
"settings": {
|
||||
"settings_title": "Agordoj",
|
||||
"log_out_button": "Elsaluti",
|
||||
"user_info": {
|
||||
"user_info_title": "Uzantaj Informoj",
|
||||
"user": "Uzanto",
|
||||
"server": "Servilo",
|
||||
"token": "Ĵetono",
|
||||
"app_version": "Aplikaĵa Versio"
|
||||
},
|
||||
"quick_connect": {
|
||||
"quick_connect_title": "Rapida Konekto",
|
||||
"authorize_button": "Aŭtorizi Rapidan Konekton",
|
||||
"enter_the_quick_connect_code": "Enigu la rapidan konektan kodon...",
|
||||
"success": "Sukceso",
|
||||
"quick_connect_autorized": "Rapida Konekto aŭtorizita",
|
||||
"error": "Eraro",
|
||||
"invalid_code": "Nevalida kodo",
|
||||
"authorize": "Aŭtorizi"
|
||||
},
|
||||
"media_controls": {
|
||||
"media_controls_title": "Mediaj Kontroloj",
|
||||
"forward_skip_length": "Antaŭensalta longeco",
|
||||
"rewind_length": "Rebobena longeco",
|
||||
"seconds_unit": "s"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Audio",
|
||||
"set_audio_track": "Agordi Aŭdian Trakon De Antaŭa Ero",
|
||||
"audio_language": "Aŭdia lingvo",
|
||||
"audio_hint": "Elektu defaŭltan aŭdian lingvon.",
|
||||
"none": "Neniu",
|
||||
"language": "Lingvo"
|
||||
},
|
||||
"subtitles": {
|
||||
"subtitle_title": "Subtekstoj",
|
||||
"subtitle_language": "Subteksta lingvo",
|
||||
"subtitle_mode": "Subteksta Reĝimo",
|
||||
"set_subtitle_track": "Agordi Subtekstan Trakon De Antaŭa Ero",
|
||||
"subtitle_size": "Subteksta Grandeco",
|
||||
"subtitle_hint": "Agordu subtekstan preferon.",
|
||||
"none": "Neniu",
|
||||
"language": "Lingvo",
|
||||
"loading": "Ŝarĝante",
|
||||
"modes": {
|
||||
"Default": "Defaŭlta",
|
||||
"Smart": "Inteligenta",
|
||||
"Always": "Ĉiam",
|
||||
"None": "Neniu",
|
||||
"OnlyForced": "NurDevigita"
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Alia",
|
||||
"follow_device_orientation": "Aŭtomata rotacio",
|
||||
"video_orientation": "Video-orientiĝo",
|
||||
"orientation": "Orientiĝo",
|
||||
"orientations": {
|
||||
"DEFAULT": "Defaŭlta",
|
||||
"ALL": "Ĉiuj",
|
||||
"PORTRAIT": "Portreta",
|
||||
"PORTRAIT_UP": "Portreta Supren",
|
||||
"PORTRAIT_DOWN": "Portreta Malsupren",
|
||||
"LANDSCAPE": "Pejzaĝa",
|
||||
"LANDSCAPE_LEFT": "Pejzaĝa Maldekstren",
|
||||
"LANDSCAPE_RIGHT": "Pejzaĝa Dekstren",
|
||||
"OTHER": "Alia",
|
||||
"UNKNOWN": "Nekonata"
|
||||
},
|
||||
"safe_area_in_controls": "Sekura areo en kontroloj",
|
||||
"video_player": "Video-ludilo",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Eksperimenta + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Montri Proprajn Menuajn Ligilojn",
|
||||
"hide_libraries": "Kaŝi Bibliotekojn",
|
||||
"select_liraries_you_want_to_hide": "Elektu la bibliotekojn, kiujn vi volas kaŝi de la Biblioteka langeto kaj hejmpaĝaj sekcioj.",
|
||||
"disable_haptic_feedback": "Malŝalti Haptan Rimarkon",
|
||||
"default_quality": "Defaŭlta kvalito"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Elŝutoj",
|
||||
"download_method": "Elŝuta metodo",
|
||||
"remux_max_download": "Remux maksimuma elŝuto",
|
||||
"auto_download": "Aŭtomata elŝuto",
|
||||
"optimized_versions_server": "Optimumigitaj versioj servilo",
|
||||
"save_button": "Konservi",
|
||||
"optimized_server": "Optimumigita Servilo",
|
||||
"optimized": "Optimumigita",
|
||||
"default": "Defaŭlta",
|
||||
"optimized_version_hint": "Enigu la URL-on por la optimumiga servilo. La URL devus inkluzivi http aŭ https kaj laŭvole la pordon.",
|
||||
"read_more_about_optimized_server": "Legu pli pri la optimumiga servilo.",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domajno.org:pordo"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Kromprogramoj",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Ĉi tiu integriĝo estas en siaj fruaj stadioj. Atendu ŝanĝojn.",
|
||||
"server_url": "Servila URL",
|
||||
"server_url_hint": "Ekzemplo: http(s)://via-gastiganto.url\n(aldonu pordon se necese)",
|
||||
"server_url_placeholder": "Jellyseerr URL...",
|
||||
"password": "Pasvorto",
|
||||
"password_placeholder": "Enigu pasvorton por Jellyfin-uzanto {{username}}",
|
||||
"save_button": "Konservi",
|
||||
"clear_button": "Forviŝi",
|
||||
"login_button": "Ensaluti",
|
||||
"total_media_requests": "Totalaj mediaj petoj",
|
||||
"movie_quota_limit": "Filma kvota limo",
|
||||
"movie_quota_days": "Filmaj kvotaj tagoj",
|
||||
"tv_quota_limit": "Televida kvota limo",
|
||||
"tv_quota_days": "Televidaj kvotaj tagoj",
|
||||
"reset_jellyseerr_config_button": "Restarigi Jellyseerr-agordon",
|
||||
"unlimited": "Senlima",
|
||||
"plus_n_more": "+{{n}} pli",
|
||||
"order_by": {
|
||||
"DEFAULT": "Defaŭlta",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Voĉdonkalkulo kaj mezumo",
|
||||
"POPULARITY": "Populareco"
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "Ebligi Marlin Serĉon ",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domajno.org:pordo",
|
||||
"marlin_search_hint": "Enigu la URL-on por la Marlin-servilo. La URL devus inkluzivi http aŭ https kaj laŭvole la pordon.",
|
||||
"read_more_about_marlin": "Legu pli pri Marlin.",
|
||||
"save_button": "Konservi",
|
||||
"toasts": {
|
||||
"saved": "Konservita"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"storage_title": "Stokado",
|
||||
"app_usage": "Aplikaĵo {{usedSpace}}%",
|
||||
"device_usage": "Aparato {{availableSpace}}%",
|
||||
"size_used": "{{used}} el {{total}} uzata",
|
||||
"delete_all_downloaded_files": "Forigi Ĉiujn Elŝutitajn Dosierojn"
|
||||
},
|
||||
"intro": {
|
||||
"show_intro": "Montri enkondukon",
|
||||
"reset_intro": "Restarigi enkondukon"
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Protokoloj",
|
||||
"export_logs": "Eksporti protokolojn",
|
||||
"click_for_more_info": "Klaku por pli da informoj",
|
||||
"level": "Nivelo",
|
||||
"no_logs_available": "Neniuj protokoloj disponeblaj",
|
||||
"delete_all_logs": "Forigi ĉiujn protokolojn"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Lingvoj",
|
||||
"app_language": "Aplikaĵa lingvo",
|
||||
"app_language_description": "Elektu la lingvon por la aplikaĵo.",
|
||||
"system": "Sistemo"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Eraro forigante dosierojn",
|
||||
"background_downloads_enabled": "Fonaj elŝutoj ebligitaj",
|
||||
"background_downloads_disabled": "Fonaj elŝutoj malŝaltitaj",
|
||||
"connected": "Konektita",
|
||||
"could_not_connect": "Ne povis konekti",
|
||||
"invalid_url": "Nevalida URL"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Sesioj",
|
||||
"no_active_sessions": "Neniuj aktivaj sesioj"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Elŝutoj",
|
||||
"tvseries": "Televidaj serioj",
|
||||
"movies": "Filmoj",
|
||||
"queue": "Vico",
|
||||
"queue_hint": "Vico kaj elŝutoj perdiĝos ĉe aplikaĵa rekomenco",
|
||||
"no_items_in_queue": "Neniuj eroj en vico",
|
||||
"no_downloaded_items": "Neniuj elŝutitaj eroj",
|
||||
"delete_all_movies_button": "Forigi ĉiujn Filmojn",
|
||||
"delete_all_tvseries_button": "Forigi ĉiujn Televidajn Seriojn",
|
||||
"delete_all_button": "Forigi ĉion",
|
||||
"active_download": "Aktiva elŝuto",
|
||||
"no_active_downloads": "Neniuj aktivaj elŝutoj",
|
||||
"active_downloads": "Aktivaj elŝutoj",
|
||||
"new_app_version_requires_re_download": "Nova aplikaĵa versio postulas re-elŝuton",
|
||||
"new_app_version_requires_re_download_description": "La nova ĝisdatigo postulas, ke enhavo estu elŝutita denove. Bonvolu forigi ĉian elŝutitan enhavon kaj provi denove.",
|
||||
"back": "Reen",
|
||||
"delete": "Forigi",
|
||||
"something_went_wrong": "Io misfunkciis",
|
||||
"could_not_get_stream_url_from_jellyfin": "Ne povis akiri la fluan URL-on de Jellyfin",
|
||||
"eta": "ETA {{eta}}",
|
||||
"methods": "Metodoj",
|
||||
"toasts": {
|
||||
"you_are_not_allowed_to_download_files": "Vi ne rajtas elŝuti dosierojn.",
|
||||
"deleted_all_movies_successfully": "Sukcese forigis ĉiujn filmojn!",
|
||||
"failed_to_delete_all_movies": "Malsukcesis forigi ĉiujn filmojn",
|
||||
"deleted_all_tvseries_successfully": "Sukcese forigis ĉiujn Televidajn Seriojn!",
|
||||
"failed_to_delete_all_tvseries": "Malsukcesis forigi ĉiujn Televidajn Seriojn",
|
||||
"download_cancelled": "Elŝuto nuligita",
|
||||
"could_not_cancel_download": "Ne povis nuligi elŝuton",
|
||||
"download_completed": "Elŝuto finita",
|
||||
"download_started_for": "Elŝuto komenciĝis por {{item}}",
|
||||
"item_is_ready_to_be_downloaded": "{{item}} estas preta por esti elŝutita",
|
||||
"download_stated_for_item": "Elŝuto komenciĝis por {{item}}",
|
||||
"download_failed_for_item": "Elŝuto malsukcesis por {{item}} - {{error}}",
|
||||
"download_completed_for_item": "Elŝuto finita por {{item}}",
|
||||
"queued_item_for_optimization": "Envicigis {{item}} por optimumigo",
|
||||
"failed_to_start_download_for_item": "Malsukcesis komenci elŝutadon por {{item}}: {{message}}",
|
||||
"server_responded_with_status_code": "Servilo respondis kun statuskodo {{statusCode}}",
|
||||
"no_response_received_from_server": "Neniu respondo ricevita de la servilo",
|
||||
"error_setting_up_the_request": "Eraro starigante la peton",
|
||||
"failed_to_start_download_for_item_unexpected_error": "Malsukcesis komenci elŝutadon por {{item}}: Neatendita eraro",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Ĉiuj dosieroj, dosierujoj kaj taskoj sukcese forigitaj",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "Eraro okazis dum forigo de dosieroj kaj taskoj",
|
||||
"go_to_downloads": "Iri al elŝutoj"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"search_here": "Serĉu ĉi tie...",
|
||||
"search": "Serĉi...",
|
||||
"x_items": "{{count}} eroj",
|
||||
"library": "Biblioteko",
|
||||
"discover": "Malkovri",
|
||||
"no_results": "Neniuj rezultoj",
|
||||
"no_results_found_for": "Neniuj rezultoj trovitaj por",
|
||||
"movies": "Filmoj",
|
||||
"series": "Serioj",
|
||||
"episodes": "Epizodoj",
|
||||
"collections": "Kolektoj",
|
||||
"actors": "Aktoroj",
|
||||
"request_movies": "Peti Filmojn",
|
||||
"request_series": "Peti Seriojn",
|
||||
"recently_added": "Ĵus Aldonita",
|
||||
"recent_requests": "Lastatempaj Petoj",
|
||||
"plex_watchlist": "Plex Spektolisto",
|
||||
"trending": "Tendencaj",
|
||||
"popular_movies": "Popularaj Filmoj",
|
||||
"movie_genres": "Filmaj Ĝenroj",
|
||||
"upcoming_movies": "Venontaj Filmoj",
|
||||
"studios": "Studioj",
|
||||
"popular_tv": "Populara Televido",
|
||||
"tv_genres": "Televidaj Ĝenroj",
|
||||
"upcoming_tv": "Venonta Televido",
|
||||
"networks": "Retoj",
|
||||
"tmdb_movie_keyword": "TMDB Filma Ŝlosilvorto",
|
||||
"tmdb_movie_genre": "TMDB Filma Ĝenro",
|
||||
"tmdb_tv_keyword": "TMDB Televida Ŝlosilvorto",
|
||||
"tmdb_tv_genre": "TMDB Televida Ĝenro",
|
||||
"tmdb_search": "TMDB Serĉo",
|
||||
"tmdb_studio": "TMDB Studio",
|
||||
"tmdb_network": "TMDB Reto",
|
||||
"tmdb_movie_streaming_services": "TMDB Filmaj Fluservoj",
|
||||
"tmdb_tv_streaming_services": "TMDB Televidaj Fluservoj"
|
||||
},
|
||||
"library": {
|
||||
"no_items_found": "Neniuj eroj trovitaj",
|
||||
"no_results": "Neniuj rezultoj",
|
||||
"no_libraries_found": "Neniuj bibliotekoj trovitaj",
|
||||
"item_types": {
|
||||
"movies": "filmoj",
|
||||
"series": "serioj",
|
||||
"boxsets": "skatolaj aroj",
|
||||
"items": "eroj"
|
||||
},
|
||||
"options": {
|
||||
"display": "Vidigi",
|
||||
"row": "Vico",
|
||||
"list": "Listo",
|
||||
"image_style": "Bildostilo",
|
||||
"poster": "Afiŝo",
|
||||
"cover": "Kovrilo",
|
||||
"show_titles": "Montri titolojn",
|
||||
"show_stats": "Montri statistikojn"
|
||||
},
|
||||
"filters": {
|
||||
"genres": "Ĝenroj",
|
||||
"years": "Jaroj",
|
||||
"sort_by": "Ordigi laŭ",
|
||||
"sort_order": "Orda ordo",
|
||||
"asc": "Supreniranta",
|
||||
"desc": "Malsupreniranta",
|
||||
"tags": "Etikedoj"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
"series": "Serioj",
|
||||
"movies": "Filmoj",
|
||||
"episodes": "Epizodoj",
|
||||
"videos": "Videoj",
|
||||
"boxsets": "Skatolaj aroj",
|
||||
"playlists": "Ludlistoj",
|
||||
"noDataTitle": "Ankoraŭ neniuj favoratoj",
|
||||
"noData": "Marku erojn kiel favoratojn por vidi ilin aperi ĉi tie por rapida aliro."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "Neniuj ligiloj"
|
||||
},
|
||||
"player": {
|
||||
"error": "Eraro",
|
||||
"failed_to_get_stream_url": "Malsukcesis akiri la fluan URL-on",
|
||||
"an_error_occured_while_playing_the_video": "Eraro okazis dum ludado de la video. Kontrolu protokolojn en agordoj.",
|
||||
"client_error": "Klienta eraro",
|
||||
"could_not_create_stream_for_chromecast": "Ne povis krei fluon por Chromecast",
|
||||
"message_from_server": "Mesaĝo de servilo: {{message}}",
|
||||
"video_has_finished_playing": "Video finis ludi!",
|
||||
"no_video_source": "Neniu video-fonto...",
|
||||
"next_episode": "Sekva Epizodo",
|
||||
"refresh_tracks": "Refreŝigi Trakojn",
|
||||
"subtitle_tracks": "Subtekstaj Trakoj:",
|
||||
"audio_tracks": "Aŭdiaj Trakoj:",
|
||||
"playback_state": "Ludada Stato:",
|
||||
"no_data_available": "Neniuj datumoj disponeblaj",
|
||||
"index": "Indekso:"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Sekva",
|
||||
"no_items_to_display": "Neniuj eroj por montri",
|
||||
"cast_and_crew": "Rolantaro & Skiparo",
|
||||
"series": "Serioj",
|
||||
"seasons": "Sezonoj",
|
||||
"season": "Sezono",
|
||||
"no_episodes_for_this_season": "Neniuj epizodoj por ĉi tiu sezono",
|
||||
"overview": "Superrigardo",
|
||||
"more_with": "Pli kun {{name}}",
|
||||
"similar_items": "Similaj eroj",
|
||||
"no_similar_items_found": "Neniuj similaj eroj trovitaj",
|
||||
"video": "Video",
|
||||
"more_details": "Pli da detaloj",
|
||||
"quality": "Kvalito",
|
||||
"audio": "Audio",
|
||||
"subtitles": "Subteksto",
|
||||
"show_more": "Montri pli",
|
||||
"show_less": "Montri malpli",
|
||||
"appeared_in": "Aperis en",
|
||||
"could_not_load_item": "Ne povis ŝarĝi eron",
|
||||
"none": "Neniu",
|
||||
"download": {
|
||||
"download_season": "Elŝuti Sezonon",
|
||||
"download_series": "Elŝuti Serion",
|
||||
"download_episode": "Elŝuti Epizodon",
|
||||
"download_movie": "Elŝuti Filmon",
|
||||
"download_x_item": "Elŝuti {{item_count}} erojn",
|
||||
"download_button": "Elŝuti",
|
||||
"using_optimized_server": "Uzante optimumigitan servilon",
|
||||
"using_default_method": "Uzante defaŭltan metodon"
|
||||
}
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Sekva",
|
||||
"previous": "Antaŭa",
|
||||
"live_tv": "Viva Televido",
|
||||
"coming_soon": "Baldaŭ",
|
||||
"on_now": "Nun",
|
||||
"shows": "Spektakloj",
|
||||
"movies": "Filmoj",
|
||||
"sports": "Sportoj",
|
||||
"for_kids": "Por Infanoj",
|
||||
"news": "Novaĵoj"
|
||||
},
|
||||
"jellyseerr": {
|
||||
"confirm": "Konfirmi",
|
||||
"cancel": "Nuligi",
|
||||
"yes": "Jes",
|
||||
"whats_wrong": "Kio estas malĝusta?",
|
||||
"issue_type": "Problema tipo",
|
||||
"select_an_issue": "Elektu problemon",
|
||||
"types": "Tipoj",
|
||||
"describe_the_issue": "(laŭvola) Priskribu la problemon...",
|
||||
"submit_button": "Sendi",
|
||||
"report_issue_button": "Raporti problemon",
|
||||
"request_button": "Peti",
|
||||
"are_you_sure_you_want_to_request_all_seasons": "Ĉu vi certas, ke vi volas peti ĉiujn sezonojn?",
|
||||
"failed_to_login": "Malsukcesis ensaluti",
|
||||
"cast": "Rolantaro",
|
||||
"details": "Detaloj",
|
||||
"status": "Stato",
|
||||
"original_title": "Originala Titolo",
|
||||
"series_type": "Seria Tipo",
|
||||
"release_dates": "Eldondatoj",
|
||||
"first_air_date": "Unua Elsendo-dato",
|
||||
"next_air_date": "Sekva Elsendo-dato",
|
||||
"revenue": "Enspezo",
|
||||
"budget": "Buĝeto",
|
||||
"original_language": "Originala Lingvo",
|
||||
"production_country": "Produktada Lando",
|
||||
"studios": "Studioj",
|
||||
"network": "Reto",
|
||||
"currently_streaming_on": "Nuntempe Flusanta ĉe",
|
||||
"advanced": "Altnivela",
|
||||
"request_as": "Peti Kiel",
|
||||
"tags": "Etikedoj",
|
||||
"quality_profile": "Kvalita Profilo",
|
||||
"root_folder": "Radika Dosierujo",
|
||||
"season_all": "Sezono (ĉiuj)",
|
||||
"season_number": "Sezono {{season_number}}",
|
||||
"number_episodes": "{{episode_number}} Epizodoj",
|
||||
"born": "Naskiĝis",
|
||||
"appearances": "Aperoj",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Jellyseerr-servilo ne plenumas minimumajn versiajn postulojn! Bonvolu ĝisdatigi al almenaŭ 2.0.0",
|
||||
"jellyseerr_test_failed": "Jellyseerr-testo malsukcesis. Bonvolu provi denove.",
|
||||
"failed_to_test_jellyseerr_server_url": "Malsukcesis testi jellyseerr-servilan url-on",
|
||||
"issue_submitted": "Problemo sendita!",
|
||||
"requested_item": "Petis {{item}}!",
|
||||
"you_dont_have_permission_to_request": "Vi ne havas permeson peti!",
|
||||
"something_went_wrong_requesting_media": "Io misfunkciis petante medion!"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"home": "Hejmo",
|
||||
"search": "Serĉi",
|
||||
"library": "Biblioteko",
|
||||
"custom_links": "Propraj Ligiloj",
|
||||
"favorites": "Favoratoj"
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,8 @@
|
||||
"hide_libraries": "Ocultar bibliotecas",
|
||||
"select_liraries_you_want_to_hide": "Selecciona las bibliotecas que quieres ocultar de la pestaña Bibliotecas y de Inicio.",
|
||||
"disable_haptic_feedback": "Desactivar feedback háptico",
|
||||
"default_quality": "Calidad por defecto"
|
||||
"default_quality": "Calidad por defecto",
|
||||
"disabled": "Deshabilitado"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Descargas",
|
||||
@@ -370,7 +371,9 @@
|
||||
"audio_tracks": "Pistas de audio:",
|
||||
"playback_state": "Estado de la reproducción:",
|
||||
"no_data_available": "No hay datos disponibles",
|
||||
"index": "Índice:"
|
||||
"index": "Índice:",
|
||||
"continue_watching": "Continuar viendo",
|
||||
"go_back": "Volver"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "A continuación",
|
||||
|
||||
@@ -138,7 +138,8 @@
|
||||
"hide_libraries": "Cacher des bibliothèques",
|
||||
"select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l'onglet Bibliothèque et les sections de la page d'accueil.",
|
||||
"disable_haptic_feedback": "Désactiver le retour haptique",
|
||||
"default_quality": "Qualité par défaut"
|
||||
"default_quality": "Qualité par défaut",
|
||||
"disabled": "Désactivé"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Téléchargements",
|
||||
@@ -370,7 +371,9 @@
|
||||
"audio_tracks": "Pistes audio:",
|
||||
"playback_state": "État de lecture:",
|
||||
"no_data_available": "Aucune donnée disponible",
|
||||
"index": "Index:"
|
||||
"index": "Index :",
|
||||
"continue_watching": "Continuer à regarder",
|
||||
"go_back": "Retour"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "À suivre",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user