forked from Ninjalama/streamyfin_mirror
Compare commits
106 Commits
fix-eos-ca
...
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 |
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -43,6 +43,7 @@ body:
|
|||||||
label: Version
|
label: Version
|
||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
|
- 0.28.1
|
||||||
- 0.28.0
|
- 0.28.0
|
||||||
- 0.27.0
|
- 0.27.0
|
||||||
- 0.26.1
|
- 0.26.1
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ concurrency:
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [develop, master]
|
branches: [develop, master,ninjalama-patch-1]
|
||||||
push:
|
push:
|
||||||
branches: [develop, master]
|
branches: [develop, master, ninjalama-patch-1]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@v4 # v4.2.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
show-progress: false
|
show-progress: false
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
with:
|
with:
|
||||||
bun-version: '1.2.15'
|
bun-version: '1.2.17'
|
||||||
|
|
||||||
- name: ☕ Setup JDK
|
- name: ☕ Setup JDK
|
||||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||||
@@ -38,6 +38,9 @@ jobs:
|
|||||||
distribution: 'zulu'
|
distribution: 'zulu'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
|
- name: Set up Android SDK
|
||||||
|
uses: android-actions/setup-android@v2
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||||
with:
|
with:
|
||||||
@@ -70,9 +73,9 @@ jobs:
|
|||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: 📤 Upload APK artifact
|
- name: 📤 Upload APK artifact
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: streamyfin-apk-${{ github.sha }}-${{ env.DATE_TAG }}
|
name: streamyfin-apk-${{ env.DATE_TAG }}
|
||||||
path: |
|
path: |
|
||||||
android/app/build/outputs/apk/release/*.apk
|
android/app/build/outputs/apk/release/*.apk
|
||||||
android/app/build/outputs/bundle/release/*.aab
|
android/app/build/outputs/bundle/release/*.aab
|
||||||
6
.github/workflows/build-ios.yml
vendored
6
.github/workflows/build-ios.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
|||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
with:
|
with:
|
||||||
bun-version: '1.2.15'
|
bun-version: '1.2.17'
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
- name: 🏗 Setup EAS
|
- name: 🏗 Setup EAS
|
||||||
uses: expo/expo-github-action@v8
|
uses: expo/expo-github-action@main
|
||||||
with:
|
with:
|
||||||
eas-version: 16.7.1
|
eas-version: 16.7.1
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
@@ -64,7 +64,7 @@ jobs:
|
|||||||
- name: 📤 Upload IPA artifact
|
- name: 📤 Upload IPA artifact
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
with:
|
with:
|
||||||
name: streamyfin-ipa-${{ github.sha }}-${{ env.DATE_TAG }}
|
name: streamyfin-ipa-${{ env.DATE_TAG }}
|
||||||
path: |
|
path: |
|
||||||
build-*.ipa
|
build-*.ipa
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|||||||
2
.github/workflows/check-lockfile.yml
vendored
2
.github/workflows/check-lockfile.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
|||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
with:
|
with:
|
||||||
bun-version: '1.2.15'
|
bun-version: '1.2.17'
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
|||||||
6
.github/workflows/ci-codeql.yml
vendored
6
.github/workflows/ci-codeql.yml
vendored
@@ -31,13 +31,13 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended,security-and-quality
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
- name: 🛠️ Autobuild
|
||||||
uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||||
|
|||||||
8
.github/workflows/linting.yml
vendored
8
.github/workflows/linting.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
- uses: marocchino/sticky-pull-request-comment@d2ad0de260ae8b0235ce059e63f2949ba9e05943 # v2.9.3
|
||||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||||
with:
|
with:
|
||||||
header: pr-title-lint-error
|
header: pr-title-lint-error
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
```
|
```
|
||||||
|
|
||||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||||
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
uses: marocchino/sticky-pull-request-comment@d2ad0de260ae8b0235ce059e63f2949ba9e05943 # v2.9.3
|
||||||
with:
|
with:
|
||||||
header: pr-title-lint-error
|
header: pr-title-lint-error
|
||||||
delete: true
|
delete: true
|
||||||
@@ -81,12 +81,12 @@ jobs:
|
|||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '22.x'
|
||||||
|
|
||||||
- name: "🍞 Setup Bun"
|
- name: "🍞 Setup Bun"
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
with:
|
with:
|
||||||
bun-version: '1.2.15'
|
bun-version: '1.2.17'
|
||||||
|
|
||||||
- name: "📦 Install dependencies"
|
- name: "📦 Install dependencies"
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -4,7 +4,7 @@
|
|||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true
|
||||||
},
|
},
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "vscode.typescript-language-features",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true
|
||||||
},
|
},
|
||||||
"prettier.printWidth": 120,
|
"prettier.printWidth": 120,
|
||||||
|
|||||||
36
README.md
36
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||||
|
|
||||||
Welcome to Streamyfin, a simple and user-friendly Jellyfin video streaming client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
|
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">
|
<div style="display: flex; flex-direction: row; gap: 8px">
|
||||||
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
||||||
@@ -23,17 +23,17 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin video streaming clien
|
|||||||
|
|
||||||
## 🧪 Experimental Features
|
## 🧪 Experimental Features
|
||||||
|
|
||||||
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These are still in development, and we appreciate your patience and feedback as we work to improve them.
|
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These features are still in development, and your patience and feedback are much appreciated as we work to improve them.
|
||||||
|
|
||||||
### Downloading
|
### 📥 Downloading
|
||||||
|
|
||||||
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
||||||
|
|
||||||
### Chromecast
|
### 🎥 Chromecast
|
||||||
|
|
||||||
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos, 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 holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
|
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
|
||||||
|
|
||||||
@@ -41,21 +41,21 @@ The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that ho
|
|||||||
- Choose the default languages
|
- Choose the default languages
|
||||||
- Set download method and search provider
|
- Set download method and search provider
|
||||||
- Customize home screen
|
- Customize home screen
|
||||||
- And more...
|
- And much more...
|
||||||
|
|
||||||
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
||||||
|
|
||||||
### Jellysearch
|
### 🔍 Jellysearch
|
||||||
|
|
||||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀
|
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀
|
||||||
|
|
||||||
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
||||||
|
|
||||||
## Roadmap for V1
|
## 🛣️ Roadmap for V1
|
||||||
|
|
||||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests.
|
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
||||||
|
|
||||||
## Get it now
|
## 📥 Get it now
|
||||||
|
|
||||||
<div style="display: flex; gap: 5px;">
|
<div style="display: flex; gap: 5px;">
|
||||||
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||||
@@ -64,7 +64,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to
|
|||||||
|
|
||||||
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
|
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
|
||||||
|
|
||||||
### Beta testing
|
### 🧪 Beta testing
|
||||||
|
|
||||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and I'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that I can manually add you.
|
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and I'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that I can manually add you.
|
||||||
|
|
||||||
@@ -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.
|
We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas.
|
||||||
|
|
||||||
### Development info
|
### 👨💻 Development info
|
||||||
|
|
||||||
1. Use node `>20`
|
1. Use node `>20`
|
||||||
2. Install dependencies `bun i && bun run submodule-reload`
|
2. Install dependencies `bun i && bun run submodule-reload`
|
||||||
@@ -118,7 +118,7 @@ If you have questions or need support, feel free to reach out:
|
|||||||
- GitHub Issues: Report bugs or request features here.
|
- GitHub Issues: Report bugs or request features here.
|
||||||
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
||||||
|
|
||||||
## FAQ
|
## ❓ FAQ
|
||||||
|
|
||||||
1. Q: Why can't I see my libraries in Streamyfin?
|
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.
|
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.
|
||||||
@@ -135,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.
|
Special shoutout to the JF official clients for being an inspiration to ours.
|
||||||
|
|
||||||
### Core Developers
|
### 🏆 Core Developers
|
||||||
|
|
||||||
Thanks to the following contributors for their significant contributions:
|
Thanks to the following contributors for their significant contributions:
|
||||||
|
|
||||||
@@ -220,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.
|
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
|
||||||
- The Jellyfin devs for always being helpful in the Discord.
|
- The Jellyfin devs for always being helpful in the Discord.
|
||||||
|
|
||||||
## Star History
|
## ⭐ Star History
|
||||||
|
|
||||||
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
||||||
|
|
||||||
|
## ⚠️ Disclaimer
|
||||||
|
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions or support requests related to piracy are strictly prohibited across all our channels.
|
||||||
|
|
||||||
|
## 🤝 Sponsorship
|
||||||
|
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur)
|
||||||
|
|||||||
2
app.json
2
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
|
|||||||
@@ -64,12 +64,6 @@ export default function IndexLayout() {
|
|||||||
title: t("home.settings.settings_title"),
|
title: t("home.settings.settings_title"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name='settings/optimized-server/page'
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/marlin-search/page'
|
name='settings/marlin-search/page'
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
@@ -1,13 +1,3 @@
|
|||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
|
||||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
|
||||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
|
||||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
|
||||||
import { type DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
|
||||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
@@ -16,30 +6,58 @@ import {
|
|||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||||
|
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||||
|
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||||
|
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { type DownloadedItem } from "@/providers/Downloads/types";
|
||||||
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
|
import { 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() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
|
const { deleteFileByType, downloadedFiles, removeProcess, deleteAllFiles } = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
const movies = useMemo(() => {
|
const movies = useMemo(() => {
|
||||||
try {
|
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
||||||
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
|
||||||
} catch {
|
|
||||||
migration_20241124();
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [downloadedFiles]);
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
const groupedBySeries = useMemo(() => {
|
const groupedBySeries = useMemo(() => {
|
||||||
@@ -54,12 +72,12 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
return Object.values(series);
|
return Object.values(series);
|
||||||
} catch {
|
} catch {
|
||||||
migration_20241124();
|
migration_20241124(deleteAllFiles, router, t);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [downloadedFiles]);
|
}, [downloadedFiles, deleteAllFiles, router, t]);
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const _insets = useSafeAreaInsets();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
@@ -98,16 +116,10 @@ export default function page() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ScrollView
|
<View style={{ flex: 1 }}>
|
||||||
contentContainerStyle={{
|
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
|
||||||
paddingLeft: insets.left,
|
<View className='py-4'>
|
||||||
paddingRight: insets.right,
|
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||||
paddingBottom: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className='py-4'>
|
|
||||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
|
||||||
{settings?.downloadMethod === DownloadMethod.Remux && (
|
|
||||||
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||||
<Text className='text-lg font-bold'>
|
<Text className='text-lg font-bold'>
|
||||||
{t("home.downloads.queue")}
|
{t("home.downloads.queue")}
|
||||||
@@ -151,70 +163,74 @@ export default function page() {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
|
|
||||||
<ActiveDownloads />
|
<ActiveDownloads />
|
||||||
</View>
|
|
||||||
|
|
||||||
{movies.length > 0 && (
|
|
||||||
<View className='mb-4'>
|
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
|
||||||
<Text className='text-lg font-bold'>
|
|
||||||
{t("home.downloads.movies")}
|
|
||||||
</Text>
|
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
|
||||||
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className='px-4 flex flex-row'>
|
|
||||||
{movies?.map((item) => (
|
|
||||||
<View className='mb-2 last:mb-0' key={item.item.Id}>
|
|
||||||
<MovieCard item={item.item} />
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
{groupedBySeries.length > 0 && (
|
{movies.length > 0 && (
|
||||||
<View className='mb-4'>
|
<View className='mb-4'>
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
<Text className='text-lg font-bold'>
|
<Text className='text-lg font-bold'>
|
||||||
{t("home.downloads.tvseries")}
|
{t("home.downloads.movies")}
|
||||||
</Text>
|
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
|
||||||
<Text className='text-xs font-bold'>
|
|
||||||
{groupedBySeries?.length}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
|
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View className='px-4 flex flex-row'>
|
||||||
|
{movies?.map((item) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item.item}
|
||||||
|
isOffline
|
||||||
|
key={item.item.Id}
|
||||||
|
>
|
||||||
|
<MovieCard item={item.item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
)}
|
||||||
<View className='px-4 flex flex-row'>
|
{groupedBySeries.length > 0 && (
|
||||||
{groupedBySeries?.map((items) => (
|
<View className='mb-4'>
|
||||||
<View
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
className='mb-2 last:mb-0'
|
<Text className='text-lg font-bold'>
|
||||||
key={items[0].item.SeriesId}
|
{t("home.downloads.tvseries")}
|
||||||
>
|
</Text>
|
||||||
<SeriesCard
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
items={items.map((i) => i.item)}
|
<Text className='text-xs font-bold'>
|
||||||
key={items[0].item.SeriesId}
|
{groupedBySeries?.length}
|
||||||
/>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
</View>
|
<View className='px-4 flex flex-row'>
|
||||||
)}
|
{groupedBySeries?.map((items) => (
|
||||||
{downloadedFiles?.length === 0 && (
|
<View
|
||||||
<View className='flex px-4'>
|
className='mb-2 last:mb-0'
|
||||||
<Text className='opacity-50'>
|
key={items[0].item.SeriesId}
|
||||||
{t("home.downloads.no_downloaded_items")}
|
>
|
||||||
</Text>
|
<SeriesCard
|
||||||
</View>
|
items={items.map((i) => i.item)}
|
||||||
)}
|
key={items[0].item.SeriesId}
|
||||||
</View>
|
/>
|
||||||
</ScrollView>
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{downloadedFiles?.length === 0 && (
|
||||||
|
<View className='flex px-4'>
|
||||||
|
<Text className='opacity-50'>
|
||||||
|
{t("home.downloads.no_downloaded_items")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={bottomSheetModalRef}
|
ref={bottomSheetModalRef}
|
||||||
enableDynamicSizing
|
enableDynamicSizing
|
||||||
@@ -249,23 +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(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -15,29 +9,18 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ItemContent } from "@/components/ItemContent";
|
||||||
|
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const { id } = useLocalSearchParams() as { id: string };
|
const { id } = useLocalSearchParams() as { id: string };
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data: item, isError } = useQuery({
|
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||||
queryKey: ["item", id],
|
const isOffline = offline === "true";
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user || !id) return;
|
|
||||||
const res = await getUserLibraryApi(api).getItem({
|
|
||||||
itemId: id,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data;
|
const { data: item, isError } = useItemQuery(id, isOffline);
|
||||||
},
|
|
||||||
staleTime: 0,
|
|
||||||
refetchOnMount: true,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
const opacity = useSharedValue(1);
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
@@ -107,7 +90,7 @@ const Page: React.FC = () => {
|
|||||||
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||||
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
{item && <ItemContent item={item} />}
|
{item && <ItemContent item={item} isOffline={isOffline} />}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -69,10 +69,16 @@ const page: React.FC = () => {
|
|||||||
seriesId: item?.Id!,
|
seriesId: item?.Id!,
|
||||||
userId: user?.Id!,
|
userId: user?.Id!,
|
||||||
enableUserData: true,
|
enableUserData: true,
|
||||||
fields: ["MediaSources", "MediaStreams", "Overview"],
|
// Note: Including trick play is necessary to enable trick play downloads
|
||||||
|
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||||
});
|
});
|
||||||
return res?.data.Items || [];
|
return res?.data.Items || [];
|
||||||
},
|
},
|
||||||
|
select: (data) =>
|
||||||
|
// This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order.
|
||||||
|
[...(data || [])].sort(
|
||||||
|
(a, b) => (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) || (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0)
|
||||||
|
),
|
||||||
staleTime: 60,
|
staleTime: 60,
|
||||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
enabled: !!api && !!user?.Id && !!item?.Id,
|
||||||
});
|
});
|
||||||
@@ -136,7 +142,7 @@ const page: React.FC = () => {
|
|||||||
resizeMode: "contain",
|
resizeMode: "contain",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col pt-4'>
|
<View className='flex flex-col pt-4'>
|
||||||
|
|||||||
@@ -367,15 +367,7 @@ const Page = () => {
|
|||||||
className='mr-1'
|
className='mr-1'
|
||||||
id={libraryId}
|
id={libraryId}
|
||||||
queryKey='sortBy'
|
queryKey='sortBy'
|
||||||
queryFn={async () =>
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
sortOptions
|
|
||||||
.filter(
|
|
||||||
(s) =>
|
|
||||||
library?.CollectionType !== "movies" ||
|
|
||||||
s.key !== SortByOption.DateLastContentAdded,
|
|
||||||
)
|
|
||||||
.map((s) => s.key)
|
|
||||||
}
|
|
||||||
set={setSortBy}
|
set={setSortBy}
|
||||||
values={sortBy}
|
values={sortBy}
|
||||||
title={t("library.filters.sort_by")}
|
title={t("library.filters.sort_by")}
|
||||||
|
|||||||
@@ -1,33 +1,8 @@
|
|||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import React, { useLayoutEffect } from "react";
|
import React from "react";
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (Platform.isTV) return;
|
|
||||||
|
|
||||||
if (!settings.followDeviceOrientation && settings.defaultVideoOrientation) {
|
|
||||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (Platform.isTV) return;
|
|
||||||
|
|
||||||
if (settings.followDeviceOrientation === true) {
|
|
||||||
ScreenOrientation.unlockAsync();
|
|
||||||
} else {
|
|
||||||
ScreenOrientation.lockAsync(
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden />
|
<SystemBars hidden />
|
||||||
|
|||||||
@@ -1,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 { BITRATES } from "@/components/BitrateSelector";
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
import { Controls } from "@/components/video-player/controls/Controls";
|
||||||
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
import { VlcPlayerView } from "@/modules";
|
import { VlcPlayerView } from "@/modules";
|
||||||
@@ -13,41 +32,17 @@ import type {
|
|||||||
ProgressUpdatePayload,
|
ProgressUpdatePayload,
|
||||||
VlcPlayerViewRef,
|
VlcPlayerViewRef,
|
||||||
} from "@/modules/VlcPlayer.types";
|
} from "@/modules/VlcPlayer.types";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import generateDeviceProfile from "@/utils/profiles/native";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
import {
|
|
||||||
type BaseItemDto,
|
const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas";
|
||||||
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;
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||||
@@ -58,7 +53,11 @@ export default function page() {
|
|||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(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 [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
@@ -72,10 +71,7 @@ export default function page() {
|
|||||||
? null
|
? null
|
||||||
: require("react-native-volume-manager");
|
: require("react-native-volume-manager");
|
||||||
|
|
||||||
let getDownloadedItem = null;
|
const downloadUtils = useDownload();
|
||||||
if (!Platform.isTV) {
|
|
||||||
getDownloadedItem = downloadProvider.useDownload();
|
|
||||||
}
|
|
||||||
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
@@ -86,6 +82,11 @@ export default function page() {
|
|||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Persist ignoreSafeAreas state whenever it changes
|
||||||
|
useEffect(() => {
|
||||||
|
storage.set(IGNORE_SAFE_AREAS_KEY, ignoreSafeAreas);
|
||||||
|
}, [ignoreSafeAreas]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
itemId,
|
itemId,
|
||||||
audioIndex: audioIndexStr,
|
audioIndex: audioIndexStr,
|
||||||
@@ -93,6 +94,7 @@ export default function page() {
|
|||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
bitrateValue: bitrateValueStr,
|
bitrateValue: bitrateValueStr,
|
||||||
offline: offlineStr,
|
offline: offlineStr,
|
||||||
|
playbackPosition: playbackPositionFromUrl,
|
||||||
} = useGlobalSearchParams<{
|
} = useGlobalSearchParams<{
|
||||||
itemId: string;
|
itemId: string;
|
||||||
audioIndex: string;
|
audioIndex: string;
|
||||||
@@ -100,10 +102,13 @@ export default function page() {
|
|||||||
mediaSourceId: string;
|
mediaSourceId: string;
|
||||||
bitrateValue: string;
|
bitrateValue: string;
|
||||||
offline: string;
|
offline: string;
|
||||||
|
/** Playback position in ticks. */
|
||||||
|
playbackPosition?: string;
|
||||||
}>();
|
}>();
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
|
const playbackManager = usePlaybackManager();
|
||||||
|
|
||||||
const audioIndex = audioIndexStr
|
const audioIndex = audioIndexStr
|
||||||
? Number.parseInt(audioIndexStr, 10)
|
? Number.parseInt(audioIndexStr, 10)
|
||||||
@@ -116,19 +121,33 @@ export default function page() {
|
|||||||
: BITRATES[0].value;
|
: BITRATES[0].value;
|
||||||
|
|
||||||
const [item, setItem] = useState<BaseItemDto | null>(null);
|
const [item, setItem] = useState<BaseItemDto | null>(null);
|
||||||
|
const [downloadedItem, setDownloadedItem] = useState<DownloadedItem | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [itemStatus, setItemStatus] = useState({
|
const [itemStatus, setItemStatus] = useState({
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isError: false,
|
isError: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Gets the initial playback position from the URL. */
|
||||||
|
const getInitialPlaybackTicks = useCallback((): number => {
|
||||||
|
if (playbackPositionFromUrl) {
|
||||||
|
return Number.parseInt(playbackPositionFromUrl, 10);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}, [playbackPositionFromUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchItemData = async () => {
|
const fetchItemData = async () => {
|
||||||
setItemStatus({ isLoading: true, isError: false });
|
setItemStatus({ isLoading: true, isError: false });
|
||||||
try {
|
try {
|
||||||
let fetchedItem: BaseItemDto | null = null;
|
let fetchedItem: BaseItemDto | null = null;
|
||||||
if (offline && !Platform.isTV) {
|
if (offline && !Platform.isTV) {
|
||||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
const data = downloadUtils.getDownloadedItemById(itemId);
|
||||||
if (data) fetchedItem = data.item as BaseItemDto;
|
if (data) {
|
||||||
|
fetchedItem = data.item as BaseItemDto;
|
||||||
|
setDownloadedItem(data);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const res = await getUserLibraryApi(api!).getItem({
|
const res = await getUserLibraryApi(api!).getItem({
|
||||||
itemId,
|
itemId,
|
||||||
@@ -164,27 +183,31 @@ export default function page() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStreamData = async () => {
|
const fetchStreamData = async () => {
|
||||||
setStreamStatus({ isLoading: true, isError: false });
|
setStreamStatus({ isLoading: true, isError: false });
|
||||||
const native = await generateDeviceProfile();
|
|
||||||
try {
|
try {
|
||||||
let result: Stream | null = null;
|
let result: Stream | null = null;
|
||||||
if (offline && !Platform.isTV) {
|
if (offline && downloadedItem) {
|
||||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
if (!downloadedItem?.mediaSource) return;
|
||||||
if (!data?.mediaSource) return;
|
const url = downloadedItem.videoFilePath;
|
||||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
|
||||||
if (item) {
|
if (item) {
|
||||||
result = { mediaSource: data.mediaSource, sessionId: "", url };
|
result = {
|
||||||
|
mediaSource: downloadedItem.mediaSource,
|
||||||
|
sessionId: "",
|
||||||
|
url: url,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
const native = await generateDeviceProfile();
|
||||||
|
const transcoding = await generateDeviceProfile({ transcode: true });
|
||||||
const res = await getStreamUrl({
|
const res = await getStreamUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
startTimeTicks: getInitialPlaybackTicks(),
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
audioStreamIndex: audioIndex,
|
audioStreamIndex: audioIndex,
|
||||||
maxStreamingBitrate: bitrateValue,
|
maxStreamingBitrate: bitrateValue,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
subtitleStreamIndex: subtitleIndex,
|
subtitleStreamIndex: subtitleIndex,
|
||||||
deviceProfile: native,
|
deviceProfile: bitrateValue ? transcoding : native,
|
||||||
});
|
});
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
const { mediaSource, sessionId, url } = res;
|
const { mediaSource, sessionId, url } = res;
|
||||||
@@ -205,26 +228,36 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchStreamData();
|
fetchStreamData();
|
||||||
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
|
}, [
|
||||||
|
itemId,
|
||||||
|
mediaSourceId,
|
||||||
|
bitrateValue,
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
user?.Id,
|
||||||
|
downloadedItem,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!stream) return;
|
if (!stream || !api) return;
|
||||||
|
|
||||||
const reportPlaybackStart = async () => {
|
const reportPlaybackStart = async () => {
|
||||||
await getPlaystateApi(api!).reportPlaybackStart({
|
console.log("reporting playback start", currentPlayStateInfo());
|
||||||
|
await getPlaystateApi(api).reportPlaybackStart({
|
||||||
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
reportPlaybackStart();
|
reportPlaybackStart();
|
||||||
}, [stream]);
|
}, [stream, api]);
|
||||||
|
|
||||||
const togglePlay = async () => {
|
const togglePlay = async () => {
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
setIsPlaying(!isPlaying);
|
setIsPlaying(!isPlaying);
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
reportPlaybackProgress();
|
playbackManager.reportPlaybackProgress(
|
||||||
|
item?.Id!,
|
||||||
|
msToTicks(progress.get()),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
videoRef.current?.play();
|
videoRef.current?.play();
|
||||||
await getPlaystateApi(api!).reportPlaybackStart({
|
await getPlaystateApi(api!).reportPlaybackStart({
|
||||||
@@ -234,7 +267,6 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const reportPlaybackStopped = useCallback(async () => {
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
if (offline) return;
|
|
||||||
const currentTimeInTicks = msToTicks(progress.get());
|
const currentTimeInTicks = msToTicks(progress.get());
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
await getPlaystateApi(api!).onPlaybackStopped({
|
||||||
itemId: item?.Id!,
|
itemId: item?.Id!,
|
||||||
@@ -242,8 +274,6 @@ export default function page() {
|
|||||||
positionTicks: currentTimeInTicks,
|
positionTicks: currentTimeInTicks,
|
||||||
playSessionId: stream?.sessionId!,
|
playSessionId: stream?.sessionId!,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidateProgressCache();
|
|
||||||
}, [
|
}, [
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
@@ -258,6 +288,7 @@ export default function page() {
|
|||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
setIsPlaybackStopped(true);
|
setIsPlaybackStopped(true);
|
||||||
videoRef.current?.stop();
|
videoRef.current?.stop();
|
||||||
|
revalidateProgressCache();
|
||||||
}, [videoRef, reportPlaybackStopped]);
|
}, [videoRef, reportPlaybackStopped]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -296,11 +327,21 @@ export default function page() {
|
|||||||
|
|
||||||
progress.set(currentTime);
|
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,
|
item?.Id,
|
||||||
@@ -320,29 +361,10 @@ export default function page() {
|
|||||||
setIsPipStarted(pipStarted);
|
setIsPipStarted(pipStarted);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const reportPlaybackProgress = useCallback(async () => {
|
/** Gets the initial playback position in seconds. */
|
||||||
if (!api || offline || !stream) return;
|
|
||||||
await getPlaystateApi(api).reportPlaybackProgress({
|
|
||||||
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
api,
|
|
||||||
isPlaying,
|
|
||||||
offline,
|
|
||||||
stream,
|
|
||||||
item?.Id,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
progress,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const startPosition = useMemo(() => {
|
const startPosition = useMemo(() => {
|
||||||
if (offline) return 0;
|
return ticksToSeconds(getInitialPlaybackTicks());
|
||||||
return item?.UserData?.PlaybackPositionTicks
|
}, [getInitialPlaybackTicks]);
|
||||||
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
|
||||||
: 0;
|
|
||||||
}, [item, offline]);
|
|
||||||
|
|
||||||
const volumeUpCb = useCallback(async () => {
|
const volumeUpCb = useCallback(async () => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
@@ -427,14 +449,24 @@ export default function page() {
|
|||||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||||
if (state === "Playing") {
|
if (state === "Playing") {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
reportPlaybackProgress();
|
if (item?.Id) {
|
||||||
|
playbackManager.reportPlaybackProgress(
|
||||||
|
item.Id,
|
||||||
|
msToTicks(progress.get()),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!Platform.isTV) await activateKeepAwakeAsync();
|
if (!Platform.isTV) await activateKeepAwakeAsync();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === "Paused") {
|
if (state === "Paused") {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
reportPlaybackProgress();
|
if (item?.Id) {
|
||||||
|
playbackManager.reportPlaybackProgress(
|
||||||
|
item.Id,
|
||||||
|
msToTicks(progress.get()),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!Platform.isTV) await deactivateKeepAwake();
|
if (!Platform.isTV) await deactivateKeepAwake();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -446,7 +478,7 @@ export default function page() {
|
|||||||
setIsBuffering(true);
|
setIsBuffering(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[reportPlaybackProgress],
|
[playbackManager, item?.Id, progress],
|
||||||
);
|
);
|
||||||
|
|
||||||
const allAudio =
|
const allAudio =
|
||||||
@@ -464,25 +496,29 @@ export default function page() {
|
|||||||
.filter((sub: any) => sub.DeliveryMethod === "External")
|
.filter((sub: any) => sub.DeliveryMethod === "External")
|
||||||
.map((sub: any) => ({
|
.map((sub: any) => ({
|
||||||
name: sub.DisplayTitle,
|
name: sub.DisplayTitle,
|
||||||
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
|
DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
|
||||||
}));
|
}));
|
||||||
|
/** The text based subtitle tracks */
|
||||||
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
||||||
|
/** The user chosen subtitle track from the server */
|
||||||
const chosenSubtitleTrack = allSubs.find(
|
const chosenSubtitleTrack = allSubs.find(
|
||||||
(sub) => sub.Index === subtitleIndex,
|
(sub) => sub.Index === subtitleIndex,
|
||||||
);
|
);
|
||||||
|
/** The user chosen audio track from the server */
|
||||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||||
|
/** Whether the stream we're playing is not transcoding*/
|
||||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||||
|
/** The initial options to pass to the VLC Player */
|
||||||
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||||
if (
|
if (
|
||||||
chosenSubtitleTrack &&
|
chosenSubtitleTrack &&
|
||||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||||
) {
|
) {
|
||||||
|
// If not transcoding, we can the index as normal.
|
||||||
|
// If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
|
||||||
const finalIndex = notTranscoding
|
const finalIndex = notTranscoding
|
||||||
? allSubs.indexOf(chosenSubtitleTrack)
|
? allSubs.indexOf(chosenSubtitleTrack)
|
||||||
: textSubs.indexOf(chosenSubtitleTrack);
|
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
|
||||||
initOptions.push(`--sub-track=${finalIndex}`);
|
initOptions.push(`--sub-track=${finalIndex}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,7 +534,7 @@ export default function page() {
|
|||||||
return () => setIsMounted(false);
|
return () => setIsMounted(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (itemStatus.isLoading || streamStatus.isLoading) {
|
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
|
||||||
return (
|
return (
|
||||||
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
||||||
<Loader />
|
<Loader />
|
||||||
@@ -532,7 +568,7 @@ export default function page() {
|
|||||||
source={{
|
source={{
|
||||||
uri: stream?.url || "",
|
uri: stream?.url || "",
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
isNetwork: true,
|
isNetwork: !offline,
|
||||||
startPosition,
|
startPosition,
|
||||||
externalSubtitles,
|
externalSubtitles,
|
||||||
initOptions,
|
initOptions,
|
||||||
@@ -555,7 +591,7 @@ export default function page() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{videoRef.current && !isPipStarted && isMounted === true ? (
|
{videoRef.current && !isPipStarted && isMounted === true && item ? (
|
||||||
<Controls
|
<Controls
|
||||||
mediaSource={stream?.mediaSource}
|
mediaSource={stream?.mediaSource}
|
||||||
item={item}
|
item={item}
|
||||||
|
|||||||
206
app/_layout.tsx
206
app/_layout.tsx
@@ -7,7 +7,6 @@ import {
|
|||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
getTokenFromStorage,
|
getTokenFromStorage,
|
||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -23,7 +22,6 @@ import {
|
|||||||
writeToLog,
|
writeToLog,
|
||||||
} from "@/utils/log";
|
} from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
|
||||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
@@ -137,16 +135,13 @@ if (!Platform.isTV) {
|
|||||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||||
console.log("TaskManager ~ trigger");
|
console.log("TaskManager ~ trigger");
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
const settingsData = storage.getString("settings");
|
const settingsData = storage.getString("settings");
|
||||||
|
|
||||||
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||||
const url = settings?.optimizedVersionsServerUrl;
|
|
||||||
|
|
||||||
if (!settings?.autoDownload || !url)
|
if (!settings?.autoDownload)
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const token = getTokenFromStorage();
|
const token = getTokenFromStorage();
|
||||||
@@ -156,74 +151,6 @@ if (!Platform.isTV) {
|
|||||||
if (!token || !deviceId || !baseDirectory)
|
if (!token || !deviceId || !baseDirectory)
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const jobs = await getAllJobsByDeviceId({
|
|
||||||
deviceId,
|
|
||||||
authHeader: token,
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
|
||||||
|
|
||||||
for (const job of jobs) {
|
|
||||||
if (job.status === "completed") {
|
|
||||||
const downloadUrl = `${url}download/${job.id}`;
|
|
||||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
|
||||||
|
|
||||||
if (tasks.find((task: { id: string }) => task.id === job.id)) {
|
|
||||||
console.log("TaskManager ~ Download already in progress: ", job.id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
BackGroundDownloader.download({
|
|
||||||
id: job.id,
|
|
||||||
url: downloadUrl,
|
|
||||||
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
|
||||||
headers: {
|
|
||||||
Authorization: token,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.begin(() => {
|
|
||||||
console.log("TaskManager ~ Download started: ", job.id);
|
|
||||||
})
|
|
||||||
.done(() => {
|
|
||||||
console.log("TaskManager ~ Download completed: ", job.id);
|
|
||||||
saveDownloadedItemInfo(job.item);
|
|
||||||
BackGroundDownloader.completeHandler(job.id);
|
|
||||||
cancelJobById({
|
|
||||||
authHeader: token,
|
|
||||||
id: job.id,
|
|
||||||
url: url,
|
|
||||||
});
|
|
||||||
Notifications.scheduleNotificationAsync({
|
|
||||||
content: {
|
|
||||||
title: job.item.Name,
|
|
||||||
body: "Download completed",
|
|
||||||
data: {
|
|
||||||
url: "/downloads",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
trigger: null,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.error((error: any) => {
|
|
||||||
console.log("TaskManager ~ Download error: ", job.id, error);
|
|
||||||
BackGroundDownloader.completeHandler(job.id);
|
|
||||||
Notifications.scheduleNotificationAsync({
|
|
||||||
content: {
|
|
||||||
title: job.item.Name,
|
|
||||||
body: "Download failed",
|
|
||||||
data: {
|
|
||||||
url: "/downloads",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
trigger: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
|
||||||
|
|
||||||
// Be sure to return the successful result type!
|
// Be sure to return the successful result type!
|
||||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||||
});
|
});
|
||||||
@@ -414,21 +341,32 @@ function Layout() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) {
|
||||||
if (segments.includes("direct-player" as never)) {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.includes("direct-player" as never)) {
|
||||||
|
if (
|
||||||
|
!settings.followDeviceOrientation &&
|
||||||
|
settings.defaultVideoOrientation
|
||||||
|
) {
|
||||||
|
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the user has auto rotate enabled, unlock the orientation
|
|
||||||
if (settings.followDeviceOrientation === true) {
|
if (settings.followDeviceOrientation === true) {
|
||||||
ScreenOrientation.unlockAsync();
|
ScreenOrientation.unlockAsync();
|
||||||
} else {
|
} else {
|
||||||
// If the user has auto rotate disabled, lock the orientation to portrait
|
|
||||||
ScreenOrientation.lockAsync(
|
ScreenOrientation.lockAsync(
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [settings.followDeviceOrientation, segments]);
|
}, [
|
||||||
|
settings.followDeviceOrientation,
|
||||||
|
settings.defaultVideoOrientation,
|
||||||
|
segments,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = AppState.addEventListener(
|
const subscription = AppState.addEventListener(
|
||||||
@@ -453,64 +391,62 @@ function Layout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<JobQueueProvider>
|
<JellyfinProvider>
|
||||||
<JellyfinProvider>
|
<PlaySettingsProvider>
|
||||||
<PlaySettingsProvider>
|
<LogProvider>
|
||||||
<LogProvider>
|
<WebSocketProvider>
|
||||||
<WebSocketProvider>
|
<DownloadProvider>
|
||||||
<DownloadProvider>
|
<BottomSheetModalProvider>
|
||||||
<BottomSheetModalProvider>
|
<SystemBars style='light' hidden={false} />
|
||||||
<SystemBars style='light' hidden={false} />
|
<ThemeProvider value={DarkTheme}>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name='(auth)/(tabs)'
|
||||||
name='(auth)/(tabs)'
|
options={{
|
||||||
options={{
|
headerShown: false,
|
||||||
headerShown: false,
|
title: "",
|
||||||
title: "",
|
header: () => null,
|
||||||
header: () => null,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/player'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
title: "",
|
|
||||||
header: () => null,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='login'
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: "",
|
|
||||||
headerTransparent: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name='+not-found' />
|
|
||||||
</Stack>
|
|
||||||
<Toaster
|
|
||||||
duration={4000}
|
|
||||||
toastOptions={{
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#262626",
|
|
||||||
borderColor: "#363639",
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
titleStyle: {
|
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
closeButton
|
|
||||||
/>
|
/>
|
||||||
</ThemeProvider>
|
<Stack.Screen
|
||||||
</BottomSheetModalProvider>
|
name='(auth)/player'
|
||||||
</DownloadProvider>
|
options={{
|
||||||
</WebSocketProvider>
|
headerShown: false,
|
||||||
</LogProvider>
|
title: "",
|
||||||
</PlaySettingsProvider>
|
header: () => null,
|
||||||
</JellyfinProvider>
|
}}
|
||||||
</JobQueueProvider>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='login'
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
title: "",
|
||||||
|
headerTransparent: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name='+not-found' />
|
||||||
|
</Stack>
|
||||||
|
<Toaster
|
||||||
|
duration={4000}
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderColor: "#363639",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
titleStyle: {
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
closeButton
|
||||||
|
/>
|
||||||
|
</ThemeProvider>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
|
</DownloadProvider>
|
||||||
|
</WebSocketProvider>
|
||||||
|
</LogProvider>
|
||||||
|
</PlaySettingsProvider>
|
||||||
|
</JellyfinProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
20
biome.json
20
biome.json
@@ -1,16 +1,14 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.0.5/schema.json",
|
||||||
"organizeImports": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"files": {
|
"files": {
|
||||||
"ignore": [
|
"includes": [
|
||||||
"node_modules",
|
"**/*",
|
||||||
"ios",
|
"!node_modules/**",
|
||||||
"android",
|
"!ios/**",
|
||||||
"Streamyfin.app",
|
"!android/**",
|
||||||
"utils/jellyseerr",
|
"!Streamyfin.app/**",
|
||||||
".expo"
|
"!utils/jellyseerr/**",
|
||||||
|
"!.expo/**"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useMemo } from "react";
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { ProgressBar } from "./common/ProgressBar";
|
||||||
import { WatchedIndicator } from "./WatchedIndicator";
|
import { WatchedIndicator } from "./WatchedIndicator";
|
||||||
|
|
||||||
type ContinueWatchingPosterProps = {
|
type ContinueWatchingPosterProps = {
|
||||||
@@ -62,18 +63,6 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
const progress = useMemo(() => {
|
|
||||||
if (item.Type === "Program") {
|
|
||||||
const startDate = new Date(item.StartDate || "");
|
|
||||||
const endDate = new Date(item.EndDate || "");
|
|
||||||
const now = new Date();
|
|
||||||
const total = endDate.getTime() - startDate.getTime();
|
|
||||||
const elapsed = now.getTime() - startDate.getTime();
|
|
||||||
return (elapsed / total) * 100;
|
|
||||||
}
|
|
||||||
return item.UserData?.PlayedPercentage || 0;
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
if (!url)
|
if (!url)
|
||||||
return <View className='aspect-video border border-neutral-800 w-44' />;
|
return <View className='aspect-video border border-neutral-800 w-44' />;
|
||||||
|
|
||||||
@@ -101,22 +90,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
{!progress && <WatchedIndicator item={item} />}
|
{!item.UserData?.Played && <WatchedIndicator item={item} />}
|
||||||
{progress > 0 && (
|
<ProgressBar item={item} />
|
||||||
<>
|
|
||||||
<View
|
|
||||||
className={
|
|
||||||
"absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: `${progress}%`,
|
|
||||||
}}
|
|
||||||
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,3 @@
|
|||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
|
||||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
|
||||||
import download from "@/utils/profiles/download";
|
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
@@ -22,17 +14,23 @@ import { t } from "i18next";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
|
import { getDownloadUrl } from "@/utils/jellyfin/media/getDownloadUrl";
|
||||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||||
import { type Bitrate, BitrateSelector } from "./BitrateSelector";
|
import { type Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import ProgressCircle from "./ProgressCircle";
|
import ProgressCircle from "./ProgressCircle";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||||
import { Text } from "./common/Text";
|
|
||||||
|
|
||||||
interface DownloadProps extends ViewProps {
|
interface DownloadProps extends ViewProps {
|
||||||
items: BaseItemDto[];
|
items: BaseItemDto[];
|
||||||
@@ -54,11 +52,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [queue, _setQueue] = useAtom(queueAtom);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
||||||
|
|
||||||
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
||||||
//const { startRemuxing } = useRemuxHlsToMp4();
|
|
||||||
|
|
||||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||||
MediaSourceInfo | undefined | null
|
MediaSourceInfo | undefined | null
|
||||||
@@ -77,10 +75,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
() => user?.Policy?.EnableContentDownloading,
|
() => user?.Policy?.EnableContentDownloading,
|
||||||
[user],
|
[user],
|
||||||
);
|
);
|
||||||
const usingOptimizedServer = useMemo(
|
|
||||||
() => settings?.downloadMethod === DownloadMethod.Optimized,
|
|
||||||
[settings],
|
|
||||||
);
|
|
||||||
|
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
@@ -88,7 +82,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
bottomSheetModalRef.current?.present();
|
bottomSheetModalRef.current?.present();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((index: number) => {}, []);
|
const handleSheetChanges = useCallback((_index: number) => { }, []);
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
const closeModal = useCallback(() => {
|
||||||
bottomSheetModalRef.current?.dismiss();
|
bottomSheetModalRef.current?.dismiss();
|
||||||
@@ -102,6 +96,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
[items, downloadedFiles],
|
[items, downloadedFiles],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const itemsToDownload = useMemo(() => {
|
||||||
|
if (downloadUnwatchedOnly) {
|
||||||
|
return itemsNotDownloaded.filter((item) => !item.UserData?.Played);
|
||||||
|
}
|
||||||
|
return itemsNotDownloaded;
|
||||||
|
}, [itemsNotDownloaded, downloadUnwatchedOnly]);
|
||||||
|
|
||||||
const allItemsDownloaded = useMemo(() => {
|
const allItemsDownloaded = useMemo(() => {
|
||||||
if (items.length === 0) return false;
|
if (items.length === 0) return false;
|
||||||
return itemsNotDownloaded.length === 0;
|
return itemsNotDownloaded.length === 0;
|
||||||
@@ -136,39 +137,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
firstItem.Type !== "Episode"
|
firstItem.Type !== "Episode"
|
||||||
? "/downloads"
|
? "/downloads"
|
||||||
: ({
|
: ({
|
||||||
pathname: `/downloads/${firstItem.SeriesId}`,
|
pathname: `/downloads/${firstItem.SeriesId}`,
|
||||||
params: {
|
params: {
|
||||||
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
||||||
},
|
},
|
||||||
} as Href),
|
} as Href),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const acceptDownloadOptions = useCallback(() => {
|
|
||||||
if (userCanDownload === true) {
|
|
||||||
if (itemsNotDownloaded.some((i) => !i.Id)) {
|
|
||||||
throw new Error("No item id");
|
|
||||||
}
|
|
||||||
closeModal();
|
|
||||||
|
|
||||||
initiateDownload(...itemsNotDownloaded);
|
|
||||||
} else {
|
|
||||||
toast.error(
|
|
||||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
queue,
|
|
||||||
setQueue,
|
|
||||||
itemsNotDownloaded,
|
|
||||||
usingOptimizedServer,
|
|
||||||
userCanDownload,
|
|
||||||
maxBitrate,
|
|
||||||
selectedMediaSource,
|
|
||||||
selectedAudioStream,
|
|
||||||
selectedSubtitleStream,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const initiateDownload = useCallback(
|
const initiateDownload = useCallback(
|
||||||
async (...items: BaseItemDto[]) => {
|
async (...items: BaseItemDto[]) => {
|
||||||
if (
|
if (
|
||||||
@@ -181,46 +157,53 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
"DownloadItem ~ initiateDownload: No api or user or item",
|
"DownloadItem ~ initiateDownload: No api or user or item",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let mediaSource = selectedMediaSource;
|
const downloadDetailsPromises = items.map(async (item) => {
|
||||||
let audioIndex: number | undefined = selectedAudioStream;
|
const { mediaSource, audioIndex, subtitleIndex } =
|
||||||
let subtitleIndex: number | undefined = selectedSubtitleStream;
|
itemsNotDownloaded.length > 1
|
||||||
|
? getDefaultPlaySettings(item, settings!)
|
||||||
|
: {
|
||||||
|
mediaSource: selectedMediaSource,
|
||||||
|
audioIndex: selectedAudioStream,
|
||||||
|
subtitleIndex: selectedSubtitleStream,
|
||||||
|
};
|
||||||
|
|
||||||
for (const item of items) {
|
const downloadDetails = await getDownloadUrl({
|
||||||
if (itemsNotDownloaded.length > 1) {
|
|
||||||
const defaults = getDefaultPlaySettings(item, settings!);
|
|
||||||
mediaSource = defaults.mediaSource;
|
|
||||||
audioIndex = defaults.audioIndex;
|
|
||||||
subtitleIndex = defaults.subtitleIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
startTimeTicks: 0,
|
userId: user.Id!,
|
||||||
userId: user?.Id,
|
mediaSource: mediaSource!,
|
||||||
audioStreamIndex: audioIndex,
|
audioStreamIndex: audioIndex ?? -1,
|
||||||
maxStreamingBitrate: maxBitrate.value,
|
subtitleStreamIndex: subtitleIndex ?? -1,
|
||||||
mediaSourceId: mediaSource?.Id,
|
maxBitrate,
|
||||||
subtitleStreamIndex: subtitleIndex,
|
deviceId: api.deviceInfo.id,
|
||||||
deviceProfile: download,
|
|
||||||
download: true,
|
|
||||||
// deviceId: mediaSource?.Id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res) {
|
return {
|
||||||
|
url: downloadDetails?.url,
|
||||||
|
item,
|
||||||
|
mediaSource: downloadDetails?.mediaSource,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const downloadDetails = await Promise.all(downloadDetailsPromises);
|
||||||
|
for (const { url, item, mediaSource } of downloadDetails) {
|
||||||
|
if (!url) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.downloads.something_went_wrong"),
|
t("home.downloads.something_went_wrong"),
|
||||||
t("home.downloads.could_not_get_stream_url_from_jellyfin"),
|
t("home.downloads.could_not_get_stream_url_from_jellyfin"),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!mediaSource) {
|
||||||
const { mediaSource: source, url } = res;
|
console.error(`Could not get download URL for ${item.Name}`);
|
||||||
|
toast.error(
|
||||||
if (!url || !source) throw new Error("No url");
|
t("Could not get download URL for {{itemName}}", {
|
||||||
|
itemName: item.Name,
|
||||||
saveDownloadItemInfoToDiskTmp(item, source, url);
|
}),
|
||||||
await startBackgroundDownload(url, item, source, maxBitrate);
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await startBackgroundDownload(url, item, mediaSource, maxBitrate);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -232,11 +215,25 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
selectedSubtitleStream,
|
selectedSubtitleStream,
|
||||||
settings,
|
settings,
|
||||||
maxBitrate,
|
maxBitrate,
|
||||||
usingOptimizedServer,
|
|
||||||
startBackgroundDownload,
|
startBackgroundDownload,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const acceptDownloadOptions = useCallback(() => {
|
||||||
|
if (userCanDownload === true) {
|
||||||
|
if (itemsToDownload.some((i) => !i.Id)) {
|
||||||
|
throw new Error("No item id");
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
|
||||||
|
initiateDownload(...itemsToDownload);
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [closeModal, initiateDownload, itemsToDownload, userCanDownload]);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
<BottomSheetBackdrop
|
<BottomSheetBackdrop
|
||||||
@@ -253,7 +250,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
if (itemsNotDownloaded.length !== 1) return;
|
if (itemsNotDownloaded.length !== 1) return;
|
||||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||||
getDefaultPlaySettings(items[0], settings);
|
getDefaultPlaySettings(items[0], settings);
|
||||||
|
|
||||||
setSelectedMediaSource(mediaSource ?? undefined);
|
setSelectedMediaSource(mediaSource ?? undefined);
|
||||||
setSelectedAudioStream(audioIndex ?? 0);
|
setSelectedAudioStream(audioIndex ?? 0);
|
||||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||||
@@ -327,7 +323,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
<Text className='text-neutral-300'>
|
<Text className='text-neutral-300'>
|
||||||
{subtitle ||
|
{subtitle ||
|
||||||
t("item_card.download.download_x_item", {
|
t("item_card.download.download_x_item", {
|
||||||
item_count: itemsNotDownloaded.length,
|
item_count: itemsToDownload.length,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -337,6 +333,15 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
onChange={setMaxBitrate}
|
onChange={setMaxBitrate}
|
||||||
selected={maxBitrate}
|
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 && (
|
{itemsNotDownloaded.length === 1 && (
|
||||||
<>
|
<>
|
||||||
<MediaSourceSelector
|
<MediaSourceSelector
|
||||||
@@ -361,6 +366,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className='mt-auto'
|
className='mt-auto'
|
||||||
onPress={acceptDownloadOptions}
|
onPress={acceptDownloadOptions}
|
||||||
@@ -368,13 +374,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
>
|
>
|
||||||
{t("item_card.download.download_button")}
|
{t("item_card.download.download_button")}
|
||||||
</Button>
|
</Button>
|
||||||
<View className='opacity-70 text-center w-full flex items-center'>
|
|
||||||
<Text className='text-xs'>
|
|
||||||
{usingOptimizedServer
|
|
||||||
? t("item_card.download.using_optimized_server")
|
|
||||||
: t("item_card.download.using_default_method")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
</BottomSheetView>
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
|
|||||||
@@ -1,25 +1,3 @@
|
|||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
|
||||||
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
|
||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
|
||||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
|
||||||
import { SimilarItems } from "@/components/SimilarItems";
|
|
||||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
|
||||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
|
||||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
|
||||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
|
||||||
import { useImageColors } from "@/hooks/useImageColors";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
@@ -30,12 +8,35 @@ import { useAtom } from "jotai";
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||||
|
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||||
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
||||||
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
|
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||||
|
import { SimilarItems } from "@/components/SimilarItems";
|
||||||
|
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||||
|
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||||
|
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||||
|
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||||
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
|
import { 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 { AddToFavorites } from "./AddToFavorites";
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||||
|
|
||||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||||
|
|
||||||
export type SelectedOptions = {
|
export type SelectedOptions = {
|
||||||
@@ -45,8 +46,13 @@ export type SelectedOptions = {
|
|||||||
subtitleIndex: number;
|
subtitleIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
interface ItemContentProps {
|
||||||
({ item }) => {
|
item: BaseItemDto;
|
||||||
|
isOffline: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||||
|
({ item, isOffline }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
@@ -68,66 +74,75 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
defaultSubtitleIndex,
|
defaultSubtitleIndex,
|
||||||
} = useDefaultPlaySettings(item, settings);
|
} = useDefaultPlaySettings(item!, settings);
|
||||||
|
|
||||||
|
const logoUrl = useMemo(
|
||||||
|
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||||
|
[api, item],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loading = useMemo(() => {
|
||||||
|
return Boolean(logoUrl && loadingLogo);
|
||||||
|
}, [loadingLogo, logoUrl]);
|
||||||
|
|
||||||
// Needs to automatically change the selected to the default values for default indexes.
|
// Needs to automatically change the selected to the default values for default indexes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedOptions(() => ({
|
if (item) {
|
||||||
bitrate: defaultBitrate,
|
setSelectedOptions(() => ({
|
||||||
mediaSource: defaultMediaSource,
|
bitrate: defaultBitrate,
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
mediaSource: defaultMediaSource,
|
||||||
audioIndex: defaultAudioIndex,
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
}));
|
audioIndex: defaultAudioIndex,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}, [
|
}, [
|
||||||
defaultAudioIndex,
|
defaultAudioIndex,
|
||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
defaultSubtitleIndex,
|
defaultSubtitleIndex,
|
||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
|
item,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
useEffect(() => {
|
||||||
useEffect(() => {
|
if (Platform.isTV) {
|
||||||
navigation.setOptions({
|
return;
|
||||||
headerRight: () =>
|
}
|
||||||
item && (
|
navigation.setOptions({
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
headerRight: () =>
|
||||||
<Chromecast.Chromecast
|
item && (
|
||||||
background='blur'
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
width={22}
|
<Chromecast.Chromecast background='blur' width={22} height={22} />
|
||||||
height={22}
|
{item.Type !== "Program" && (
|
||||||
/>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
{item.Type !== "Program" && (
|
{!Platform.isTV && !isOffline && (
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<DownloadSingleItem item={item} size='large' />
|
||||||
{!Platform.isTV && (
|
)}
|
||||||
<DownloadSingleItem item={item} size='large' />
|
{user?.Policy?.IsAdministrator && !isOffline && (
|
||||||
)}
|
<PlayInRemoteSessionButton item={item} size='large' />
|
||||||
{user?.Policy?.IsAdministrator && (
|
)}
|
||||||
<PlayInRemoteSessionButton item={item} size='large' />
|
<PlayedStatus
|
||||||
)}
|
items={[item]}
|
||||||
|
size='large'
|
||||||
<PlayedStatus items={[item]} size='large' />
|
isOffline={isOffline}
|
||||||
<AddToFavorites item={item} />
|
/>
|
||||||
</View>
|
{!isOffline && <AddToFavorites item={item} />}
|
||||||
)}
|
</View>
|
||||||
</View>
|
)}
|
||||||
),
|
</View>
|
||||||
});
|
),
|
||||||
}, [item]);
|
});
|
||||||
}
|
}, [item, navigation, isOffline, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
if (item) {
|
||||||
setHeaderHeight(230);
|
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||||
else if (item.Type === "Movie") setHeaderHeight(500);
|
setHeaderHeight(230);
|
||||||
else setHeaderHeight(350);
|
else if (item.Type === "Movie") setHeaderHeight(500);
|
||||||
}, [item.Type, orientation]);
|
else setHeaderHeight(350);
|
||||||
|
}
|
||||||
|
}, [item, orientation]);
|
||||||
|
|
||||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
if (!item || !selectedOptions) return null;
|
||||||
|
|
||||||
const loading = useMemo(() => {
|
|
||||||
return Boolean(logoUrl && loadingLogo);
|
|
||||||
}, [loadingLogo, logoUrl]);
|
|
||||||
if (!selectedOptions) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -168,13 +183,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
onLoad={() => setLoadingLogo(false)}
|
onLoad={() => setLoadingLogo(false)}
|
||||||
onError={() => setLoadingLogo(false)}
|
onError={() => setLoadingLogo(false)}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col bg-transparent shrink'>
|
<View className='flex flex-col bg-transparent shrink'>
|
||||||
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
||||||
<ItemHeader item={item} className='mb-4' />
|
<ItemHeader item={item} className='mb-4' />
|
||||||
{item.Type !== "Program" && !Platform.isTV && (
|
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
||||||
<View className='flex flex-row items-center justify-start w-full h-16'>
|
<View className='flex flex-row items-center justify-start w-full h-16'>
|
||||||
<BitrateSelector
|
<BitrateSelector
|
||||||
className='mr-1'
|
className='mr-1'
|
||||||
@@ -233,25 +248,34 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
className='grow'
|
className='grow'
|
||||||
selectedOptions={selectedOptions}
|
selectedOptions={selectedOptions}
|
||||||
item={item}
|
item={item}
|
||||||
|
isOffline={isOffline}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
<SeasonEpisodesCarousel
|
||||||
|
item={item}
|
||||||
|
loading={loading}
|
||||||
|
isOffline={isOffline}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
{!isOffline && (
|
||||||
|
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||||
|
)}
|
||||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||||
|
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<>
|
<>
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && !isOffline && (
|
||||||
<CurrentSeries item={item} className='mb-4' />
|
<CurrentSeries item={item} className='mb-4' />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
{!isOffline && (
|
||||||
|
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
||||||
|
)}
|
||||||
|
|
||||||
{item.People && item.People.length > 0 && (
|
{item.People && item.People.length > 0 && !isOffline && (
|
||||||
<View className='mb-4'>
|
<View className='mb-4'>
|
||||||
{item.People.slice(0, 3).map((person, idx) => (
|
{item.People.slice(0, 3).map((person, idx) => (
|
||||||
<MoreMoviesWithActor
|
<MoreMoviesWithActor
|
||||||
@@ -264,7 +288,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SimilarItems itemId={item.Id} />
|
{!isOffline && <SimilarItems itemId={item.Id} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,13 +1,3 @@
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { chromecast } from "@/utils/profiles/chromecast";
|
|
||||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
@@ -15,7 +5,6 @@ import { useRouter } from "expo-router";
|
|||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, Pressable } from "react-native";
|
|
||||||
import { Alert, TouchableOpacity, View } from "react-native";
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
CastButton,
|
||||||
@@ -33,12 +22,23 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import { chromecast } from "@/utils/profiles/chromecast";
|
||||||
|
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import type { Button } from "./Button";
|
import type { Button } from "./Button";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
selectedOptions: SelectedOptions;
|
selectedOptions: SelectedOptions;
|
||||||
|
isOffline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANIMATION_DURATION = 500;
|
const ANIMATION_DURATION = 500;
|
||||||
@@ -47,6 +47,7 @@ const MIN_PLAYBACK_WIDTH = 15;
|
|||||||
export const PlayButton: React.FC<Props> = ({
|
export const PlayButton: React.FC<Props> = ({
|
||||||
item,
|
item,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
|
isOffline,
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
@@ -76,7 +77,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
router.push(`/player/direct-player?${q}`);
|
router.push(`/player/direct-player?${q}`);
|
||||||
},
|
},
|
||||||
[router],
|
[router, isOffline],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
@@ -91,6 +92,8 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||||
|
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
offline: isOffline ? "true" : "false",
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryString = queryParams.toString();
|
const queryString = queryParams.toString();
|
||||||
|
|||||||
@@ -1,50 +1,22 @@
|
|||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
items: BaseItemDto[];
|
items: BaseItemDto[];
|
||||||
|
isOffline?: boolean;
|
||||||
size?: "default" | "large";
|
size?: "default" | "large";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
export const PlayedStatus: React.FC<Props> = ({
|
||||||
const queryClient = useQueryClient();
|
items,
|
||||||
|
isOffline = false,
|
||||||
const invalidateQueries = () => {
|
...props
|
||||||
items.forEach((item) => {
|
}) => {
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["item", item.Id],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["resumeItems"],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["continueWatching"],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["nextUp-all"],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["nextUp"],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["episodes"],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["seasons"],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["home"],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const allPlayed = items.every((item) => item.UserData?.Played);
|
const allPlayed = items.every((item) => item.UserData?.Played);
|
||||||
|
const toggle = useMarkAsPlayed(items, isOffline);
|
||||||
const markAsPlayedStatus = useMarkAsPlayed(items);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
@@ -52,8 +24,7 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
|||||||
fillColor={allPlayed ? "primary" : undefined}
|
fillColor={allPlayed ? "primary" : undefined}
|
||||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
console.log(allPlayed);
|
await toggle(!allPlayed);
|
||||||
await markAsPlayedStatus(!allPlayed);
|
|
||||||
}}
|
}}
|
||||||
size={props.size}
|
size={props.size}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
if (Platform.isTV) return null;
|
const { t } = useTranslation();
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||||
}, [source]);
|
}, [source]);
|
||||||
@@ -28,9 +28,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
[subtitleStreams, selected],
|
[subtitleStreams, selected],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (subtitleStreams?.length === 0) return null;
|
if (Platform.isTV || subtitleStreams?.length === 0) return null;
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
|
|||||||
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 { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
@@ -8,9 +6,12 @@ import type {
|
|||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { type PropsWithChildren, useCallback } from "react";
|
import { type PropsWithChildren, useCallback } from "react";
|
||||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||||
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
|
isOffline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const itemRouter = (
|
export const itemRouter = (
|
||||||
@@ -50,6 +51,7 @@ export const itemRouter = (
|
|||||||
|
|
||||||
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||||
item,
|
item,
|
||||||
|
isOffline = false,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
@@ -105,7 +107,10 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onLongPress={showActionSheet}
|
onLongPress={showActionSheet}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
const url = itemRouter(item, from);
|
let url = itemRouter(item, from);
|
||||||
|
if (isOffline) {
|
||||||
|
url += `&offline=true`;
|
||||||
|
}
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.push(url);
|
router.push(url);
|
||||||
}}
|
}}
|
||||||
@@ -114,4 +119,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
{children}
|
{children}
|
||||||
</TouchableOpacity>
|
</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 { Ionicons } from "@expo/vector-icons";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
@@ -19,12 +13,22 @@ import {
|
|||||||
type ViewProps,
|
type ViewProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { toast } from "sonner-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";
|
import { Button } from "../Button";
|
||||||
|
|
||||||
const BackGroundDownloader = !Platform.isTV
|
const BackGroundDownloader = !Platform.isTV
|
||||||
? require("@kesha-antonov/react-native-background-downloader")
|
? require("@kesha-antonov/react-native-background-downloader")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps { }
|
||||||
|
|
||||||
|
const bytesToMB = (bytes: number) => {
|
||||||
|
return bytes / 1024 / 1024;
|
||||||
|
};
|
||||||
|
|
||||||
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
||||||
const { processes } = useDownload();
|
const { processes } = useDownload();
|
||||||
@@ -59,32 +63,18 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||||
const { processes, startDownload } = useDownload();
|
const { startDownload, removeProcess } = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { removeProcess, setProcesses } = useDownload();
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const cancelJobMutation = useMutation({
|
const cancelJobMutation = useMutation({
|
||||||
mutationFn: async (id: string) => {
|
mutationFn: async (id: string) => {
|
||||||
if (!process) throw new Error("No active download");
|
if (!process) throw new Error("No active download");
|
||||||
|
removeProcess(id);
|
||||||
try {
|
|
||||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
|
||||||
for (const task of tasks) {
|
|
||||||
if (task.id === id) {
|
|
||||||
task.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await removeProcess(id);
|
|
||||||
if (settings?.downloadMethod === DownloadMethod.Optimized) {
|
|
||||||
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(t("home.downloads.toasts.download_cancelled"));
|
toast.success(t("home.downloads.toasts.download_cancelled"));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["downloads"] });
|
||||||
},
|
},
|
||||||
onError: (e) => {
|
onError: (e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -93,11 +83,14 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const eta = (p: JobStatus) => {
|
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 bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
|
||||||
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
|
if (bytesRemaining <= 0) return null;
|
||||||
return formatTimeString(timeLeft, "tick");
|
|
||||||
|
const secondsRemaining = bytesRemaining / p.speed;
|
||||||
|
|
||||||
|
return formatTimeString(secondsRemaining, "s");
|
||||||
};
|
};
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
@@ -110,8 +103,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
|
className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{(process.status === "optimizing" ||
|
{process.status === "downloading" && (
|
||||||
process.status === "downloading") && (
|
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
bg-purple-600 h-1 absolute bottom-0 left-0
|
bg-purple-600 h-1 absolute bottom-0 left-0
|
||||||
@@ -151,8 +143,10 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
) : (
|
) : (
|
||||||
<Text className='text-xs'>{process.progress.toFixed(0)}%</Text>
|
<Text className='text-xs'>{process.progress.toFixed(0)}%</Text>
|
||||||
)}
|
)}
|
||||||
{process.speed && (
|
{process.speed && process.speed > 0 && (
|
||||||
<Text className='text-xs'>{process.speed?.toFixed(2)}x</Text>
|
<Text className='text-xs'>
|
||||||
|
{bytesToMB(process.speed).toFixed(2)} MB/s
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
{eta(process) && (
|
{eta(process) && (
|
||||||
<Text className='text-xs'>
|
<Text className='text-xs'>
|
||||||
|
|||||||
@@ -1,25 +1,16 @@
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import {
|
import {
|
||||||
ActionSheetProvider,
|
ActionSheetProvider,
|
||||||
useActionSheet,
|
useActionSheet,
|
||||||
} from "@expo/react-native-action-sheet";
|
} 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 type React from "react";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback } from "react";
|
||||||
import {
|
import { type TouchableOpacityProps, View } from "react-native";
|
||||||
TouchableOpacity,
|
|
||||||
type TouchableOpacityProps,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
|
||||||
@@ -27,26 +18,17 @@ interface EpisodeCardProps extends TouchableOpacityProps {
|
|||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||||
const { deleteFile } = useDownload();
|
const { deleteFile } = useDownload();
|
||||||
const { openFile } = useDownloadedFileOpener();
|
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const successHapticFeedback = useHaptic("success");
|
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.
|
* Handles deleting the file with haptic feedback.
|
||||||
*/
|
*/
|
||||||
const handleDeleteFile = useCallback(() => {
|
const handleDeleteFile = useCallback(() => {
|
||||||
if (item.Id) {
|
if (item.Id) {
|
||||||
deleteFile(item.Id);
|
deleteFile(item.Id, "Episode");
|
||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
}
|
}
|
||||||
}, [deleteFile, item.Id]);
|
}, [deleteFile, item.Id]);
|
||||||
@@ -77,10 +59,10 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
|||||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableItemRouter
|
||||||
onPress={handleOpenFile}
|
item={item}
|
||||||
|
isOffline={true}
|
||||||
onLongPress={showActionSheet}
|
onLongPress={showActionSheet}
|
||||||
key={item.Id}
|
|
||||||
className='flex flex-col mb-4'
|
className='flex flex-col mb-4'
|
||||||
>
|
>
|
||||||
<View className='flex flex-row items-start mb-2'>
|
<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'>
|
<Text numberOfLines={3} className='text-xs text-neutral-500 shrink'>
|
||||||
{item.Overview}
|
{item.Overview}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableItemRouter>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import {
|
import {
|
||||||
ActionSheetProvider,
|
ActionSheetProvider,
|
||||||
useActionSheet,
|
useActionSheet,
|
||||||
} from "@expo/react-native-action-sheet";
|
} from "@expo/react-native-action-sheet";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useMemo } 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 { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { ProgressBar } from "../common/ProgressBar";
|
||||||
import { Image } from "expo-image";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
|
|
||||||
interface MovieCardProps {
|
interface MovieCardProps {
|
||||||
@@ -27,16 +26,10 @@ interface MovieCardProps {
|
|||||||
*/
|
*/
|
||||||
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||||
const { deleteFile } = useDownload();
|
const { deleteFile } = useDownload();
|
||||||
const { openFile } = useDownloadedFileOpener();
|
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const successHapticFeedback = useHaptic("success");
|
|
||||||
|
|
||||||
const handleOpenFile = useCallback(() => {
|
|
||||||
openFile(item);
|
|
||||||
}, [item, openFile]);
|
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
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(() => {
|
const handleDeleteFile = useCallback(() => {
|
||||||
if (item.Id) {
|
if (item.Id) {
|
||||||
deleteFile(item.Id);
|
deleteFile(item.Id, "Movie");
|
||||||
successHapticFeedback();
|
|
||||||
}
|
}
|
||||||
}, [deleteFile, item.Id]);
|
}, [deleteFile, item.Id]);
|
||||||
|
|
||||||
@@ -75,9 +67,9 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}>
|
<TouchableItemRouter onLongPress={showActionSheet} item={item} isOffline>
|
||||||
{base64Image ? (
|
{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
|
<Image
|
||||||
source={{
|
source={{
|
||||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||||
@@ -88,22 +80,24 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
resizeMode: "cover",
|
resizeMode: "cover",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<ProgressBar item={item} />
|
||||||
</View>
|
</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
|
<Ionicons
|
||||||
name='image-outline'
|
name='image-outline'
|
||||||
size={24}
|
size={24}
|
||||||
color='gray'
|
color='gray'
|
||||||
className='self-center mt-16'
|
className='self-center mt-16'
|
||||||
/>
|
/>
|
||||||
|
<ProgressBar item={item} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View className='w-28'>
|
<View className='w-28'>
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
<DownloadSize items={[item]} />
|
<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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import {
|
||||||
type QueryFunction,
|
type QueryFunction,
|
||||||
@@ -8,9 +6,11 @@ import {
|
|||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, View, type ViewProps } from "react-native";
|
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 ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
import { ItemCardText } from "../ItemCardText";
|
||||||
import SeriesPoster from "../posters/SeriesPoster";
|
import SeriesPoster from "../posters/SeriesPoster";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -20,6 +20,7 @@ interface Props extends ViewProps {
|
|||||||
queryKey: QueryKey;
|
queryKey: QueryKey;
|
||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
queryFn: QueryFunction<BaseItemDto[]>;
|
||||||
hideIfEmpty?: boolean;
|
hideIfEmpty?: boolean;
|
||||||
|
isOffline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||||
@@ -29,6 +30,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
queryFn,
|
queryFn,
|
||||||
queryKey,
|
queryKey,
|
||||||
hideIfEmpty = false,
|
hideIfEmpty = false,
|
||||||
|
isOffline = false,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
@@ -90,6 +92,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
|
isOffline={isOffline}
|
||||||
className={`mr-2
|
className={`mr-2
|
||||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||||
`}
|
`}
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{<WatchedIndicator item={item} />}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 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 { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
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 ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
|
||||||
import {
|
import {
|
||||||
HorizontalScroll,
|
HorizontalScroll,
|
||||||
type HorizontalScrollRef,
|
type HorizontalScrollRef,
|
||||||
} from "../common/HorrizontalScroll";
|
} from "../common/HorrizontalScroll";
|
||||||
|
import { ItemCardText } from "../ItemCardText";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item?: BaseItemDto | null;
|
item?: BaseItemDto | null;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
isOffline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||||
item,
|
item,
|
||||||
loading,
|
loading,
|
||||||
|
isOffline,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
const { downloadedFiles } = useDownload();
|
||||||
|
|
||||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||||
|
|
||||||
@@ -41,24 +46,28 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["episodes", seasonId],
|
queryKey: ["episodes", seasonId, isOffline],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id) return [];
|
if (isOffline) {
|
||||||
const response = await api.axiosInstance.get(
|
return downloadedFiles
|
||||||
`${api.basePath}/Shows/${item?.Id}/Episodes`,
|
?.filter(
|
||||||
{
|
(f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId,
|
||||||
params: {
|
)
|
||||||
userId: user?.Id,
|
.map((f) => f.item);
|
||||||
seasonId,
|
}
|
||||||
Fields:
|
if (!api || !user?.Id || !item?.SeriesId) return [];
|
||||||
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview",
|
const response = await getTvShowsApi(api).getEpisodes({
|
||||||
},
|
userId: user.Id,
|
||||||
headers: {
|
seasonId: seasonId || undefined,
|
||||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
seriesId: item.SeriesId,
|
||||||
},
|
fields: [
|
||||||
},
|
"ItemCounts",
|
||||||
);
|
"PrimaryImageAspectRatio",
|
||||||
|
"CanDelete",
|
||||||
|
"MediaSourceCount",
|
||||||
|
"Overview",
|
||||||
|
],
|
||||||
|
});
|
||||||
return response.data.Items as BaseItemDto[];
|
return response.data.Items as BaseItemDto[];
|
||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id && !!seasonId,
|
enabled: !!api && !!user?.Id && !!seasonId,
|
||||||
@@ -123,7 +132,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
data={episodes}
|
data={episodes}
|
||||||
extraData={item}
|
extraData={item}
|
||||||
loading={loading || isLoading || isFetching}
|
loading={loading || isLoading || isFetching}
|
||||||
renderItem={(_item, idx) => (
|
renderItem={(_item, _idx) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={_item.Id}
|
key={_item.Id}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
seasonId: selectedSeasonId,
|
seasonId: selectedSeasonId,
|
||||||
enableUserData: true,
|
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)
|
if (res.data.TotalRecordCount === 0)
|
||||||
@@ -97,6 +98,10 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
|
|
||||||
return res.data.Items;
|
return res.data.Items;
|
||||||
},
|
},
|
||||||
|
select: (data) =>
|
||||||
|
[...(data || [])].sort(
|
||||||
|
(a, b) => (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
||||||
|
),
|
||||||
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,20 @@
|
|||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import {
|
import {
|
||||||
DownloadMethod,
|
|
||||||
type Settings,
|
type Settings,
|
||||||
useSettings,
|
useSettings,
|
||||||
} from "@/utils/atoms/settings";
|
} 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 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 DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export default function DownloadSettings({ ...props }) {
|
export default function DownloadSettings({ ...props }) {
|
||||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
const { setProcesses } = useDownload();
|
|
||||||
const router = useRouter();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const allDisabled = useMemo(
|
const allDisabled = useMemo(
|
||||||
() =>
|
() =>
|
||||||
pluginSettings?.downloadMethod?.locked === true &&
|
|
||||||
pluginSettings?.remuxConcurrentLimit?.locked === true &&
|
pluginSettings?.remuxConcurrentLimit?.locked === true &&
|
||||||
pluginSettings?.autoDownload.locked === true,
|
pluginSettings?.autoDownload.locked === true,
|
||||||
[pluginSettings],
|
[pluginSettings],
|
||||||
@@ -37,69 +25,10 @@ export default function DownloadSettings({ ...props }) {
|
|||||||
return (
|
return (
|
||||||
<DisabledSetting disabled={allDisabled} {...props} className='mb-4'>
|
<DisabledSetting disabled={allDisabled} {...props} className='mb-4'>
|
||||||
<ListGroup title={t("home.settings.downloads.downloads_title")}>
|
<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
|
<ListItem
|
||||||
title={t("home.settings.downloads.remux_max_download")}
|
title={t("home.settings.downloads.remux_max_download")}
|
||||||
disabled={
|
disabled={
|
||||||
pluginSettings?.remuxConcurrentLimit?.locked ||
|
pluginSettings?.remuxConcurrentLimit?.locked
|
||||||
settings.downloadMethod !== DownloadMethod.Remux
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Stepper
|
<Stepper
|
||||||
@@ -114,33 +43,6 @@ export default function DownloadSettings({ ...props }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</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>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</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 { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import type { Api } from "@jellyfin/sdk";
|
import type { Api } from "@jellyfin/sdk";
|
||||||
import type {
|
import type {
|
||||||
@@ -25,12 +13,7 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import NetInfo from "@react-native-community/netinfo";
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import { useNavigation, useRouter, useSegments } from "expo-router";
|
||||||
useNavigation,
|
|
||||||
usePathname,
|
|
||||||
useRouter,
|
|
||||||
useSegments,
|
|
||||||
} from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -43,6 +26,18 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { 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 ScrollingCollectionListSection = {
|
||||||
type: "ScrollingCollectionList";
|
type: "ScrollingCollectionList";
|
||||||
@@ -71,9 +66,9 @@ export const HomeIndex = () => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [
|
const [
|
||||||
settings,
|
settings,
|
||||||
updateSettings,
|
_updateSettings,
|
||||||
pluginSettings,
|
_pluginSettings,
|
||||||
setPluginSettings,
|
_setPluginSettings,
|
||||||
refreshStreamyfinPluginSettings,
|
refreshStreamyfinPluginSettings,
|
||||||
] = useSettings();
|
] = useSettings();
|
||||||
|
|
||||||
@@ -87,6 +82,17 @@ export const HomeIndex = () => {
|
|||||||
const scrollViewRef = useRef<ScrollView>(null);
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
|
|
||||||
const { downloadedFiles, cleanCacheDirectory } = useDownload();
|
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(() => {
|
useEffect(() => {
|
||||||
if (Platform.isTV) {
|
if (Platform.isTV) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
@@ -114,7 +120,7 @@ export const HomeIndex = () => {
|
|||||||
}, [downloadedFiles, navigation, router]);
|
}, [downloadedFiles, navigation, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cleanCacheDirectory().catch((e) =>
|
cleanCacheDirectory().catch((_e) =>
|
||||||
console.error("Something went wrong cleaning cache directory"),
|
console.error("Something went wrong cleaning cache directory"),
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -149,10 +155,6 @@ export const HomeIndex = () => {
|
|||||||
setIsConnected(state.isConnected);
|
setIsConnected(state.isConnected);
|
||||||
});
|
});
|
||||||
|
|
||||||
// cleanCacheDirectory().catch((e) =>
|
|
||||||
// console.error("Something went wrong cleaning cache directory")
|
|
||||||
// );
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
@@ -193,8 +195,6 @@ export const HomeIndex = () => {
|
|||||||
);
|
);
|
||||||
}, [userViews]);
|
}, [userViews]);
|
||||||
|
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
|
||||||
|
|
||||||
const refetch = async () => {
|
const refetch = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await refreshStreamyfinPluginSettings();
|
await refreshStreamyfinPluginSettings();
|
||||||
@@ -213,209 +213,187 @@ export const HomeIndex = () => {
|
|||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) return [];
|
if (!api) return [];
|
||||||
|
return (
|
||||||
const response = await getItemsApi(api).getItems({
|
(
|
||||||
userId: user?.Id,
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
limit: 40,
|
userId: user?.Id,
|
||||||
recursive: true,
|
limit: 20,
|
||||||
includeItemTypes,
|
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||||
sortBy: ["DateCreated"],
|
imageTypeLimit: 1,
|
||||||
sortOrder: ["Descending"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
includeItemTypes,
|
||||||
parentId,
|
parentId,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
})
|
||||||
});
|
).data || []
|
||||||
|
);
|
||||||
let items = response.data.Items || [];
|
|
||||||
|
|
||||||
if (includeItemTypes.includes("Episode")) {
|
|
||||||
// Removes individual episodes from the list if they are part of a series
|
|
||||||
// and only keeps the series item
|
|
||||||
// Note: The 'Latest' API endpoint does not work well with combining batch episode imports
|
|
||||||
// and will either only show the series or the episodes, not both.
|
|
||||||
// This is a workaround to filter out the episodes from the list
|
|
||||||
const seriesIds = new Set(
|
|
||||||
items.filter((i) => i.Type === "Series").map((i) => i.Id),
|
|
||||||
);
|
|
||||||
|
|
||||||
items = items.filter(
|
|
||||||
(i) =>
|
|
||||||
i.Type === "Series" ||
|
|
||||||
(i.Type === "Episode" && !seriesIds.has(i.SeriesId!)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (items.length > 20) {
|
|
||||||
items = items.slice(0, 20);
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
},
|
},
|
||||||
type: "ScrollingCollectionList",
|
type: "ScrollingCollectionList",
|
||||||
}),
|
}),
|
||||||
[api, user?.Id],
|
[api, user?.Id],
|
||||||
);
|
);
|
||||||
|
|
||||||
let sections: Section[] = [];
|
const defaultSections = useMemo(() => {
|
||||||
if (!settings?.home || !settings?.home?.sections) {
|
if (!api || !user?.Id) return [];
|
||||||
sections = useMemo(() => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
|
|
||||||
const latestMediaViews = collections.map((c) => {
|
const latestMediaViews = collections.map((c) => {
|
||||||
const includeItemTypes: BaseItemKind[] =
|
const includeItemTypes: BaseItemKind[] =
|
||||||
c.CollectionType === "tvshows" ? ["Episode", "Series"] : ["Movie"];
|
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
||||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||||
const queryKey = [
|
const queryKey = [
|
||||||
"home",
|
"home",
|
||||||
`recentlyAddedIn${c.CollectionType}`,
|
`recentlyAddedIn${c.CollectionType}`,
|
||||||
user?.Id!,
|
user?.Id!,
|
||||||
c.Id!,
|
c.Id!,
|
||||||
];
|
|
||||||
return createCollectionConfig(
|
|
||||||
title || "",
|
|
||||||
queryKey,
|
|
||||||
includeItemTypes,
|
|
||||||
c.Id,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ss: Section[] = [
|
|
||||||
{
|
|
||||||
title: t("home.continue_watching"),
|
|
||||||
queryKey: ["home", "resumeItems"],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getItemsApi(api).getResumeItems({
|
|
||||||
userId: user.Id,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("home.next_up"),
|
|
||||||
queryKey: ["home", "nextUp-all"],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user?.Id,
|
|
||||||
fields: ["MediaSourceCount"],
|
|
||||||
limit: 20,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
enableResumable: false,
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
...latestMediaViews,
|
|
||||||
// ...(mediaListCollections?.map(
|
|
||||||
// (ml) =>
|
|
||||||
// ({
|
|
||||||
// title: ml.Name,
|
|
||||||
// queryKey: ["home", "mediaList", ml.Id!],
|
|
||||||
// queryFn: async () => ml,
|
|
||||||
// type: "MediaListSection",
|
|
||||||
// orientation: "vertical",
|
|
||||||
// } as Section)
|
|
||||||
// ) || []),
|
|
||||||
{
|
|
||||||
title: t("home.suggested_movies"),
|
|
||||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 10,
|
|
||||||
mediaType: ["Video"],
|
|
||||||
type: ["Movie"],
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "vertical",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("home.suggested_episodes"),
|
|
||||||
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
try {
|
|
||||||
const suggestions = await getSuggestions(api, user.Id);
|
|
||||||
const nextUpPromises = suggestions.map((series) =>
|
|
||||||
getNextUp(api, user.Id, series.Id),
|
|
||||||
);
|
|
||||||
const nextUpResults = await Promise.all(nextUpPromises);
|
|
||||||
|
|
||||||
return nextUpResults.filter((item) => item !== null) || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching data:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
return ss;
|
return createCollectionConfig(
|
||||||
}, [api, user?.Id, collections]);
|
title || "",
|
||||||
} else {
|
queryKey,
|
||||||
sections = useMemo(() => {
|
includeItemTypes,
|
||||||
if (!api || !user?.Id) return [];
|
c.Id,
|
||||||
const ss: Section[] = [];
|
);
|
||||||
|
});
|
||||||
|
|
||||||
for (const key in settings.home?.sections) {
|
const ss: Section[] = [
|
||||||
// @ts-expect-error
|
{
|
||||||
const section = settings.home?.sections[key];
|
title: t("home.continue_watching"),
|
||||||
const id = section.title || key;
|
queryKey: ["home", "resumeItems"],
|
||||||
ss.push({
|
queryFn: async () =>
|
||||||
title: id,
|
(
|
||||||
queryKey: ["home", id],
|
await getItemsApi(api).getResumeItems({
|
||||||
queryFn: async () => {
|
userId: user.Id,
|
||||||
if (section.items) {
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
const response = await getItemsApi(api).getItems({
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
userId: user?.Id,
|
})
|
||||||
limit: section.items?.limit || 25,
|
).data.Items || [],
|
||||||
recursive: true,
|
type: "ScrollingCollectionList",
|
||||||
includeItemTypes: section.items?.includeItemTypes,
|
orientation: "horizontal",
|
||||||
sortBy: section.items?.sortBy,
|
},
|
||||||
sortOrder: section.items?.sortOrder,
|
{
|
||||||
filters: section.items?.filters,
|
title: t("home.next_up"),
|
||||||
parentId: section.items?.parentId,
|
queryKey: ["home", "nextUp-all"],
|
||||||
});
|
queryFn: async () =>
|
||||||
return response.data.Items || [];
|
(
|
||||||
}
|
await getTvShowsApi(api).getNextUp({
|
||||||
if (section.nextUp) {
|
userId: user?.Id,
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
fields: ["MediaSourceCount"],
|
||||||
userId: user?.Id,
|
limit: 20,
|
||||||
fields: ["MediaSourceCount"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
limit: section.nextUp?.limit || 25,
|
enableResumable: false,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
})
|
||||||
enableResumable: section.nextUp?.enableResumable,
|
).data.Items || [],
|
||||||
enableRewatching: section.nextUp?.enableRewatching,
|
type: "ScrollingCollectionList",
|
||||||
});
|
orientation: "horizontal",
|
||||||
return response.data.Items || [];
|
},
|
||||||
}
|
...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) {
|
return nextUpResults.filter((item) => item !== null) || [];
|
||||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
} catch (error) {
|
||||||
userId: user?.Id,
|
console.error("Error fetching data:", error);
|
||||||
includeItemTypes: section.latest?.includeItemTypes,
|
|
||||||
limit: section.latest?.limit || 25,
|
|
||||||
isPlayed: section.latest?.isPlayed,
|
|
||||||
groupItems: section.latest?.groupItems,
|
|
||||||
});
|
|
||||||
return response.data || [];
|
|
||||||
}
|
|
||||||
return [];
|
return [];
|
||||||
},
|
}
|
||||||
type: "ScrollingCollectionList",
|
},
|
||||||
orientation: section?.orientation || "vertical",
|
type: "ScrollingCollectionList",
|
||||||
});
|
orientation: "horizontal",
|
||||||
}
|
},
|
||||||
return ss;
|
];
|
||||||
}, [api, user?.Id, settings.home?.sections]);
|
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) {
|
if (isConnected === false) {
|
||||||
return (
|
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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 { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
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 { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
@@ -17,22 +16,15 @@ export const StorageSettings = () => {
|
|||||||
const errorHapticFeedback = useHaptic("error");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
|
||||||
const { data: size, isLoading: appSizeLoading } = useQuery({
|
const { data: size, isLoading: appSizeLoading } = useQuery({
|
||||||
queryKey: ["appSize", appSizeUsage],
|
queryKey: ["appSize"],
|
||||||
queryFn: async () => {
|
queryFn: appSizeUsage,
|
||||||
const app = await appSizeUsage;
|
|
||||||
|
|
||||||
const remaining = await FileSystem.getFreeDiskStorageAsync();
|
|
||||||
const total = await FileSystem.getTotalDiskCapacityAsync();
|
|
||||||
|
|
||||||
return { app, remaining, total, used: (total - remaining) / total };
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDeleteClicked = async () => {
|
const onDeleteClicked = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteAllFiles();
|
await deleteAllFiles();
|
||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
errorHapticFeedback();
|
errorHapticFeedback();
|
||||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||||
}
|
}
|
||||||
@@ -67,10 +59,7 @@ export const StorageSettings = () => {
|
|||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: `${
|
width: `${((size.total - size.remaining - size.app) / size.total) * 100}%`,
|
||||||
((size.total - size.remaining - size.app) / size.total) *
|
|
||||||
100
|
|
||||||
}%`,
|
|
||||||
backgroundColor: Colors.primaryLightRGB,
|
backgroundColor: Colors.primaryLightRGB,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,25 +1,3 @@
|
|||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
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 * 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 { Ionicons, MaterialIcons } from "@expo/vector-icons";
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
@@ -29,7 +7,7 @@ import { Image } from "expo-image";
|
|||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
import React, {
|
import {
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
type FC,
|
type FC,
|
||||||
type MutableRefObject,
|
type MutableRefObject,
|
||||||
@@ -42,27 +20,48 @@ import React, {
|
|||||||
import {
|
import {
|
||||||
Platform,
|
Platform,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import {
|
import {
|
||||||
type SharedValue,
|
|
||||||
runOnJS,
|
runOnJS,
|
||||||
|
type SharedValue,
|
||||||
useAnimatedReaction,
|
useAnimatedReaction,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import 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 AudioSlider from "./AudioSlider";
|
||||||
import BrightnessSlider from "./BrightnessSlider";
|
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 { ControlProvider } from "./contexts/ControlContext";
|
||||||
import { VideoProvider } from "./contexts/VideoContext";
|
import { VideoProvider } from "./contexts/VideoContext";
|
||||||
import DropdownView from "./dropdown/DropdownView";
|
import DropdownView from "./dropdown/DropdownView";
|
||||||
|
import { EpisodeList } from "./EpisodeList";
|
||||||
|
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||||
|
import SkipButton from "./SkipButton";
|
||||||
import { useControlsTimeout } from "./useControlsTimeout";
|
import { useControlsTimeout } from "./useControlsTimeout";
|
||||||
|
import { VideoTouchOverlay } from "./VideoTouchOverlay";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -119,7 +118,6 @@ export const Controls: FC<Props> = ({
|
|||||||
setSubtitleTrack,
|
setSubtitleTrack,
|
||||||
setAudioTrack,
|
setAudioTrack,
|
||||||
offline = false,
|
offline = false,
|
||||||
enableTrickplay = true,
|
|
||||||
isVlc = false,
|
isVlc = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
@@ -134,13 +132,16 @@ export const Controls: FC<Props> = ({
|
|||||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||||
|
|
||||||
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||||
const { previousItem, nextItem } = useAdjacentItems({ item });
|
const { previousItem, nextItem } = useAdjacentItems({
|
||||||
|
item,
|
||||||
|
isOffline: offline,
|
||||||
|
});
|
||||||
const {
|
const {
|
||||||
trickPlayUrl,
|
trickPlayUrl,
|
||||||
calculateTrickplayUrl,
|
calculateTrickplayUrl,
|
||||||
trickplayInfo,
|
trickplayInfo,
|
||||||
prefetchAllTrickplayImages,
|
prefetchAllTrickplayImages,
|
||||||
} = useTrickplay(item, !offline && enableTrickplay);
|
} = useTrickplay(item);
|
||||||
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
|
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
|
||||||
@@ -175,19 +176,21 @@ export const Controls: FC<Props> = ({
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||||
offline ? undefined : item.Id,
|
item?.Id!,
|
||||||
currentTime,
|
currentTime,
|
||||||
seek,
|
seek,
|
||||||
play,
|
play,
|
||||||
isVlc,
|
isVlc,
|
||||||
|
offline,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
||||||
offline ? undefined : item.Id,
|
item?.Id!,
|
||||||
currentTime,
|
currentTime,
|
||||||
seek,
|
seek,
|
||||||
play,
|
play,
|
||||||
isVlc,
|
isVlc,
|
||||||
|
offline,
|
||||||
);
|
);
|
||||||
|
|
||||||
const goToItemCommon = useCallback(
|
const goToItemCommon = useCallback(
|
||||||
@@ -195,9 +198,7 @@ export const Controls: FC<Props> = ({
|
|||||||
if (!item || !settings) {
|
if (!item || !settings) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
|
|
||||||
const previousIndexes = {
|
const previousIndexes = {
|
||||||
subtitleIndex: subtitleIndex
|
subtitleIndex: subtitleIndex
|
||||||
? Number.parseInt(subtitleIndex)
|
? Number.parseInt(subtitleIndex)
|
||||||
@@ -215,15 +216,18 @@ export const Controls: FC<Props> = ({
|
|||||||
previousIndexes,
|
previousIndexes,
|
||||||
mediaSource ?? undefined,
|
mediaSource ?? undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id ?? "",
|
itemId: item.Id ?? "",
|
||||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||||
mediaSourceId: newMediaSource?.Id ?? "",
|
mediaSourceId: newMediaSource?.Id ?? "",
|
||||||
bitrateValue: bitrateValue?.toString(),
|
bitrateValue: bitrateValue?.toString(),
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
|
console.log("queryParams", queryParams);
|
||||||
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.replace(`player/direct-player?${queryParams}`);
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
},
|
},
|
||||||
@@ -241,7 +245,10 @@ export const Controls: FC<Props> = ({
|
|||||||
({
|
({
|
||||||
isAutoPlay,
|
isAutoPlay,
|
||||||
resetWatchCount,
|
resetWatchCount,
|
||||||
}: { isAutoPlay?: boolean; resetWatchCount?: boolean }) => {
|
}: {
|
||||||
|
isAutoPlay?: boolean;
|
||||||
|
resetWatchCount?: boolean;
|
||||||
|
}) => {
|
||||||
if (!nextItem) {
|
if (!nextItem) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -303,10 +310,18 @@ export const Controls: FC<Props> = ({
|
|||||||
|
|
||||||
const goToItem = useCallback(
|
const goToItem = useCallback(
|
||||||
async (itemId: string) => {
|
async (itemId: string) => {
|
||||||
const gotoItem = await getItemById(api, itemId);
|
if (offline) {
|
||||||
if (!gotoItem) {
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: itemId,
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||||
|
}).toString();
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const gotoItem = await getItemById(api, itemId);
|
||||||
|
if (!gotoItem) return;
|
||||||
goToItemCommon(gotoItem);
|
goToItemCommon(gotoItem);
|
||||||
},
|
},
|
||||||
[goToItemCommon, api],
|
[goToItemCommon, api],
|
||||||
@@ -522,9 +537,6 @@ export const Controls: FC<Props> = ({
|
|||||||
|
|
||||||
const onClose = async () => {
|
const onClose = async () => {
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
await ScreenOrientation.lockAsync(
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
|
||||||
);
|
|
||||||
router.back();
|
router.back();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -563,8 +575,8 @@ export const Controls: FC<Props> = ({
|
|||||||
pointerEvents={showControls ? "auto" : "none"}
|
pointerEvents={showControls ? "auto" : "none"}
|
||||||
className={"flex flex-row w-full pt-2"}
|
className={"flex flex-row w-full pt-2"}
|
||||||
>
|
>
|
||||||
{!Platform.isTV && (
|
<View className='mr-auto'>
|
||||||
<View className='mr-auto'>
|
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
|
||||||
<VideoProvider
|
<VideoProvider
|
||||||
getAudioTracks={getAudioTracks}
|
getAudioTracks={getAudioTracks}
|
||||||
getSubtitleTracks={getSubtitleTracks}
|
getSubtitleTracks={getSubtitleTracks}
|
||||||
@@ -574,26 +586,25 @@ export const Controls: FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<DropdownView />
|
<DropdownView />
|
||||||
</VideoProvider>
|
</VideoProvider>
|
||||||
</View>
|
)}
|
||||||
)}
|
</View>
|
||||||
|
|
||||||
<View className='flex flex-row items-center space-x-2 '>
|
<View className='flex flex-row items-center space-x-2 '>
|
||||||
{!Platform.isTV &&
|
{false && (
|
||||||
settings.defaultPlayer === VideoPlayer.VLC_4 && (
|
<TouchableOpacity
|
||||||
<TouchableOpacity
|
onPress={startPictureInPicture}
|
||||||
onPress={startPictureInPicture}
|
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
>
|
||||||
>
|
<MaterialIcons
|
||||||
<MaterialIcons
|
name='picture-in-picture'
|
||||||
name='picture-in-picture'
|
size={24}
|
||||||
size={24}
|
color='white'
|
||||||
color='white'
|
style={{ opacity: showControls ? 1 : 0 }}
|
||||||
style={{ opacity: showControls ? 1 : 0 }}
|
/>
|
||||||
/>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{item?.Type === "Episode" && !offline && (
|
{item?.Type === "Episode" && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
switchOnEpisodeMode();
|
switchOnEpisodeMode();
|
||||||
@@ -632,16 +643,14 @@ export const Controls: FC<Props> = ({
|
|||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{/* )} */}
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onClose}
|
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' />
|
<Ionicons name='close' size={24} color='white' />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|||||||
@@ -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 ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import {
|
import {
|
||||||
HorizontalScroll,
|
HorizontalScroll,
|
||||||
type HorizontalScrollRef,
|
type HorizontalScrollRef,
|
||||||
} from "@/components/common/HorrizontalScroll";
|
} from "@/components/common/HorrizontalScroll";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
import {
|
import {
|
||||||
SeasonDropdown,
|
SeasonDropdown,
|
||||||
type SeasonIndexState,
|
type SeasonIndexState,
|
||||||
} from "@/components/series/SeasonDropdown";
|
} 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 { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
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 = {
|
type Props = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -33,12 +37,15 @@ export const seasonIndexAtom = atom<SeasonIndexState>({});
|
|||||||
export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const insets = useSafeAreaInsets(); // Get safe area insets
|
|
||||||
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||||
const scrollViewRef = useRef<HorizontalScrollRef>(null); // Reference to the HorizontalScroll
|
const scrollViewRef = useRef<HorizontalScrollRef>(null); // Reference to the HorizontalScroll
|
||||||
const scrollToIndex = (index: number) => {
|
const scrollToIndex = (index: number) => {
|
||||||
scrollViewRef.current?.scrollToIndex(index, 100);
|
scrollViewRef.current?.scrollToIndex(index, 100);
|
||||||
};
|
};
|
||||||
|
const { offline } = useGlobalSearchParams<{
|
||||||
|
offline: string;
|
||||||
|
}>();
|
||||||
|
const isOffline = offline === "true";
|
||||||
|
|
||||||
// Set the initial season index
|
// Set the initial season index
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,23 +57,35 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const seasonIndex = seasonIndexState[item.SeriesId ?? ""];
|
const { downloadedFiles } = useDownload();
|
||||||
const [seriesItem, setSeriesItem] = useState<BaseItemDto | null>(null);
|
|
||||||
|
|
||||||
// This effect fetches the series item data/
|
const seasonIndex = seasonIndexState[item.SeriesId ?? ""];
|
||||||
useEffect(() => {
|
const { data: seriesItem } = useItemQuery(item.SeriesId!, isOffline);
|
||||||
if (item.SeriesId) {
|
|
||||||
getUserItemData({ api, userId: user?.Id, itemId: item.SeriesId }).then(
|
|
||||||
(res) => {
|
|
||||||
setSeriesItem(res);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [item.SeriesId]);
|
|
||||||
|
|
||||||
const { data: seasons } = useQuery({
|
const { data: seasons } = useQuery({
|
||||||
queryKey: ["seasons", item.SeriesId],
|
queryKey: ["seasons", item.SeriesId],
|
||||||
queryFn: async () => {
|
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 [];
|
if (!api || !user?.Id || !item.SeriesId) return [];
|
||||||
const response = await api.axiosInstance.get(
|
const response = await api.axiosInstance.get(
|
||||||
`${api.basePath}/Shows/${item.SeriesId}/Seasons`,
|
`${api.basePath}/Shows/${item.SeriesId}/Seasons`,
|
||||||
@@ -93,9 +112,19 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
[seasons, seasonIndex],
|
[seasons, seasonIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: episodes, isFetching } = useQuery({
|
const { data: episodes } = useQuery({
|
||||||
queryKey: ["episodes", item.SeriesId, selectedSeasonId],
|
queryKey: ["episodes", item.SeriesId, selectedSeasonId],
|
||||||
queryFn: async () => {
|
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 [];
|
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
||||||
const res = await getTvShowsApi(api).getEpisodes({
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
seriesId: item.SeriesId || "",
|
seriesId: item.SeriesId || "",
|
||||||
@@ -112,7 +141,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item?.Type === "Episode" && item.Id) {
|
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) {
|
if (index !== undefined && index !== -1) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scrollToIndex(index);
|
scrollToIndex(index);
|
||||||
@@ -155,7 +184,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<SafeAreaView
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
backgroundColor: "black",
|
backgroundColor: "black",
|
||||||
@@ -163,92 +192,81 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<>
|
<View
|
||||||
<View
|
style={{
|
||||||
style={{
|
justifyContent: "space-between",
|
||||||
justifyContent: "space-between",
|
}}
|
||||||
}}
|
className={"flex flex-row items-center space-x-2 z-10 p-4"}
|
||||||
className={"flex flex-row items-center space-x-2 z-10 p-4"}
|
>
|
||||||
>
|
{seriesItem && (
|
||||||
{seriesItem && (
|
<SeasonDropdown
|
||||||
<SeasonDropdown
|
item={seriesItem}
|
||||||
item={seriesItem}
|
seasons={seasons}
|
||||||
seasons={seasons}
|
state={seasonIndexState}
|
||||||
state={seasonIndexState}
|
onSelect={(season) => {
|
||||||
onSelect={(season) => {
|
setSeasonIndexState((prev) => ({
|
||||||
setSeasonIndexState((prev) => ({
|
...prev,
|
||||||
...prev,
|
[item.SeriesId ?? ""]: season.IndexNumber,
|
||||||
[item.SeriesId ?? ""]: season.IndexNumber,
|
}));
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={async () => {
|
|
||||||
close();
|
|
||||||
}}
|
}}
|
||||||
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
|
||||||
</TouchableOpacity>
|
onPress={close}
|
||||||
</View>
|
className='aspect-square flex flex-col l items-center justify-center p-2'
|
||||||
|
>
|
||||||
|
<Ionicons name='close' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
ref={scrollViewRef}
|
ref={scrollViewRef}
|
||||||
data={episodes}
|
data={episodes}
|
||||||
extraData={item}
|
extraData={item}
|
||||||
renderItem={(_item, idx) => (
|
renderItem={(_item, _idx) => (
|
||||||
<View
|
<View
|
||||||
key={_item.Id}
|
key={_item.Id}
|
||||||
style={{}}
|
style={{}}
|
||||||
className={`flex flex-col w-44 ${
|
className={`flex flex-col w-44 ${item.Id !== _item.Id ? "opacity-75" : ""
|
||||||
item.Id !== _item.Id ? "opacity-75" : ""
|
|
||||||
}`}
|
}`}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
goToItem(_item.Id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<TouchableOpacity
|
<ContinueWatchingPoster
|
||||||
onPress={() => {
|
item={_item}
|
||||||
goToItem(_item.Id);
|
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.Name}
|
||||||
item={_item}
|
</Text>
|
||||||
useEpisodePoster
|
<Text numberOfLines={1} className='text-xs text-neutral-475'>
|
||||||
showPlayButton={_item.Id !== item.Id}
|
{`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`}
|
||||||
/>
|
</Text>
|
||||||
</TouchableOpacity>
|
<Text className='text-xs text-neutral-500'>
|
||||||
<View className='shrink'>
|
{runtimeTicksToSeconds(_item.RunTimeTicks)}
|
||||||
<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}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
<Text numberOfLines={5} className='text-xs text-neutral-500 shrink'>
|
||||||
keyExtractor={(e: BaseItemDto) => e.Id ?? ""}
|
{_item.Overview}
|
||||||
estimatedItemSize={200}
|
</Text>
|
||||||
showsHorizontalScrollIndicator={false}
|
</View>
|
||||||
/>
|
)}
|
||||||
</>
|
keyExtractor={(e: BaseItemDto) => e.Id ?? ""}
|
||||||
</View>
|
estimatedItemSize={200}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import type { TrackInfo } from "@/modules/VlcPlayer.types";
|
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { VideoPlayer, useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import {
|
import {
|
||||||
type ReactNode,
|
|
||||||
createContext,
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import type { TrackInfo } from "@/modules/VlcPlayer.types";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import type { Track } from "../types";
|
import type { Track } from "../types";
|
||||||
import { useControlContext } from "./ControlContext";
|
import { useControlContext } from "./ControlContext";
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
||||||
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
||||||
const [settings] = useSettings();
|
const [_settings] = useSettings();
|
||||||
|
|
||||||
const ControlContext = useControlContext();
|
const ControlContext = useControlContext();
|
||||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
||||||
@@ -57,22 +58,27 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
const allSubs =
|
const allSubs =
|
||||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
||||||
|
|
||||||
const { itemId, audioIndex, bitrateValue, subtitleIndex } =
|
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
||||||
useLocalSearchParams<{
|
useLocalSearchParams<{
|
||||||
itemId: string;
|
itemId: string;
|
||||||
audioIndex: string;
|
audioIndex: string;
|
||||||
subtitleIndex: string;
|
subtitleIndex: string;
|
||||||
mediaSourceId: string;
|
mediaSourceId: string;
|
||||||
bitrateValue: string;
|
bitrateValue: string;
|
||||||
|
playbackPosition: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const onTextBasedSubtitle = useMemo(
|
const onTextBasedSubtitle = useMemo(() => {
|
||||||
() =>
|
return (
|
||||||
allSubs.find(
|
allSubs.find(
|
||||||
(s) => s.Index?.toString() === subtitleIndex && s.IsTextSubtitleStream,
|
(s) =>
|
||||||
) || subtitleIndex === "-1",
|
s.Index?.toString() === subtitleIndex &&
|
||||||
[allSubs, subtitleIndex],
|
(s.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
||||||
);
|
s.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
||||||
|
s.DeliveryMethod === SubtitleDeliveryMethod.External),
|
||||||
|
) || subtitleIndex === "-1"
|
||||||
|
);
|
||||||
|
}, [allSubs, subtitleIndex]);
|
||||||
|
|
||||||
const setPlayerParams = ({
|
const setPlayerParams = ({
|
||||||
chosenAudioIndex = audioIndex,
|
chosenAudioIndex = audioIndex,
|
||||||
@@ -88,6 +94,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
subtitleIndex: chosenSubtitleIndex,
|
subtitleIndex: chosenSubtitleIndex,
|
||||||
mediaSourceId: mediaSource?.Id ?? "",
|
mediaSourceId: mediaSource?.Id ?? "",
|
||||||
bitrateValue: bitrateValue,
|
bitrateValue: bitrateValue,
|
||||||
|
playbackPosition: playbackPosition,
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
@@ -126,30 +133,32 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTracks = async () => {
|
const fetchTracks = async () => {
|
||||||
if (getSubtitleTracks) {
|
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
|
let embedSubIndex = 1;
|
||||||
const sortedSubs = allSubs.sort(
|
const processedSubs: Track[] = allSubs?.map((sub) => {
|
||||||
(a, b) => Number(a.IsExternal) - Number(b.IsExternal),
|
/** A boolean value determining if we should increment the embedSubIndex, currently only Embed and Hls subtitles are automatically added into VLC Player */
|
||||||
);
|
|
||||||
|
|
||||||
// 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
|
|
||||||
const shouldIncrement =
|
const shouldIncrement =
|
||||||
!mediaSource?.TranscodingUrl || sub.IsTextSubtitleStream;
|
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
||||||
const vlcIndex = subtitleData?.at(textSubIndex)?.index ?? -1;
|
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
||||||
const finalIndex = shouldIncrement ? vlcIndex : (sub.Index ?? -1);
|
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
|
||||||
|
/** The index of subtitle inside VLC Player Itself */
|
||||||
if (shouldIncrement) textSubIndex++;
|
const vlcIndex = subtitleData?.at(embedSubIndex)?.index ?? -1;
|
||||||
|
if (shouldIncrement) embedSubIndex++;
|
||||||
return {
|
return {
|
||||||
name: sub.DisplayTitle || "Undefined Subtitle",
|
name: sub.DisplayTitle || "Undefined Subtitle",
|
||||||
index: sub.Index ?? -1,
|
index: sub.Index ?? -1,
|
||||||
setTrack: () =>
|
setTrack: () =>
|
||||||
shouldIncrement
|
shouldIncrement
|
||||||
? setTrackParams("subtitle", finalIndex, sub.Index ?? -1)
|
? setTrackParams("subtitle", vlcIndex, sub.Index ?? -1)
|
||||||
: setPlayerParams({
|
: setPlayerParams({
|
||||||
chosenSubtitleIndex: sub.Index?.toString(),
|
chosenSubtitleIndex: sub.Index?.toString(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import React, { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Platform, TouchableOpacity } from "react-native";
|
import { Platform, TouchableOpacity } from "react-native";
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import { useControlContext } from "../contexts/ControlContext";
|
import { useControlContext } from "../contexts/ControlContext";
|
||||||
import { useVideoContext } from "../contexts/VideoContext";
|
import { useVideoContext } from "../contexts/VideoContext";
|
||||||
|
|
||||||
@@ -17,13 +19,18 @@ const DropdownView = () => {
|
|||||||
];
|
];
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { subtitleIndex, audioIndex, bitrateValue } = useLocalSearchParams<{
|
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } =
|
||||||
itemId: string;
|
useLocalSearchParams<{
|
||||||
audioIndex: string;
|
itemId: string;
|
||||||
subtitleIndex: string;
|
audioIndex: string;
|
||||||
mediaSourceId: string;
|
subtitleIndex: string;
|
||||||
bitrateValue: string;
|
mediaSourceId: string;
|
||||||
}>();
|
bitrateValue: string;
|
||||||
|
playbackPosition: string;
|
||||||
|
offline: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isOffline = offline === "true";
|
||||||
|
|
||||||
const changeBitrate = useCallback(
|
const changeBitrate = useCallback(
|
||||||
(bitrate: string) => {
|
(bitrate: string) => {
|
||||||
@@ -33,11 +40,12 @@ const DropdownView = () => {
|
|||||||
subtitleIndex: subtitleIndex.toString() ?? "",
|
subtitleIndex: subtitleIndex.toString() ?? "",
|
||||||
mediaSourceId: mediaSource?.Id ?? "",
|
mediaSourceId: mediaSource?.Id ?? "",
|
||||||
bitrateValue: bitrate.toString(),
|
bitrateValue: bitrate.toString(),
|
||||||
|
playbackPosition: playbackPosition,
|
||||||
}).toString();
|
}).toString();
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.replace(`player/direct-player?${queryParams}`);
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
},
|
},
|
||||||
[item, mediaSource, subtitleIndex, audioIndex],
|
[item, mediaSource, subtitleIndex, audioIndex, playbackPosition],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -56,32 +64,34 @@ const DropdownView = () => {
|
|||||||
collisionPadding={8}
|
collisionPadding={8}
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Sub>
|
{!isOffline && (
|
||||||
<DropdownMenu.SubTrigger key='qualitytrigger'>
|
<DropdownMenu.Sub>
|
||||||
Quality
|
<DropdownMenu.SubTrigger key='qualitytrigger'>
|
||||||
</DropdownMenu.SubTrigger>
|
Quality
|
||||||
<DropdownMenu.SubContent
|
</DropdownMenu.SubTrigger>
|
||||||
alignOffset={-10}
|
<DropdownMenu.SubContent
|
||||||
avoidCollisions={true}
|
alignOffset={-10}
|
||||||
collisionPadding={0}
|
avoidCollisions={true}
|
||||||
loop={true}
|
collisionPadding={0}
|
||||||
sideOffset={10}
|
loop={true}
|
||||||
>
|
sideOffset={10}
|
||||||
{BITRATES?.map((bitrate, idx: number) => (
|
>
|
||||||
<DropdownMenu.CheckboxItem
|
{BITRATES?.map((bitrate, idx: number) => (
|
||||||
key={`quality-item-${idx}`}
|
<DropdownMenu.CheckboxItem
|
||||||
value={bitrateValue === (bitrate.value?.toString() ?? "")}
|
key={`quality-item-${idx}`}
|
||||||
onValueChange={() =>
|
value={bitrateValue === (bitrate.value?.toString() ?? "")}
|
||||||
changeBitrate(bitrate.value?.toString() ?? "")
|
onValueChange={() =>
|
||||||
}
|
changeBitrate(bitrate.value?.toString() ?? "")
|
||||||
>
|
}
|
||||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
>
|
||||||
{bitrate.key}
|
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||||
</DropdownMenu.ItemTitle>
|
{bitrate.key}
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.ItemTitle>
|
||||||
))}
|
</DropdownMenu.CheckboxItem>
|
||||||
</DropdownMenu.SubContent>
|
))}
|
||||||
</DropdownMenu.Sub>
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
)}
|
||||||
<DropdownMenu.Sub>
|
<DropdownMenu.Sub>
|
||||||
<DropdownMenu.SubTrigger key='subtitle-trigger'>
|
<DropdownMenu.SubTrigger key='subtitle-trigger'>
|
||||||
Subtitle
|
Subtitle
|
||||||
|
|||||||
6
eas.json
6
eas.json
@@ -47,14 +47,14 @@
|
|||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.28.0",
|
"channel": "0.28.1",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.28.0",
|
"channel": "0.28.1",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
},
|
},
|
||||||
"production-apk-tv": {
|
"production-apk-tv": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.28.0",
|
"channel": "0.28.1",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -1,21 +1,63 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
interface AdjacentEpisodesProps {
|
interface AdjacentEpisodesProps {
|
||||||
item?: BaseItemDto | null;
|
item?: BaseItemDto | null;
|
||||||
|
isOffline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
|
export const useAdjacentItems = ({
|
||||||
|
item,
|
||||||
|
isOffline = false,
|
||||||
|
}: AdjacentEpisodesProps) => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
const { downloadedFiles } = useDownload();
|
||||||
|
|
||||||
const { data: adjacentItems } = useQuery({
|
const { data: adjacentItems } = useQuery({
|
||||||
queryKey: ["adjacentItems", item?.Id, item?.SeriesId],
|
queryKey: ["adjacentItems", item?.Id, item?.SeriesId, isOffline],
|
||||||
queryFn: async (): Promise<BaseItemDto[] | null> => {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +71,7 @@ export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
|
|||||||
return res.data.Items || null;
|
return res.data.Items || null;
|
||||||
},
|
},
|
||||||
enabled:
|
enabled:
|
||||||
!!api &&
|
(isOffline || !!api) &&
|
||||||
!!item?.Id &&
|
!!item?.Id &&
|
||||||
!!item?.SeriesId &&
|
!!item?.SeriesId &&
|
||||||
(item?.Type === "Episode" || item?.Type === "Audio"),
|
(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 { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useSegments } from "@/utils/segments";
|
||||||
|
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||||
import { useHaptic } from "./useHaptic";
|
import { useHaptic } from "./useHaptic";
|
||||||
|
|
||||||
interface CreditTimestamps {
|
|
||||||
Introduction: {
|
|
||||||
Start: number;
|
|
||||||
End: number;
|
|
||||||
Valid: boolean;
|
|
||||||
};
|
|
||||||
Credits: {
|
|
||||||
Start: number;
|
|
||||||
End: number;
|
|
||||||
Valid: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCreditSkipper = (
|
export const useCreditSkipper = (
|
||||||
itemId: string | undefined,
|
itemId: string,
|
||||||
currentTime: number,
|
currentTime: number,
|
||||||
seek: (time: number) => void,
|
seek: (time: number) => void,
|
||||||
play: () => void,
|
play: () => void,
|
||||||
isVlc = false,
|
isVlc = false,
|
||||||
|
isOffline = false,
|
||||||
) => {
|
) => {
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
@@ -43,50 +26,28 @@ export const useCreditSkipper = (
|
|||||||
seek(seconds);
|
seek(seconds);
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data: creditTimestamps } = useQuery<CreditTimestamps | null>({
|
const { data: segments } = useSegments(itemId, isOffline);
|
||||||
queryKey: ["creditTimestamps", itemId],
|
const creditTimestamps = segments?.creditSegments?.[0];
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (creditTimestamps) {
|
if (creditTimestamps) {
|
||||||
setShowSkipCreditButton(
|
setShowSkipCreditButton(
|
||||||
currentTime > creditTimestamps.Credits.Start &&
|
currentTime > creditTimestamps.startTime &&
|
||||||
currentTime < creditTimestamps.Credits.End,
|
currentTime < creditTimestamps.endTime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [creditTimestamps, currentTime]);
|
}, [creditTimestamps, currentTime]);
|
||||||
|
|
||||||
const skipCredit = useCallback(() => {
|
const skipCredit = useCallback(() => {
|
||||||
if (!creditTimestamps) return;
|
if (!creditTimestamps) return;
|
||||||
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
|
|
||||||
try {
|
try {
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
wrappedSeek(creditTimestamps.Credits.End);
|
wrappedSeek(creditTimestamps.endTime);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
play();
|
play();
|
||||||
}, 200);
|
}, 200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error skipping intro", error);
|
console.error("Error skipping credit", error);
|
||||||
}
|
}
|
||||||
}, [creditTimestamps]);
|
}, [creditTimestamps]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { BITRATES, Bitrate } from "@/components/BitrateSelector";
|
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import type { Settings } from "@/utils/atoms/settings";
|
|
||||||
import {
|
|
||||||
type BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
|
import type { Settings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
// Used only for initial play settings.
|
// Used only for initial play settings.
|
||||||
const useDefaultPlaySettings = (
|
const useDefaultPlaySettings = (
|
||||||
@@ -33,10 +30,10 @@ const useDefaultPlaySettings = (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
defaultAudioIndex:
|
defaultAudioIndex:
|
||||||
preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined,
|
preferedAudioIndex ?? defaultAudioIndex ?? firstAudioIndex ?? undefined,
|
||||||
defaultSubtitleIndex: mediaSource?.DefaultSubtitleStreamIndex || -1,
|
defaultSubtitleIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1,
|
||||||
defaultMediaSource: mediaSource || undefined,
|
defaultMediaSource: mediaSource ?? undefined,
|
||||||
defaultBitrate: bitrate || undefined,
|
defaultBitrate: bitrate ?? undefined,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
item.MediaSources,
|
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 type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||||
export const getDownloadedFileUrl = async (itemId: string): Promise<string> => {
|
import { writeToLog } from "@/utils/log";
|
||||||
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}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useDownloadedFileOpener = () => {
|
export const useDownloadedFileOpener = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -33,9 +10,19 @@ export const useDownloadedFileOpener = () => {
|
|||||||
|
|
||||||
const openFile = useCallback(
|
const openFile = useCallback(
|
||||||
async (item: BaseItemDto) => {
|
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 {
|
try {
|
||||||
// @ts-expect-error
|
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||||
router.push(`/player/direct-player?offline=true&itemId=${item.Id}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error opening file", error);
|
writeToLog("ERROR", "Error opening file", error);
|
||||||
console.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 { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useSegments } from "@/utils/segments";
|
||||||
|
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||||
import { useHaptic } from "./useHaptic";
|
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.
|
* Custom hook to handle skipping intros in a media player.
|
||||||
*
|
*
|
||||||
* @param {number} currentTime - The current playback time in seconds.
|
* @param {number} currentTime - The current playback time in seconds.
|
||||||
*/
|
*/
|
||||||
export const useIntroSkipper = (
|
export const useIntroSkipper = (
|
||||||
itemId: string | undefined,
|
itemId: string,
|
||||||
currentTime: number,
|
currentTime: number,
|
||||||
seek: (ticks: number) => void,
|
seek: (ticks: number) => void,
|
||||||
play: () => void,
|
play: () => void,
|
||||||
isVlc = false,
|
isVlc = false,
|
||||||
|
isOffline = false,
|
||||||
) => {
|
) => {
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [showSkipButton, setShowSkipButton] = useState(false);
|
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||||
if (isVlc) {
|
if (isVlc) {
|
||||||
currentTime = msToSeconds(currentTime);
|
currentTime = msToSeconds(currentTime);
|
||||||
@@ -43,35 +30,14 @@ export const useIntroSkipper = (
|
|||||||
seek(seconds);
|
seek(seconds);
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data: introTimestamps } = useQuery<IntroTimestamps | null>({
|
const { data: segments } = useSegments(itemId, isOffline);
|
||||||
queryKey: ["introTimestamps", itemId],
|
const introTimestamps = segments?.introSegments?.[0];
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (introTimestamps) {
|
if (introTimestamps) {
|
||||||
setShowSkipButton(
|
setShowSkipButton(
|
||||||
currentTime > introTimestamps.ShowSkipPromptAt &&
|
currentTime > introTimestamps.startTime &&
|
||||||
currentTime < introTimestamps.HideSkipPromptAt,
|
currentTime < introTimestamps.endTime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [introTimestamps, currentTime]);
|
}, [introTimestamps, currentTime]);
|
||||||
@@ -80,12 +46,12 @@ export const useIntroSkipper = (
|
|||||||
if (!introTimestamps) return;
|
if (!introTimestamps) return;
|
||||||
try {
|
try {
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
wrappedSeek(introTimestamps.IntroEnd);
|
wrappedSeek(introTimestamps.endTime);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
play();
|
play();
|
||||||
}, 200);
|
}, 200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error skipping intro", error);
|
console.error("Error skipping intro", error);
|
||||||
}
|
}
|
||||||
}, [introTimestamps]);
|
}, [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 type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { QueryKey, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useHaptic } from "./useHaptic";
|
import { useHaptic } from "./useHaptic";
|
||||||
|
import { usePlaybackManager } from "./usePlaybackManager";
|
||||||
|
import { useInvalidatePlaybackProgressCache } from "./useRevalidatePlaybackProgressCache";
|
||||||
|
|
||||||
export const useMarkAsPlayed = (items: BaseItemDto[]) => {
|
export const useMarkAsPlayed = (items: BaseItemDto[]) => {
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
const { markItemPlayed, markItemUnplayed } = usePlaybackManager();
|
||||||
const invalidateQueries = () => {
|
const invalidatePlaybackProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
const queriesToInvalidate = [
|
const invalidateQueries = async () => {
|
||||||
["resumeItems"],
|
const queriesToInvalidate: QueryKey[] = [];
|
||||||
["continueWatching"],
|
|
||||||
["nextUp-all"],
|
|
||||||
["nextUp"],
|
|
||||||
["episodes"],
|
|
||||||
["seasons"],
|
|
||||||
["home"],
|
|
||||||
];
|
|
||||||
|
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
if (!item.Id) return;
|
if (!item.Id) return;
|
||||||
queriesToInvalidate.push(["item", item.Id]);
|
queriesToInvalidate.push(["item", item.Id]);
|
||||||
});
|
});
|
||||||
|
await Promise.all(
|
||||||
queriesToInvalidate.forEach((queryKey) => {
|
queriesToInvalidate.map((queryKey) =>
|
||||||
queryClient.invalidateQueries({ queryKey });
|
queryClient.invalidateQueries({ queryKey }),
|
||||||
});
|
),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const markAsPlayedStatus = async (played: boolean) => {
|
const toggle = async (played: boolean) => {
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
|
// Process all items
|
||||||
items.forEach((item) => {
|
await Promise.all(
|
||||||
// Optimistic update
|
items.map((item) => {
|
||||||
queryClient.setQueryData(
|
if (!item.Id) return Promise.resolve();
|
||||||
["item", item.Id],
|
return played ? markItemPlayed(item.Id) : markItemUnplayed(item.Id);
|
||||||
(oldData: BaseItemDto | undefined) => {
|
}),
|
||||||
if (oldData) {
|
);
|
||||||
return {
|
invalidatePlaybackProgressCache();
|
||||||
...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);
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidateQueries();
|
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,10 +1,14 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { useTwoWaySync } from "./useTwoWaySync";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useRevalidatePlaybackProgressCache invalidates queries related to playback progress.
|
* useRevalidatePlaybackProgressCache invalidates queries related to playback progress.
|
||||||
*/
|
*/
|
||||||
export function useInvalidatePlaybackProgressCache() {
|
export function useInvalidatePlaybackProgressCache() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { downloadedFiles } = useDownload();
|
||||||
|
const { syncPlaybackState } = useTwoWaySync();
|
||||||
|
|
||||||
const revalidate = async () => {
|
const revalidate = async () => {
|
||||||
// List of all the queries to invalidate
|
// List of all the queries to invalidate
|
||||||
@@ -17,11 +21,33 @@ export function useInvalidatePlaybackProgressCache() {
|
|||||||
["episodes"],
|
["episodes"],
|
||||||
["seasons"],
|
["seasons"],
|
||||||
["home"],
|
["home"],
|
||||||
|
["downloadedItems"],
|
||||||
];
|
];
|
||||||
|
|
||||||
// Invalidate each query
|
// We Invalidate all the queries to the latest server versions
|
||||||
for (const queryKey of queriesToInvalidate) {
|
await Promise.all(
|
||||||
await queryClient.invalidateQueries({ queryKey });
|
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 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,69 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { ticksToMs } from "@/utils/time";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
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;
|
Interval?: number;
|
||||||
TileWidth?: number;
|
TileWidth?: number;
|
||||||
TileHeight?: number;
|
TileHeight?: number;
|
||||||
@@ -14,141 +72,93 @@ interface TrickplayData {
|
|||||||
ThumbnailCount?: number;
|
ThumbnailCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrickplayInfo {
|
export interface TrickplayInfo {
|
||||||
resolution: string;
|
resolution: string;
|
||||||
aspectRatio: number;
|
aspectRatio: number;
|
||||||
data: TrickplayData;
|
data: TrickplayData;
|
||||||
|
totalImageSheets: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrickplayUrl {
|
/** Generates a trickplay URL based on the item, resolution, and sheet index. */
|
||||||
x: number;
|
export const generateTrickplayUrl = (item: BaseItemDto, sheetIndex: number) => {
|
||||||
y: number;
|
const api = store.get(apiAtom);
|
||||||
url: string;
|
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);
|
* Parses the trickplay metadata from a BaseItemDto.
|
||||||
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
* @param item The Jellyfin media item.
|
||||||
const lastCalculationTime = useRef(0);
|
* @returns Parsed trickplay information or null if not available.
|
||||||
const throttleDelay = 200; // 200ms throttle
|
*/
|
||||||
|
export const getTrickplayInfo = (item: BaseItemDto): TrickplayInfo | null => {
|
||||||
|
if (!item.Id || !item.Trickplay) return null;
|
||||||
|
|
||||||
const trickplayInfo = useMemo(() => {
|
const mediaSourceId = item.Id;
|
||||||
if (!enabled || !item.Id || !item.Trickplay) {
|
const trickplayDataForSource = item.Trickplay[mediaSourceId];
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mediaSourceId = item.Id;
|
if (!trickplayDataForSource) {
|
||||||
const trickplayData = item.Trickplay[mediaSourceId];
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!trickplayData) {
|
const firstResolution = Object.keys(trickplayDataForSource)[0];
|
||||||
return null;
|
if (!firstResolution) {
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Get the first available resolution
|
const data = trickplayDataForSource[firstResolution];
|
||||||
const firstResolution = Object.keys(trickplayData)[0];
|
const { Interval, TileWidth, TileHeight, Width, Height } = data;
|
||||||
return firstResolution
|
|
||||||
? {
|
|
||||||
resolution: firstResolution,
|
|
||||||
aspectRatio:
|
|
||||||
trickplayData[firstResolution].Width! /
|
|
||||||
trickplayData[firstResolution].Height!,
|
|
||||||
data: trickplayData[firstResolution],
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
}, [item, enabled]);
|
|
||||||
|
|
||||||
// Takes in ticks.
|
if (
|
||||||
const calculateTrickplayUrl = useCallback(
|
!Interval ||
|
||||||
(progress: number) => {
|
!TileWidth ||
|
||||||
if (!enabled) {
|
!TileHeight ||
|
||||||
return null;
|
!Width ||
|
||||||
}
|
!Height ||
|
||||||
|
!item.RunTimeTicks
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const tilesPerSheet = TileWidth * TileHeight;
|
||||||
if (now - lastCalculationTime.current < throttleDelay) {
|
const totalTiles = Math.ceil(ticksToMs(item.RunTimeTicks) / Interval);
|
||||||
return null;
|
const totalImageSheets = Math.ceil(totalTiles / tilesPerSheet);
|
||||||
}
|
|
||||||
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]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
trickPlayUrl: enabled ? trickPlayUrl : null,
|
resolution: firstResolution,
|
||||||
calculateTrickplayUrl: enabled ? calculateTrickplayUrl : () => null,
|
aspectRatio: Width / Height,
|
||||||
prefetchAllTrickplayImages: enabled
|
data,
|
||||||
? prefetchAllTrickplayImages
|
totalImageSheets,
|
||||||
: () => null,
|
|
||||||
trickplayInfo: enabled ? trickplayInfo : null,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
};
|
||||||
3
i18n.ts
3
i18n.ts
@@ -1,7 +1,6 @@
|
|||||||
|
import { getLocales } from "expo-localization";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
|
|
||||||
import { getLocales } from "expo-localization";
|
|
||||||
import de from "./translations/de.json";
|
import de from "./translations/de.json";
|
||||||
import en from "./translations/en.json";
|
import en from "./translations/en.json";
|
||||||
import eo from "./translations/eo.json";
|
import eo from "./translations/eo.json";
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ export type VlcPlayerSource = {
|
|||||||
type?: string;
|
type?: string;
|
||||||
isNetwork?: boolean;
|
isNetwork?: boolean;
|
||||||
autoplay?: boolean;
|
autoplay?: boolean;
|
||||||
externalSubtitles: { name: string; DeliveryUrl: string }[];
|
startPosition?: number;
|
||||||
|
externalSubtitles?: { name: string; DeliveryUrl: string }[];
|
||||||
initOptions?: any[];
|
initOptions?: any[];
|
||||||
mediaOptions?: { [key: string]: any };
|
mediaOptions?: { [key: string]: any };
|
||||||
startPosition?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TrackInfo = {
|
export type TrackInfo = {
|
||||||
@@ -94,5 +94,5 @@ export interface VlcPlayerViewRef {
|
|||||||
getChapters: () => Promise<ChapterInfo[] | null>;
|
getChapters: () => Promise<ChapterInfo[] | null>;
|
||||||
setVideoCropGeometry: (geometry: string | null) => Promise<void>;
|
setVideoCropGeometry: (geometry: string | null) => Promise<void>;
|
||||||
getVideoCropGeometry: () => Promise<string | null>;
|
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 { requireNativeViewManager } from "expo-modules-core";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { ViewStyle } from "react-native";
|
||||||
import { VideoPlayer, useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Platform, ViewStyle } from "react-native";
|
|
||||||
import type {
|
import type {
|
||||||
VlcPlayerSource,
|
VlcPlayerSource,
|
||||||
VlcPlayerViewProps,
|
VlcPlayerViewProps,
|
||||||
@@ -13,22 +11,12 @@ interface NativeViewRef extends VlcPlayerViewRef {
|
|||||||
setNativeProps?: (props: Partial<VlcPlayerViewProps>) => void;
|
setNativeProps?: (props: Partial<VlcPlayerViewProps>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VLCViewManager = requireNativeViewManager("VlcPlayer");
|
|
||||||
const VLC3ViewManager = requireNativeViewManager("VlcPlayer3");
|
const VLC3ViewManager = requireNativeViewManager("VlcPlayer3");
|
||||||
|
|
||||||
// Create a forwarded ref version of the native view
|
// Create a forwarded ref version of the native view
|
||||||
const NativeView = React.forwardRef<NativeViewRef, VlcPlayerViewProps>(
|
const NativeView = React.forwardRef<NativeViewRef, VlcPlayerViewProps>(
|
||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
const [settings] = useSettings();
|
return <VLC3ViewManager {...props} ref={ref} />;
|
||||||
|
|
||||||
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} />;
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -95,8 +83,8 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
|||||||
const geometry = await nativeRef.current?.getVideoCropGeometry();
|
const geometry = await nativeRef.current?.getVideoCropGeometry();
|
||||||
return geometry ?? null;
|
return geometry ?? null;
|
||||||
},
|
},
|
||||||
setSubtitleURL: async (url: string, name: string) => {
|
setSubtitleURL: async (url: string) => {
|
||||||
await nativeRef.current?.setSubtitleURL(url, name);
|
await nativeRef.current?.setSubtitleURL(url);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ public class VlcPlayer3Module: Module {
|
|||||||
return view.getAudioTracks()
|
return view.getAudioTracks()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleURL") { (view: VlcPlayer3View, url: String, name: String) in
|
||||||
|
view.setSubtitleURL(url, name: name)
|
||||||
|
}
|
||||||
|
|
||||||
AsyncFunction("setSubtitleTrack") { (view: VlcPlayer3View, trackIndex: Int) in
|
AsyncFunction("setSubtitleTrack") { (view: VlcPlayer3View, trackIndex: Int) in
|
||||||
view.setSubtitleTrack(trackIndex)
|
view.setSubtitleTrack(trackIndex)
|
||||||
}
|
}
|
||||||
@@ -61,11 +65,6 @@ public class VlcPlayer3Module: Module {
|
|||||||
AsyncFunction("getSubtitleTracks") { (view: VlcPlayer3View) -> [[String: Any]]? in
|
AsyncFunction("getSubtitleTracks") { (view: VlcPlayer3View) -> [[String: Any]]? in
|
||||||
return view.getSubtitleTracks()
|
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 isStopping: Bool = false // Define isStopping here
|
||||||
private var lastProgressCall = Date().timeIntervalSince1970
|
private var lastProgressCall = Date().timeIntervalSince1970
|
||||||
var hasSource = false
|
var hasSource = false
|
||||||
|
var isTranscoding = false
|
||||||
|
private var initialSeekPerformed: Bool = false
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
@@ -88,7 +90,6 @@ class VlcPlayer3View: ExpoView {
|
|||||||
// If the specified time is greater than the duration, seek to the end
|
// If the specified time is greater than the duration, seek to the end
|
||||||
let seekTime = time > duration ? duration - 1000 : time
|
let seekTime = time > duration ? duration - 1000 : time
|
||||||
player.time = VLCTime(int: seekTime)
|
player.time = VLCTime(int: seekTime)
|
||||||
|
|
||||||
if wasPlaying {
|
if wasPlaying {
|
||||||
self.play()
|
self.play()
|
||||||
}
|
}
|
||||||
@@ -110,13 +111,18 @@ class VlcPlayer3View: ExpoView {
|
|||||||
var initOptions = source["initOptions"] as? [Any] ?? []
|
var initOptions = source["initOptions"] as? [Any] ?? []
|
||||||
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
||||||
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
|
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
|
||||||
initOptions.append("--start-time=\(self.startPosition)")
|
|
||||||
|
|
||||||
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
||||||
print("Error: Invalid or empty URI")
|
print("Error: Invalid or empty URI")
|
||||||
self.onVideoError?(["error": "Invalid or empty URI"])
|
self.onVideoError?(["error": "Invalid or empty URI"])
|
||||||
return
|
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 autoplay = source["autoplay"] as? Bool ?? false
|
||||||
let isNetwork = source["isNetwork"] as? Bool ?? false
|
let isNetwork = source["isNetwork"] as? Bool ?? false
|
||||||
@@ -126,6 +132,7 @@ class VlcPlayer3View: ExpoView {
|
|||||||
self.mediaPlayer?.delegate = self
|
self.mediaPlayer?.delegate = self
|
||||||
self.mediaPlayer?.drawable = self.videoView
|
self.mediaPlayer?.drawable = self.videoView
|
||||||
self.mediaPlayer?.scaleFactor = 0
|
self.mediaPlayer?.scaleFactor = 0
|
||||||
|
self.initialSeekPerformed = false
|
||||||
|
|
||||||
let media: VLCMedia
|
let media: VLCMedia
|
||||||
if isNetwork {
|
if isNetwork {
|
||||||
@@ -287,9 +294,14 @@ class VlcPlayer3View: ExpoView {
|
|||||||
|
|
||||||
let currentTimeMs = player.time.intValue
|
let currentTimeMs = player.time.intValue
|
||||||
let durationMs = player.media?.length.intValue ?? 0
|
let durationMs = player.media?.length.intValue ?? 0
|
||||||
|
|
||||||
|
|
||||||
print("Debug: Current time: \(currentTimeMs)")
|
print("Debug: Current time: \(currentTimeMs)")
|
||||||
if currentTimeMs >= 0 && currentTimeMs < durationMs {
|
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?([
|
self.onVideoProgress?([
|
||||||
"currentTime": currentTimeMs,
|
"currentTime": currentTimeMs,
|
||||||
"duration": durationMs,
|
"duration": durationMs,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ if (useManagedAndroidSdkVersions) {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath "com.android.tools.build:gradle:7.1.3"
|
classpath "com.android.tools.build:gradle:8.11.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
project.android {
|
project.android {
|
||||||
|
|||||||
@@ -137,10 +137,7 @@ extension VLCPlayerWrapper: VLCMediaPlayerDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - VLCMediaDelegate
|
|
||||||
extension VLCPlayerWrapper: VLCMediaDelegate {
|
|
||||||
// Implement VLCMediaDelegate methods if needed
|
|
||||||
}
|
|
||||||
|
|
||||||
class VlcPlayerView: ExpoView {
|
class VlcPlayerView: ExpoView {
|
||||||
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayerView")
|
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 isStopping: Bool = false // Define isStopping here
|
||||||
private var externalSubtitles: [[String: String]]?
|
private var externalSubtitles: [[String: String]]?
|
||||||
var hasSource = false
|
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
|
// MARK: - Initialization
|
||||||
required init(appContext: AppContext? = nil) {
|
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() {
|
private func setupNotifications() {
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self, selector: #selector(applicationWillResignActive),
|
self, selector: #selector(applicationWillResignActive),
|
||||||
@@ -254,6 +268,8 @@ class VlcPlayerView: ExpoView {
|
|||||||
let autoplay = source["autoplay"] as? Bool ?? false
|
let autoplay = source["autoplay"] as? Bool ?? false
|
||||||
let isNetwork = source["isNetwork"] 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()])
|
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
|
||||||
|
|
||||||
let media: VLCMedia!
|
let media: VLCMedia!
|
||||||
@@ -277,8 +293,11 @@ class VlcPlayerView: ExpoView {
|
|||||||
self.hasSource = true
|
self.hasSource = true
|
||||||
if autoplay {
|
if autoplay {
|
||||||
logger.info("Playing...")
|
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.play()
|
||||||
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -415,6 +434,9 @@ class VlcPlayerView: ExpoView {
|
|||||||
|
|
||||||
private func updatePlayerState() {
|
private func updatePlayerState() {
|
||||||
let player = self.vlc.player
|
let player = self.vlc.player
|
||||||
|
if player.isPlaying {
|
||||||
|
performInitialSeek()
|
||||||
|
}
|
||||||
self.onVideoStateChange?([
|
self.onVideoStateChange?([
|
||||||
"target": self.reactTag ?? NSNull(),
|
"target": self.reactTag ?? NSNull(),
|
||||||
"currentTime": player.time.intValue,
|
"currentTime": player.time.intValue,
|
||||||
|
|||||||
66
package.json
66
package.json
@@ -18,7 +18,7 @@
|
|||||||
"lint": "biome check --write --unsafe"
|
"lint": "biome check --write --unsafe"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bottom-tabs/react-navigation": "0.8.6",
|
"@bottom-tabs/react-navigation": "0.9.2",
|
||||||
"@expo/config-plugins": "~9.0.15",
|
"@expo/config-plugins": "~9.0.15",
|
||||||
"@expo/react-native-action-sheet": "^4.1.1",
|
"@expo/react-native-action-sheet": "^4.1.1",
|
||||||
"@expo/vector-icons": "^14.0.4",
|
"@expo/vector-icons": "^14.0.4",
|
||||||
@@ -35,36 +35,36 @@
|
|||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
"add": "^2.0.6",
|
"add": "^2.0.6",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"expo": "^52.0.31",
|
"expo": "~52.0.47",
|
||||||
"expo-asset": "~11.0.3",
|
"expo-asset": "~11.0.5",
|
||||||
"expo-background-fetch": "~13.0.5",
|
"expo-background-fetch": "~13.0.6",
|
||||||
"expo-blur": "~14.0.3",
|
"expo-blur": "~14.0.3",
|
||||||
"expo-brightness": "~13.0.3",
|
"expo-brightness": "~13.0.3",
|
||||||
"expo-build-properties": "~0.13.2",
|
"expo-build-properties": "~0.13.3",
|
||||||
"expo-constants": "~17.0.5",
|
"expo-constants": "~17.0.8",
|
||||||
"expo-crypto": "~14.0.2",
|
"expo-crypto": "~14.0.2",
|
||||||
"expo-dev-client": "~5.0.11",
|
"expo-dev-client": "~5.0.20",
|
||||||
"expo-device": "~7.0.2",
|
"expo-device": "~7.0.3",
|
||||||
"expo-font": "~13.0.3",
|
"expo-font": "~13.0.3",
|
||||||
"expo-haptics": "~14.0.1",
|
"expo-haptics": "~14.0.1",
|
||||||
"expo-image": "~2.0.4",
|
"expo-image": "~2.0.7",
|
||||||
"expo-keep-awake": "~14.0.2",
|
"expo-keep-awake": "~14.0.2",
|
||||||
"expo-linear-gradient": "~14.0.2",
|
"expo-linear-gradient": "~14.0.2",
|
||||||
"expo-linking": "~7.0.5",
|
"expo-linking": "~7.0.5",
|
||||||
"expo-localization": "~16.0.1",
|
"expo-localization": "~16.0.1",
|
||||||
"expo-network": "~7.0.5",
|
"expo-network": "~7.0.5",
|
||||||
"expo-notifications": "~0.29.13",
|
"expo-notifications": "~0.29.14",
|
||||||
"expo-router": "~4.0.17",
|
"expo-router": "~4.0.21",
|
||||||
"expo-screen-orientation": "~8.0.4",
|
"expo-screen-orientation": "~8.0.4",
|
||||||
"expo-sensors": "~14.0.2",
|
"expo-sensors": "~14.0.2",
|
||||||
"expo-sharing": "~13.0.1",
|
"expo-sharing": "~13.0.1",
|
||||||
"expo-splash-screen": "~0.29.22",
|
"expo-splash-screen": "~0.29.24",
|
||||||
"expo-status-bar": "~2.0.1",
|
"expo-status-bar": "~2.0.1",
|
||||||
"expo-system-ui": "~4.0.8",
|
"expo-system-ui": "~4.0.9",
|
||||||
"expo-task-manager": "~12.0.5",
|
"expo-task-manager": "~12.0.6",
|
||||||
"expo-updates": "~0.26.17",
|
"expo-updates": "~0.27.4",
|
||||||
"expo-web-browser": "~14.0.2",
|
"expo-web-browser": "~14.0.2",
|
||||||
"i18next": "^24.2.2",
|
"i18next": "^25.0.0",
|
||||||
"jotai": "^2.11.3",
|
"jotai": "^2.11.3",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
@@ -73,27 +73,27 @@
|
|||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "^15.4.0",
|
||||||
"react-native": "npm:react-native-tvos@~0.77.2-0",
|
"react-native": "npm:react-native-tvos@~0.77.2-0",
|
||||||
"react-native-awesome-slider": "^2.9.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-circular-progress": "^1.4.1",
|
||||||
"react-native-collapsible": "^1.6.2",
|
"react-native-collapsible": "^1.6.2",
|
||||||
"react-native-compressor": "^1.10.3",
|
"react-native-compressor": "^1.10.3",
|
||||||
"react-native-country-flag": "^2.0.2",
|
"react-native-country-flag": "^2.0.2",
|
||||||
"react-native-device-info": "^14.0.4",
|
"react-native-device-info": "^14.0.4",
|
||||||
"react-native-edge-to-edge": "^1.4.3",
|
"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-get-random-values": "^1.11.0",
|
||||||
"react-native-google-cast": "^4.8.3",
|
"react-native-google-cast": "^4.8.3",
|
||||||
"react-native-image-colors": "^2.4.0",
|
"react-native-image-colors": "^2.4.0",
|
||||||
"react-native-ios-context-menu": "^3.1.0",
|
"react-native-ios-context-menu": "^3.1.0",
|
||||||
"react-native-ios-utilities": "5.1.1",
|
"react-native-ios-utilities": "5.1.1",
|
||||||
"react-native-mmkv": "^2.12.2",
|
"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-progress": "^5.0.1",
|
||||||
"react-native-reanimated": "~3.16.7",
|
"react-native-reanimated": "~3.16.7",
|
||||||
"react-native-reanimated-carousel": "3.5.1",
|
"react-native-reanimated-carousel": "3.5.1",
|
||||||
"react-native-safe-area-context": "5.1.0",
|
"react-native-safe-area-context": "5.2.0",
|
||||||
"react-native-screens": "~4.5.0",
|
"react-native-screens": "4.10.0",
|
||||||
"react-native-svg": "15.11.1",
|
"react-native-svg": "15.11.2",
|
||||||
"react-native-tab-view": "^4.0.5",
|
"react-native-tab-view": "^4.0.5",
|
||||||
"react-native-udp": "^4.1.7",
|
"react-native-udp": "^4.1.7",
|
||||||
"react-native-uitextview": "^1.4.0",
|
"react-native-uitextview": "^1.4.0",
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
"react-native-video": "6.10.0",
|
"react-native-video": "6.10.0",
|
||||||
"react-native-volume-manager": "^2.0.8",
|
"react-native-volume-manager": "^2.0.8",
|
||||||
"react-native-web": "~0.19.13",
|
"react-native-web": "~0.19.13",
|
||||||
"react-native-webview": "13.13.2",
|
"react-native-webview": "13.13.0",
|
||||||
"sonner-native": "^0.17.0",
|
"sonner-native": "^0.17.0",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
@@ -112,10 +112,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.26.8",
|
"@babel/core": "^7.26.8",
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "^2.0.0",
|
||||||
"@react-native-community/cli": "15.1.3",
|
"@react-native-community/cli": "18.0.0",
|
||||||
"@react-native-tvos/config-tv": "^0.1.1",
|
"@react-native-tvos/config-tv": "^0.1.1",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/lodash": "^4.17.15",
|
"@types/lodash": "^4.17.15",
|
||||||
"@types/react": "~18.3.12",
|
"@types/react": "~18.3.12",
|
||||||
"@types/react-native-vector-icons": "^6.4.18",
|
"@types/react-native-vector-icons": "^6.4.18",
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^15.5.0",
|
"lint-staged": "^16.1.2",
|
||||||
"postinstall-postinstall": "^2.1.0",
|
"postinstall-postinstall": "^2.1.0",
|
||||||
"react-test-renderer": "19.0.0",
|
"react-test-renderer": "19.0.0",
|
||||||
"typescript": "~5.7.3"
|
"typescript": "~5.7.3"
|
||||||
@@ -131,11 +131,17 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"expo": {
|
"expo": {
|
||||||
"install": {
|
"install": {
|
||||||
"exclude": ["react-native"]
|
"exclude": [
|
||||||
|
"react-native"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx}": ["biome check --write --unsafe"],
|
"*.{js,jsx,ts,tsx}": [
|
||||||
"*.{json}": ["biome format --write"]
|
"biome check --write --unsafe --no-errors-on-unmatched"
|
||||||
|
],
|
||||||
|
"*.json": [
|
||||||
|
"biome format --write"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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 "@/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 Api, Jellyfin } from "@jellyfin/sdk";
|
||||||
import type { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
|
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 axios, { AxiosError } from "axios";
|
||||||
import { router, useSegments } from "expo-router";
|
import { router, useSegments } from "expo-router";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import {
|
import {
|
||||||
type ReactNode,
|
|
||||||
createContext,
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -27,6 +21,12 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { getDeviceName } from "react-native-device-info";
|
import { getDeviceName } from "react-native-device-info";
|
||||||
import uuid from "react-native-uuid";
|
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 {
|
interface Server {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.28.0" },
|
clientInfo: { name: "Streamyfin", version: "0.28.1" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -80,9 +80,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const [isPolling, setIsPolling] = useState<boolean>(false);
|
const [isPolling, setIsPolling] = useState<boolean>(false);
|
||||||
const [secret, setSecret] = useState<string | null>(null);
|
const [secret, setSecret] = useState<string | null>(null);
|
||||||
const [
|
const [
|
||||||
settings,
|
_settings,
|
||||||
updateSettings,
|
_updateSettings,
|
||||||
pluginSettings,
|
_pluginSettings,
|
||||||
setPluginSettings,
|
setPluginSettings,
|
||||||
refreshStreamyfinPluginSettings,
|
refreshStreamyfinPluginSettings,
|
||||||
] = useSettings();
|
] = useSettings();
|
||||||
@@ -91,9 +91,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const headers = useMemo(() => {
|
const headers = useMemo(() => {
|
||||||
if (!deviceId) return {};
|
if (!deviceId) return {};
|
||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${Platform.OS === "android" ? "Android" : "iOS"
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
}, DeviceId="${deviceId}", Version="0.28.1"`,
|
||||||
}, DeviceId="${deviceId}", Version="0.28.0"`,
|
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
@@ -287,8 +286,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
api
|
api
|
||||||
?.delete(`/Streamyfin/device/${deviceId}`)
|
?.delete(`/Streamyfin/device/${deviceId}`)
|
||||||
.then((r) => writeInfoLog("Deleted expo push token for device"))
|
.then((_r) => writeInfoLog("Deleted expo push token for device"))
|
||||||
.catch((e) =>
|
.catch((_e) =>
|
||||||
writeErrorLog("Failed to delete expo push token for device"),
|
writeErrorLog("Failed to delete expo push token for device"),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -380,8 +379,6 @@ function useProtectedRoute(user: UserDto | null, loaded = false) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loaded === false) return;
|
if (loaded === false) return;
|
||||||
|
|
||||||
console.log("Loaded", user);
|
|
||||||
|
|
||||||
const inAuthGroup = segments[0] === "(auth)";
|
const inAuthGroup = segments[0] === "(auth)";
|
||||||
|
|
||||||
if (!user?.Id && inAuthGroup) {
|
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 generateDeviceProfile from "@/utils/profiles/native";
|
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import {
|
import { createContext, useCallback, useContext, useState } from "react";
|
||||||
createContext,
|
import type { Bitrate } from "@/components/BitrateSelector";
|
||||||
useCallback,
|
import { settingsAtom } from "@/utils/atoms/settings";
|
||||||
useContext,
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
useEffect,
|
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { apiAtom, userAtom } from "./JellyfinProvider";
|
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||||
|
|
||||||
export type PlaybackType = {
|
export type PlaybackType = {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"description": "Default Renovate preset for Streamyfin repositories",
|
"description": "Default Renovate preset for Streamyfin repositories",
|
||||||
"extends": [
|
"extends": [
|
||||||
"config:base",
|
"config:base",
|
||||||
":disableDependencyDashboard",
|
":dependencyDashboard",
|
||||||
":enableVulnerabilityAlertsWithLabel(security)",
|
":enableVulnerabilityAlertsWithLabel(security)",
|
||||||
":semanticCommits",
|
":semanticCommits",
|
||||||
":timezone(Etc/UTC)",
|
":timezone(Etc/UTC)",
|
||||||
|
|||||||
@@ -408,6 +408,7 @@
|
|||||||
"download_episode": "Download Episode",
|
"download_episode": "Download Episode",
|
||||||
"download_movie": "Download Movie",
|
"download_movie": "Download Movie",
|
||||||
"download_x_item": "Download {{item_count}} items",
|
"download_x_item": "Download {{item_count}} items",
|
||||||
|
"download_unwatched_only": "Unwatched Only",
|
||||||
"download_button": "Download",
|
"download_button": "Download",
|
||||||
"using_optimized_server": "Using optimized server",
|
"using_optimized_server": "Using optimized server",
|
||||||
"using_default_method": "Using default method"
|
"using_default_method": "Using default method"
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export enum SortByOption {
|
|||||||
CommunityRating = "CommunityRating",
|
CommunityRating = "CommunityRating",
|
||||||
CriticRating = "CriticRating",
|
CriticRating = "CriticRating",
|
||||||
DateCreated = "DateCreated",
|
DateCreated = "DateCreated",
|
||||||
DateLastContentAdded = "DateLastContentAdded",
|
|
||||||
DatePlayed = "DatePlayed",
|
DatePlayed = "DatePlayed",
|
||||||
PlayCount = "PlayCount",
|
PlayCount = "PlayCount",
|
||||||
ProductionYear = "ProductionYear",
|
ProductionYear = "ProductionYear",
|
||||||
@@ -38,7 +37,6 @@ export const sortOptions: {
|
|||||||
{ key: SortByOption.CommunityRating, value: "Community Rating" },
|
{ key: SortByOption.CommunityRating, value: "Community Rating" },
|
||||||
{ key: SortByOption.CriticRating, value: "Critics Rating" },
|
{ key: SortByOption.CriticRating, value: "Critics Rating" },
|
||||||
{ key: SortByOption.DateCreated, value: "Date Added" },
|
{ key: SortByOption.DateCreated, value: "Date Added" },
|
||||||
{ key: SortByOption.DateLastContentAdded, value: "Date Episode Added" },
|
|
||||||
{ key: SortByOption.DatePlayed, value: "Date Played" },
|
{ key: SortByOption.DatePlayed, value: "Date Played" },
|
||||||
{ key: SortByOption.PlayCount, value: "Play Count" },
|
{ key: SortByOption.PlayCount, value: "Play Count" },
|
||||||
{ key: SortByOption.ProductionYear, value: "Production Year" },
|
{ key: SortByOption.ProductionYear, value: "Production Year" },
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { processesAtom } from "@/providers/DownloadProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import type { JobStatus } from "@/utils/optimize-server";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { processesAtom } from "@/providers/DownloadProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { JobStatus } from "@/providers/Downloads/types";
|
||||||
|
|
||||||
export interface Job {
|
export interface Job {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -68,5 +68,5 @@ export const useJobProcessor = () => {
|
|||||||
console.info("Processing queue", queue);
|
console.info("Processing queue", queue);
|
||||||
queueActions.processJob(queue, setQueue, setRunning);
|
queueActions.processJob(queue, setQueue, setRunning);
|
||||||
}
|
}
|
||||||
}, [processes, queue, running, setQueue, setRunning]);
|
}, [processes, queue, running, setQueue, setRunning, settings]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { Video } from "@/utils/jellyseerr/server/models/Movie";
|
|
||||||
import { writeInfoLog } from "@/utils/log";
|
|
||||||
import {
|
import {
|
||||||
type BaseItemKind,
|
type BaseItemKind,
|
||||||
type CultureDto,
|
type CultureDto,
|
||||||
@@ -14,9 +9,13 @@ import {
|
|||||||
import { atom, useAtom, useAtomValue } from "jotai";
|
import { atom, useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { writeInfoLog } from "@/utils/log";
|
||||||
import { storage } from "../mmkv";
|
import { storage } from "../mmkv";
|
||||||
|
|
||||||
const STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004";
|
const _STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004";
|
||||||
const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS";
|
const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS";
|
||||||
|
|
||||||
export type DownloadQuality = "original" | "high" | "low";
|
export type DownloadQuality = "original" | "high" | "low";
|
||||||
@@ -82,7 +81,6 @@ export type DefaultLanguageOption = {
|
|||||||
|
|
||||||
export enum DownloadMethod {
|
export enum DownloadMethod {
|
||||||
Remux = "remux",
|
Remux = "remux",
|
||||||
Optimized = "optimized",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Home = {
|
export type Home = {
|
||||||
@@ -156,7 +154,6 @@ export type Settings = {
|
|||||||
defaultVideoOrientation: ScreenOrientation.OrientationLock;
|
defaultVideoOrientation: ScreenOrientation.OrientationLock;
|
||||||
forwardSkipTime: number;
|
forwardSkipTime: number;
|
||||||
rewindSkipTime: number;
|
rewindSkipTime: number;
|
||||||
optimizedVersionsServerUrl?: string | null;
|
|
||||||
downloadMethod: DownloadMethod;
|
downloadMethod: DownloadMethod;
|
||||||
autoDownload: boolean;
|
autoDownload: boolean;
|
||||||
showCustomMenuLinks: boolean;
|
showCustomMenuLinks: boolean;
|
||||||
@@ -213,7 +210,6 @@ const defaultValues: Settings = {
|
|||||||
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
|
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
|
||||||
forwardSkipTime: 30,
|
forwardSkipTime: 30,
|
||||||
rewindSkipTime: 10,
|
rewindSkipTime: 10,
|
||||||
optimizedVersionsServerUrl: null,
|
|
||||||
downloadMethod: DownloadMethod.Remux,
|
downloadMethod: DownloadMethod.Remux,
|
||||||
autoDownload: false,
|
autoDownload: false,
|
||||||
showCustomMenuLinks: false,
|
showCustomMenuLinks: false,
|
||||||
@@ -224,7 +220,7 @@ const defaultValues: Settings = {
|
|||||||
jellyseerrServerUrl: undefined,
|
jellyseerrServerUrl: undefined,
|
||||||
hiddenLibraries: [],
|
hiddenLibraries: [],
|
||||||
enableH265ForChromecast: false,
|
enableH265ForChromecast: false,
|
||||||
defaultPlayer: VideoPlayer.VLC_3, // ios-only setting. does not matter what this is for android
|
defaultPlayer: VideoPlayer.VLC_4, // ios-only setting. does not matter what this is for android
|
||||||
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
||||||
autoPlayEpisodeCount: 0,
|
autoPlayEpisodeCount: 0,
|
||||||
};
|
};
|
||||||
@@ -288,7 +284,7 @@ export const useSettings = () => {
|
|||||||
writeInfoLog("Got plugin settings", data?.settings);
|
writeInfoLog("Got plugin settings", data?.settings);
|
||||||
return data?.settings;
|
return data?.settings;
|
||||||
},
|
},
|
||||||
(err) => undefined,
|
(_err) => undefined,
|
||||||
);
|
);
|
||||||
setPluginSettings(settings);
|
setPluginSettings(settings);
|
||||||
return settings;
|
return settings;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// utils/getDefaultPlaySettings.ts
|
// utils/getDefaultPlaySettings.ts
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { type Settings, useSettings } from "../atoms/settings";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
|
import { type Settings } from "../atoms/settings";
|
||||||
import {
|
import {
|
||||||
AudioStreamRanker,
|
AudioStreamRanker,
|
||||||
StreamRanker,
|
StreamRanker,
|
||||||
@@ -50,18 +51,9 @@ export function getDefaultPlaySettings(
|
|||||||
|
|
||||||
const mediaSource = item.MediaSources?.[0];
|
const mediaSource = item.MediaSources?.[0];
|
||||||
|
|
||||||
// 2. Get default or preferred audio
|
|
||||||
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
|
||||||
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
|
||||||
(x) => x.Type === "Audio" && x.Language === settings?.defaultAudioLanguage,
|
|
||||||
)?.Index;
|
|
||||||
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
|
||||||
(x) => x.Type === "Audio",
|
|
||||||
)?.Index;
|
|
||||||
|
|
||||||
// We prefer the previous track over the default track.
|
// We prefer the previous track over the default track.
|
||||||
const trackOptions: TrackOptions = {
|
const trackOptions: TrackOptions = {
|
||||||
DefaultAudioStreamIndex: defaultAudioIndex ?? -1,
|
DefaultAudioStreamIndex: mediaSource?.DefaultAudioStreamIndex ?? -1,
|
||||||
DefaultSubtitleStreamIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1,
|
DefaultSubtitleStreamIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
68
utils/jellyfin/media/getDownloadUrl.ts
Normal file
68
utils/jellyfin/media/getDownloadUrl.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Bitrate } from "@/components/BitrateSelector";
|
||||||
|
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||||
|
import { getDownloadStreamUrl, getStreamUrl } from "./getStreamUrl";
|
||||||
|
|
||||||
|
export const getDownloadUrl = async ({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
userId,
|
||||||
|
mediaSource,
|
||||||
|
maxBitrate,
|
||||||
|
audioStreamIndex,
|
||||||
|
subtitleStreamIndex,
|
||||||
|
deviceId,
|
||||||
|
}: {
|
||||||
|
api: Api;
|
||||||
|
item: BaseItemDto;
|
||||||
|
userId: string;
|
||||||
|
mediaSource: MediaSourceInfo;
|
||||||
|
maxBitrate: Bitrate;
|
||||||
|
audioStreamIndex: number;
|
||||||
|
subtitleStreamIndex: number;
|
||||||
|
deviceId: string;
|
||||||
|
}): Promise<{
|
||||||
|
url: string | null;
|
||||||
|
mediaSource: MediaSourceInfo | null;
|
||||||
|
} | null> => {
|
||||||
|
const streamDetails = await getStreamUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
userId,
|
||||||
|
startTimeTicks: 0,
|
||||||
|
mediaSourceId: mediaSource.Id,
|
||||||
|
maxStreamingBitrate: maxBitrate.value,
|
||||||
|
audioStreamIndex,
|
||||||
|
subtitleStreamIndex,
|
||||||
|
deviceId,
|
||||||
|
deviceProfile: await generateDeviceProfile(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) {
|
||||||
|
console.log("Downloading item directly");
|
||||||
|
return {
|
||||||
|
url: `${api.basePath}/Items/${item.Id}/Download?api_key=${api.accessToken}`,
|
||||||
|
mediaSource: streamDetails?.mediaSource ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadStreamDetails = await getDownloadStreamUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
userId,
|
||||||
|
mediaSourceId: mediaSource.Id,
|
||||||
|
deviceId,
|
||||||
|
maxStreamingBitrate: maxBitrate.value,
|
||||||
|
audioStreamIndex,
|
||||||
|
subtitleStreamIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: downloadStreamDetails?.url ?? null,
|
||||||
|
mediaSource: downloadStreamDetails?.mediaSource ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import generateDeviceProfile from "@/utils/profiles/native";
|
|
||||||
import type { Api } from "@jellyfin/sdk";
|
import type { Api } from "@jellyfin/sdk";
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
PlaybackInfoResponse,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { Alert } from "react-native";
|
import download from "@/utils/profiles/download";
|
||||||
|
|
||||||
export const getStreamUrl = async ({
|
export const getStreamUrl = async ({
|
||||||
api,
|
api,
|
||||||
@@ -15,11 +13,10 @@ export const getStreamUrl = async ({
|
|||||||
startTimeTicks = 0,
|
startTimeTicks = 0,
|
||||||
maxStreamingBitrate,
|
maxStreamingBitrate,
|
||||||
playSessionId,
|
playSessionId,
|
||||||
deviceProfile = generateDeviceProfile(),
|
deviceProfile,
|
||||||
audioStreamIndex = 0,
|
audioStreamIndex = 0,
|
||||||
subtitleStreamIndex = undefined,
|
subtitleStreamIndex = undefined,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
download = false,
|
|
||||||
deviceId,
|
deviceId,
|
||||||
}: {
|
}: {
|
||||||
api: Api | null | undefined;
|
api: Api | null | undefined;
|
||||||
@@ -28,12 +25,11 @@ export const getStreamUrl = async ({
|
|||||||
startTimeTicks: number;
|
startTimeTicks: number;
|
||||||
maxStreamingBitrate?: number;
|
maxStreamingBitrate?: number;
|
||||||
playSessionId?: string | null;
|
playSessionId?: string | null;
|
||||||
deviceProfile?: any;
|
deviceProfile: any;
|
||||||
audioStreamIndex?: number;
|
audioStreamIndex?: number;
|
||||||
subtitleStreamIndex?: number;
|
subtitleStreamIndex?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
mediaSourceId?: string | null;
|
mediaSourceId?: string | null;
|
||||||
download?: bool;
|
|
||||||
deviceId?: string | null;
|
deviceId?: string | null;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
url: string | null;
|
url: string | null;
|
||||||
@@ -73,12 +69,16 @@ export const getStreamUrl = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
sessionId = res.data.PlaySessionId || null;
|
sessionId = res.data.PlaySessionId || null;
|
||||||
mediaSource = res.data.MediaSources[0];
|
mediaSource = res.data.MediaSources?.[0];
|
||||||
let transcodeUrl = mediaSource.TranscodingUrl;
|
let transcodeUrl = mediaSource?.TranscodingUrl;
|
||||||
|
|
||||||
if (transcodeUrl) {
|
if (transcodeUrl) {
|
||||||
if (download) {
|
// We need to change the subtitle method to hls for the transcoded url.
|
||||||
transcodeUrl = transcodeUrl.replace("master.m3u8", "stream");
|
if (subtitleStreamIndex === -1) {
|
||||||
|
transcodeUrl = transcodeUrl.replace(
|
||||||
|
"SubtitleMethod=Encode",
|
||||||
|
"SubtitleMethod=Hls",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
console.log("Video is being transcoded:", transcodeUrl);
|
console.log("Video is being transcoded:", transcodeUrl);
|
||||||
return {
|
return {
|
||||||
@@ -88,21 +88,6 @@ export const getStreamUrl = async ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let downloadParams = {};
|
|
||||||
|
|
||||||
if (download) {
|
|
||||||
// We need to disable static so we can have a remux with subtitle.
|
|
||||||
downloadParams = {
|
|
||||||
subtitleMethod: "Embed",
|
|
||||||
enableSubtitlesInManifest: true,
|
|
||||||
static: "false",
|
|
||||||
allowVideoStreamCopy: true,
|
|
||||||
allowAudioStreamCopy: true,
|
|
||||||
playSessionId: sessionId || "",
|
|
||||||
container: "ts",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamParams = new URLSearchParams({
|
const streamParams = new URLSearchParams({
|
||||||
static: "true",
|
static: "true",
|
||||||
container: "mp4",
|
container: "mp4",
|
||||||
@@ -114,7 +99,6 @@ export const getStreamUrl = async ({
|
|||||||
startTimeTicks: startTimeTicks.toString(),
|
startTimeTicks: startTimeTicks.toString(),
|
||||||
maxStreamingBitrate: maxStreamingBitrate?.toString() || "",
|
maxStreamingBitrate: maxStreamingBitrate?.toString() || "",
|
||||||
userId: userId || "",
|
userId: userId || "",
|
||||||
...downloadParams,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const directPlayUrl = `${
|
const directPlayUrl = `${
|
||||||
@@ -125,7 +109,113 @@ export const getStreamUrl = async ({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
url: directPlayUrl,
|
url: directPlayUrl,
|
||||||
sessionId: sessionId || playSessionId,
|
sessionId: sessionId || playSessionId || null,
|
||||||
|
mediaSource,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDownloadStreamUrl = async ({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
userId,
|
||||||
|
maxStreamingBitrate,
|
||||||
|
audioStreamIndex = 0,
|
||||||
|
subtitleStreamIndex = undefined,
|
||||||
|
mediaSourceId,
|
||||||
|
deviceId,
|
||||||
|
}: {
|
||||||
|
api: Api | null | undefined;
|
||||||
|
item: BaseItemDto | null | undefined;
|
||||||
|
userId: string | null | undefined;
|
||||||
|
maxStreamingBitrate?: number;
|
||||||
|
audioStreamIndex?: number;
|
||||||
|
subtitleStreamIndex?: number;
|
||||||
|
mediaSourceId?: string | null;
|
||||||
|
deviceId?: string | null;
|
||||||
|
}): Promise<{
|
||||||
|
url: string | null;
|
||||||
|
sessionId: string | null;
|
||||||
|
mediaSource: MediaSourceInfo | undefined;
|
||||||
|
} | null> => {
|
||||||
|
if (!api || !userId || !item?.Id) {
|
||||||
|
console.warn("Missing required parameters for getStreamUrl");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mediaSource: MediaSourceInfo | undefined;
|
||||||
|
let sessionId: string | null | undefined;
|
||||||
|
|
||||||
|
const res = await getMediaInfoApi(api).getPlaybackInfo(
|
||||||
|
{
|
||||||
|
itemId: item.Id!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
deviceProfile: download,
|
||||||
|
subtitleStreamIndex,
|
||||||
|
startTimeTicks: 0,
|
||||||
|
isPlayback: true,
|
||||||
|
autoOpenLiveStream: true,
|
||||||
|
maxStreamingBitrate,
|
||||||
|
audioStreamIndex,
|
||||||
|
mediaSourceId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
console.error("Error getting playback info:", res.status, res.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionId = res.data.PlaySessionId || null;
|
||||||
|
mediaSource = res.data.MediaSources?.[0];
|
||||||
|
let transcodeUrl = mediaSource?.TranscodingUrl;
|
||||||
|
|
||||||
|
if (transcodeUrl) {
|
||||||
|
transcodeUrl = transcodeUrl.replace("master.m3u8", "stream");
|
||||||
|
console.log("Video is being transcoded:", transcodeUrl);
|
||||||
|
return {
|
||||||
|
url: `${api.basePath}${transcodeUrl}`,
|
||||||
|
sessionId,
|
||||||
|
mediaSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadParams = {
|
||||||
|
// We need to disable static so we can have a remux with subtitle.
|
||||||
|
subtitleMethod: "Embed",
|
||||||
|
enableSubtitlesInManifest: true,
|
||||||
|
allowVideoStreamCopy: true,
|
||||||
|
allowAudioStreamCopy: true,
|
||||||
|
playSessionId: sessionId || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const streamParams = new URLSearchParams({
|
||||||
|
static: "false",
|
||||||
|
container: "ts",
|
||||||
|
mediaSourceId: mediaSource?.Id || "",
|
||||||
|
subtitleStreamIndex: subtitleStreamIndex?.toString() || "",
|
||||||
|
audioStreamIndex: audioStreamIndex?.toString() || "",
|
||||||
|
deviceId: deviceId || api.deviceInfo.id,
|
||||||
|
api_key: api.accessToken,
|
||||||
|
startTimeTicks: "0",
|
||||||
|
maxStreamingBitrate: maxStreamingBitrate?.toString() || "",
|
||||||
|
userId: userId || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.entries(downloadParams).forEach(([key, value]) => {
|
||||||
|
streamParams.append(key, value.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
const directPlayUrl = `${
|
||||||
|
api.basePath
|
||||||
|
}/Videos/${item.Id}/stream?${streamParams.toString()}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: directPlayUrl,
|
||||||
|
sessionId: sessionId || null,
|
||||||
mediaSource,
|
mediaSource,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type { AxiosError } from "axios";
|
|
||||||
|
|
||||||
interface MarkAsNotPlayedParams {
|
|
||||||
api: Api | null | undefined;
|
|
||||||
itemId: string | null | undefined;
|
|
||||||
userId: string | null | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks a media item as not played for a specific user.
|
|
||||||
*
|
|
||||||
* @param params - The parameters for marking an item as not played
|
|
||||||
* @returns A promise that resolves to true if the operation was successful, false otherwise
|
|
||||||
*/
|
|
||||||
export const markAsNotPlayed = async ({
|
|
||||||
api,
|
|
||||||
itemId,
|
|
||||||
userId,
|
|
||||||
}: MarkAsNotPlayedParams): Promise<void> => {
|
|
||||||
if (!api || !itemId || !userId) {
|
|
||||||
console.error("Invalid parameters for markAsNotPlayed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.axiosInstance.delete(
|
|
||||||
`${api.basePath}/UserPlayedItems/${itemId}`,
|
|
||||||
{
|
|
||||||
params: { userId },
|
|
||||||
headers: {
|
|
||||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
const axiosError = error as AxiosError;
|
|
||||||
console.error(
|
|
||||||
"Failed to mark item as not played:",
|
|
||||||
axiosError.message,
|
|
||||||
axiosError.response?.status,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
|
|
||||||
interface MarkAsPlayedParams {
|
|
||||||
api: Api | null | undefined;
|
|
||||||
item: BaseItemDto | null | undefined;
|
|
||||||
userId: string | null | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks a media item as played and updates its progress to completion.
|
|
||||||
*
|
|
||||||
* @param params - The parameters for marking an item as played∏
|
|
||||||
* @returns A promise that resolves to true if the operation was successful, false otherwise
|
|
||||||
*/
|
|
||||||
export const markAsPlayed = async ({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
userId,
|
|
||||||
}: MarkAsPlayedParams): Promise<boolean> => {
|
|
||||||
if (!api || !item?.Id || !userId || !item.RunTimeTicks) {
|
|
||||||
console.error("Invalid parameters for markAsPlayed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await getPlaystateApi(api).markPlayedItem({
|
|
||||||
itemId: item.Id,
|
|
||||||
datePlayed: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.status === 200;
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
|
||||||
import type { Settings } from "@/utils/atoms/settings";
|
|
||||||
import old from "@/utils/profiles/old";
|
|
||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import { DeviceProfile } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
|
||||||
getMediaInfoApi,
|
|
||||||
getPlaystateApi,
|
|
||||||
getSessionApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { getAuthHeaders } from "../jellyfin";
|
|
||||||
import { postCapabilities } from "../session/capabilities";
|
|
||||||
|
|
||||||
interface ReportPlaybackProgressParams {
|
|
||||||
api?: Api | null;
|
|
||||||
sessionId?: string | null;
|
|
||||||
itemId?: string | null;
|
|
||||||
positionTicks?: number | null;
|
|
||||||
IsPaused?: boolean;
|
|
||||||
deviceProfile?: Settings["deviceProfile"];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reports playback progress to the Jellyfin server.
|
|
||||||
*
|
|
||||||
* @param params - The parameters for reporting playback progress
|
|
||||||
* @throws {Error} If any required parameter is missing
|
|
||||||
*/
|
|
||||||
export const reportPlaybackProgress = async ({
|
|
||||||
api,
|
|
||||||
sessionId,
|
|
||||||
itemId,
|
|
||||||
positionTicks,
|
|
||||||
IsPaused = false,
|
|
||||||
deviceProfile,
|
|
||||||
}: ReportPlaybackProgressParams): Promise<void> => {
|
|
||||||
if (!api || !sessionId || !itemId || !positionTicks) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info("reportPlaybackProgress ~ IsPaused", IsPaused);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId,
|
|
||||||
audioStreamIndex: 0,
|
|
||||||
subtitleStreamIndex: 0,
|
|
||||||
mediaSourceId: itemId,
|
|
||||||
positionTicks: Math.round(positionTicks),
|
|
||||||
isPaused: IsPaused,
|
|
||||||
isMuted: false,
|
|
||||||
playMethod: "Transcode",
|
|
||||||
});
|
|
||||||
// await api.axiosInstance.post(
|
|
||||||
// `${api.basePath}/Sessions/Playing/Progress`,
|
|
||||||
// {
|
|
||||||
// ItemId: itemId,
|
|
||||||
// PlaySessionId: sessionId,
|
|
||||||
// IsPaused,
|
|
||||||
// PositionTicks: Math.round(positionTicks),
|
|
||||||
// CanSeek: true,
|
|
||||||
// MediaSourceId: itemId,
|
|
||||||
// EventName: "timeupdate",
|
|
||||||
// },
|
|
||||||
// { headers: getAuthHeaders(api) }
|
|
||||||
// );
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Settings } from "@/utils/atoms/settings";
|
|
||||||
import generateDeviceProfile from "@/utils/profiles/native";
|
|
||||||
import type { Api } from "@jellyfin/sdk";
|
import type { Api } from "@jellyfin/sdk";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
|
import type { Settings } from "@/utils/atoms/settings";
|
||||||
|
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||||
import { getAuthHeaders } from "../jellyfin";
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
|
||||||
interface PostCapabilitiesParams {
|
interface PostCapabilitiesParams {
|
||||||
@@ -43,14 +43,14 @@ export const postCapabilities = async ({
|
|||||||
],
|
],
|
||||||
supportsMediaControl: true,
|
supportsMediaControl: true,
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
DeviceProfile: generateDeviceProfile(),
|
DeviceProfile: await generateDeviceProfile(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: getAuthHeaders(api),
|
headers: getAuthHeaders(api),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return d;
|
return d;
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
throw new Error("Failed to mark as not played");
|
throw new Error("Failed to mark as not played");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
import { itemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { DownloadedItem } from "@/providers/DownloadProvider";
|
|
||||||
import type {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import axios from "axios";
|
|
||||||
import { MMKV } from "react-native-mmkv";
|
|
||||||
import { writeToLog } from "./log";
|
|
||||||
|
|
||||||
interface IJobInput {
|
|
||||||
deviceId?: string | null;
|
|
||||||
authHeader?: string | null;
|
|
||||||
url?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JobStatus {
|
|
||||||
id: string;
|
|
||||||
status:
|
|
||||||
| "queued"
|
|
||||||
| "optimizing"
|
|
||||||
| "completed"
|
|
||||||
| "failed"
|
|
||||||
| "cancelled"
|
|
||||||
| "downloading";
|
|
||||||
progress: number;
|
|
||||||
outputPath: string;
|
|
||||||
inputUrl: string;
|
|
||||||
deviceId: string;
|
|
||||||
itemId: string;
|
|
||||||
item: BaseItemDto;
|
|
||||||
speed?: number;
|
|
||||||
timestamp: Date;
|
|
||||||
base64Image?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches all jobs for a specific device.
|
|
||||||
*
|
|
||||||
* @param {IGetAllDeviceJobs} params - The parameters for the API request.
|
|
||||||
* @param {string} params.deviceId - The ID of the device to fetch jobs for.
|
|
||||||
* @param {string} params.authHeader - The authorization header for the API request.
|
|
||||||
* @param {string} params.url - The base URL for the API endpoint.
|
|
||||||
*
|
|
||||||
* @returns {Promise<JobStatus[]>} A promise that resolves to an array of job statuses.
|
|
||||||
*
|
|
||||||
* @throws {Error} Throws an error if the API request fails or returns a non-200 status code.
|
|
||||||
*/
|
|
||||||
export async function getAllJobsByDeviceId({
|
|
||||||
deviceId,
|
|
||||||
authHeader,
|
|
||||||
url,
|
|
||||||
}: IJobInput): Promise<JobStatus[]> {
|
|
||||||
const statusResponse = await axios.get(`${url}all-jobs`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: authHeader,
|
|
||||||
},
|
|
||||||
params: {
|
|
||||||
deviceId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (statusResponse.status !== 200) {
|
|
||||||
console.error(
|
|
||||||
statusResponse.status,
|
|
||||||
statusResponse.data,
|
|
||||||
statusResponse.statusText,
|
|
||||||
);
|
|
||||||
throw new Error("Failed to fetch job status");
|
|
||||||
}
|
|
||||||
|
|
||||||
return statusResponse.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ICancelJob {
|
|
||||||
authHeader: string;
|
|
||||||
url: string;
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelJobById({
|
|
||||||
authHeader,
|
|
||||||
url,
|
|
||||||
id,
|
|
||||||
}: ICancelJob): Promise<boolean> {
|
|
||||||
const statusResponse = await axios.delete(`${url}cancel-job/${id}`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: authHeader,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (statusResponse.status !== 200) {
|
|
||||||
throw new Error("Failed to cancel process");
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelAllJobs({ authHeader, url, deviceId }: IJobInput) {
|
|
||||||
if (!deviceId) return false;
|
|
||||||
if (!authHeader) return false;
|
|
||||||
if (!url) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await getAllJobsByDeviceId({
|
|
||||||
deviceId,
|
|
||||||
authHeader,
|
|
||||||
url,
|
|
||||||
}).then((jobs) => {
|
|
||||||
for (const job of jobs) {
|
|
||||||
cancelJobById({
|
|
||||||
authHeader,
|
|
||||||
url,
|
|
||||||
id: job.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Failed to cancel all jobs", error);
|
|
||||||
console.error(error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches statistics for a specific device.
|
|
||||||
*
|
|
||||||
* @param {IJobInput} params - The parameters for the API request.
|
|
||||||
* @param {string} params.deviceId - The ID of the device to fetch statistics for.
|
|
||||||
* @param {string} params.authHeader - The authorization header for the API request.
|
|
||||||
* @param {string} params.url - The base URL for the API endpoint.
|
|
||||||
*
|
|
||||||
* @returns {Promise<any | null>} A promise that resolves to the statistics data or null if the request fails.
|
|
||||||
*
|
|
||||||
* @throws {Error} Throws an error if any required parameter is missing.
|
|
||||||
*/
|
|
||||||
export async function getStatistics({
|
|
||||||
authHeader,
|
|
||||||
url,
|
|
||||||
deviceId,
|
|
||||||
}: IJobInput): Promise<any | null> {
|
|
||||||
if (!deviceId || !authHeader || !url) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const statusResponse = await axios.get(`${url}statistics`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: authHeader,
|
|
||||||
},
|
|
||||||
params: {
|
|
||||||
deviceId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return statusResponse.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch statistics:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves the download item info to disk - this data is used temporarily to fetch additional download information
|
|
||||||
* in combination with the optimize server. This is used to not have to send all item info to the optimize server.
|
|
||||||
*
|
|
||||||
* @param {BaseItemDto} item - The item to save.
|
|
||||||
* @param {MediaSourceInfo} mediaSource - The media source of the item.
|
|
||||||
* @param {string} url - The URL of the item.
|
|
||||||
* @return {boolean} A promise that resolves when the item info is saved.
|
|
||||||
*/
|
|
||||||
export function saveDownloadItemInfoToDiskTmp(
|
|
||||||
item: BaseItemDto,
|
|
||||||
mediaSource: MediaSourceInfo,
|
|
||||||
url: string,
|
|
||||||
): boolean {
|
|
||||||
try {
|
|
||||||
const storage = new MMKV();
|
|
||||||
|
|
||||||
const downloadInfo = JSON.stringify({
|
|
||||||
item,
|
|
||||||
mediaSource,
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
|
|
||||||
storage.set(`tmp_download_info_${item.Id}`, downloadInfo);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save download item info to disk:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the download item info from disk.
|
|
||||||
*
|
|
||||||
* @param {string} itemId - The ID of the item to retrieve.
|
|
||||||
* @return {{
|
|
||||||
* item: BaseItemDto;
|
|
||||||
* mediaSource: MediaSourceInfo;
|
|
||||||
* url: string;
|
|
||||||
* } | null} The retrieved download item info or null if not found.
|
|
||||||
*/
|
|
||||||
export function getDownloadItemInfoFromDiskTmp(itemId: string): {
|
|
||||||
item: BaseItemDto;
|
|
||||||
mediaSource: MediaSourceInfo;
|
|
||||||
url: string;
|
|
||||||
} | null {
|
|
||||||
try {
|
|
||||||
const storage = new MMKV();
|
|
||||||
const rawInfo = storage.getString(`tmp_download_info_${itemId}`);
|
|
||||||
|
|
||||||
if (rawInfo) {
|
|
||||||
return JSON.parse(rawInfo);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to retrieve download item info from disk:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes the download item info from disk.
|
|
||||||
*
|
|
||||||
* @param {string} itemId - The ID of the item to delete.
|
|
||||||
* @return {boolean} True if the item info was successfully deleted, false otherwise.
|
|
||||||
*/
|
|
||||||
export function deleteDownloadItemInfoFromDiskTmp(itemId: string): boolean {
|
|
||||||
try {
|
|
||||||
const storage = new MMKV();
|
|
||||||
storage.delete(`tmp_download_info_${itemId}`);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete download item info from disk:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -59,80 +59,55 @@ export default {
|
|||||||
],
|
],
|
||||||
SubtitleProfiles: [
|
SubtitleProfiles: [
|
||||||
// Official foramts
|
// Official foramts
|
||||||
{ Format: "vtt", Method: "Embed" },
|
|
||||||
{ Format: "vtt", Method: "Encode" },
|
{ Format: "vtt", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "webvtt", Method: "Embed" },
|
|
||||||
{ Format: "webvtt", Method: "Encode" },
|
{ Format: "webvtt", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "srt", Method: "Embed" },
|
|
||||||
{ Format: "srt", Method: "Encode" },
|
{ Format: "srt", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "subrip", Method: "Embed" },
|
|
||||||
{ Format: "subrip", Method: "Encode" },
|
{ Format: "subrip", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "ttml", Method: "Embed" },
|
|
||||||
{ Format: "ttml", Method: "Encode" },
|
{ Format: "ttml", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "dvbsub", Method: "Embed" },
|
|
||||||
{ Format: "dvdsub", Method: "Encode" },
|
{ Format: "dvdsub", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "ass", Method: "Embed" },
|
|
||||||
{ Format: "ass", Method: "Encode" },
|
{ Format: "ass", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "idx", Method: "Embed" },
|
|
||||||
{ Format: "idx", Method: "Encode" },
|
{ Format: "idx", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "pgs", Method: "Embed" },
|
|
||||||
{ Format: "pgs", Method: "Encode" },
|
{ Format: "pgs", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "pgssub", Method: "Embed" },
|
|
||||||
{ Format: "pgssub", Method: "Encode" },
|
{ Format: "pgssub", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "ssa", Method: "Embed" },
|
|
||||||
{ Format: "ssa", Method: "Encode" },
|
{ Format: "ssa", Method: "Encode" },
|
||||||
|
|
||||||
// Other formats
|
// Other formats
|
||||||
{ Format: "microdvd", Method: "Embed" },
|
|
||||||
{ Format: "microdvd", Method: "Encode" },
|
{ Format: "microdvd", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "mov_text", Method: "Embed" },
|
|
||||||
{ Format: "mov_text", Method: "Encode" },
|
{ Format: "mov_text", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "mpl2", Method: "Embed" },
|
|
||||||
{ Format: "mpl2", Method: "Encode" },
|
{ Format: "mpl2", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "pjs", Method: "Embed" },
|
|
||||||
{ Format: "pjs", Method: "Encode" },
|
{ Format: "pjs", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "realtext", Method: "Embed" },
|
|
||||||
{ Format: "realtext", Method: "Encode" },
|
{ Format: "realtext", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "scc", Method: "Embed" },
|
|
||||||
{ Format: "scc", Method: "Encode" },
|
{ Format: "scc", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "smi", Method: "Embed" },
|
|
||||||
{ Format: "smi", Method: "Encode" },
|
{ Format: "smi", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "stl", Method: "Embed" },
|
|
||||||
{ Format: "stl", Method: "Encode" },
|
{ Format: "stl", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "sub", Method: "Embed" },
|
|
||||||
{ Format: "sub", Method: "Encode" },
|
{ Format: "sub", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "subviewer", Method: "Embed" },
|
|
||||||
{ Format: "subviewer", Method: "Encode" },
|
{ Format: "subviewer", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "teletext", Method: "Embed" },
|
|
||||||
{ Format: "teletext", Method: "Encode" },
|
{ Format: "teletext", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "text", Method: "Embed" },
|
|
||||||
{ Format: "text", Method: "Encode" },
|
{ Format: "text", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "vplayer", Method: "Embed" },
|
|
||||||
{ Format: "vplayer", Method: "Encode" },
|
{ Format: "vplayer", Method: "Encode" },
|
||||||
|
|
||||||
{ Format: "xsub", Method: "Embed" },
|
|
||||||
{ Format: "xsub", Method: "Encode" },
|
{ Format: "xsub", Method: "Encode" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import DeviceInfo from "react-native-device-info";
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
import MediaTypes from "../../constants/MediaTypes";
|
import MediaTypes from "../../constants/MediaTypes";
|
||||||
|
import { getSubtitleProfiles } from "./subtitles";
|
||||||
|
|
||||||
// Helper function to detect Dolby Vision support
|
// Helper function to detect Dolby Vision support
|
||||||
const supportsDolbyVision = async () => {
|
const supportsDolbyVision = async () => {
|
||||||
@@ -27,13 +28,14 @@ const supportsDolbyVision = async () => {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateDeviceProfile = async () => {
|
export const generateDeviceProfile = async ({ transcode = false } = {}) => {
|
||||||
|
console.log("generating device profile", { transcode });
|
||||||
const dolbyVisionSupported = await supportsDolbyVision();
|
const dolbyVisionSupported = await supportsDolbyVision();
|
||||||
/**
|
/**
|
||||||
* Device profile for Native video player
|
* Device profile for Native video player
|
||||||
*/
|
*/
|
||||||
const profile = {
|
const profile = {
|
||||||
Name: "1. Vlc Player",
|
Name: `1. Vlc Player${transcode ? " (Transcoding)" : ""}`,
|
||||||
MaxStaticBitrate: 999_999_999,
|
MaxStaticBitrate: 999_999_999,
|
||||||
MaxStreamingBitrate: 999_999_999,
|
MaxStreamingBitrate: 999_999_999,
|
||||||
CodecProfiles: [
|
CodecProfiles: [
|
||||||
@@ -62,7 +64,7 @@ export const generateDeviceProfile = async () => {
|
|||||||
DirectPlayProfiles: [
|
DirectPlayProfiles: [
|
||||||
{
|
{
|
||||||
Type: MediaTypes.Video,
|
Type: MediaTypes.Video,
|
||||||
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
|
Container: "mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
|
||||||
VideoCodec:
|
VideoCodec:
|
||||||
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
||||||
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma,dts",
|
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma,dts",
|
||||||
@@ -79,7 +81,7 @@ export const generateDeviceProfile = async () => {
|
|||||||
Type: MediaTypes.Video,
|
Type: MediaTypes.Video,
|
||||||
Context: "Streaming",
|
Context: "Streaming",
|
||||||
Protocol: "hls",
|
Protocol: "hls",
|
||||||
Container: "mp4",
|
Container: transcode ? "fmp4" : "ts",
|
||||||
VideoCodec: "h264, hevc",
|
VideoCodec: "h264, hevc",
|
||||||
AudioCodec: "aac,mp3,ac3,dts",
|
AudioCodec: "aac,mp3,ac3,dts",
|
||||||
},
|
},
|
||||||
@@ -92,84 +94,7 @@ export const generateDeviceProfile = async () => {
|
|||||||
MaxAudioChannels: "2",
|
MaxAudioChannels: "2",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
SubtitleProfiles: [
|
SubtitleProfiles: getSubtitleProfiles(transcode ? "hls" : "External"),
|
||||||
// Official formats
|
|
||||||
{ Format: "vtt", Method: "Embed" },
|
|
||||||
{ Format: "vtt", Method: "External" },
|
|
||||||
|
|
||||||
{ Format: "webvtt", Method: "Embed" },
|
|
||||||
{ Format: "webvtt", Method: "External" },
|
|
||||||
|
|
||||||
{ Format: "srt", Method: "Embed" },
|
|
||||||
{ Format: "srt", Method: "External" },
|
|
||||||
|
|
||||||
{ Format: "subrip", Method: "Embed" },
|
|
||||||
{ Format: "subrip", Method: "External" },
|
|
||||||
|
|
||||||
{ Format: "ttml", Method: "Embed" },
|
|
||||||
{ Format: "ttml", Method: "External" },
|
|
||||||
|
|
||||||
{ Format: "dvbsub", Method: "Embed" },
|
|
||||||
{ Format: "dvdsub", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "ass", Method: "Embed" },
|
|
||||||
{ Format: "ass", Method: "External" },
|
|
||||||
|
|
||||||
{ Format: "idx", Method: "Embed" },
|
|
||||||
{ Format: "idx", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "pgs", Method: "Embed" },
|
|
||||||
{ Format: "pgs", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "pgssub", Method: "Embed" },
|
|
||||||
{ Format: "pgssub", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "ssa", Method: "Embed" },
|
|
||||||
{ Format: "ssa", Method: "External" },
|
|
||||||
|
|
||||||
// Other formats
|
|
||||||
{ Format: "microdvd", Method: "Embed" },
|
|
||||||
{ Format: "microdvd", Method: "External" },
|
|
||||||
|
|
||||||
{ Format: "mov_text", Method: "Embed" },
|
|
||||||
{ Format: "mov_text", Method: "External" },
|
|
||||||
|
|
||||||
{ Format: "mpl2", Method: "Embed" },
|
|
||||||
{ Format: "mpl2", Method: "External" },
|
|
||||||
|
|
||||||
{ Format: "pjs", Method: "Embed" },
|
|
||||||
{ Format: "pjs", Method: "External" },
|
|
||||||
|
|
||||||
{ Format: "realtext", Method: "Embed" },
|
|
||||||
{ Format: "realtext", Method: "External" },
|
|
||||||
|
|
||||||
{ Format: "scc", Method: "Embed" },
|
|
||||||
{ Format: "scc", Method: "External" },
|
|
||||||
|
|
||||||
{ Format: "smi", Method: "Embed" },
|
|
||||||
{ Format: "smi", Method: "External" },
|
|
||||||
|
|
||||||
{ Format: "stl", Method: "Embed" },
|
|
||||||
{ Format: "stl", Method: "External" },
|
|
||||||
|
|
||||||
{ Format: "sub", Method: "Embed" },
|
|
||||||
{ Format: "sub", Method: "External" },
|
|
||||||
|
|
||||||
{ Format: "subviewer", Method: "Embed" },
|
|
||||||
{ Format: "subviewer", Method: "External" },
|
|
||||||
|
|
||||||
{ Format: "teletext", Method: "Embed" },
|
|
||||||
{ Format: "teletext", Method: "Encode" },
|
|
||||||
|
|
||||||
{ Format: "text", Method: "Embed" },
|
|
||||||
{ Format: "text", Method: "External" },
|
|
||||||
|
|
||||||
{ Format: "vplayer", Method: "Embed" },
|
|
||||||
{ Format: "vplayer", Method: "External" },
|
|
||||||
|
|
||||||
{ Format: "xsub", Method: "Embed" },
|
|
||||||
{ Format: "xsub", Method: "External" },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add Dolby Vision restriction if not supported
|
// Add Dolby Vision restriction if not supported
|
||||||
@@ -192,5 +117,5 @@ export const generateDeviceProfile = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async () => {
|
export default async () => {
|
||||||
return await generateDeviceProfile();
|
return await generateDeviceProfile({ transcode: false });
|
||||||
};
|
};
|
||||||
|
|||||||
56
utils/profiles/subtitles.js
Normal file
56
utils/profiles/subtitles.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const COMMON_SUBTITLE_PROFILES = [
|
||||||
|
// Official formats
|
||||||
|
|
||||||
|
{ Format: "dvdsub", Method: "Embed" },
|
||||||
|
{ Format: "dvdsub", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "idx", Method: "Embed" },
|
||||||
|
{ Format: "idx", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "pgs", Method: "Embed" },
|
||||||
|
{ Format: "pgs", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "pgssub", Method: "Embed" },
|
||||||
|
{ Format: "pgssub", Method: "Encode" },
|
||||||
|
|
||||||
|
{ Format: "teletext", Method: "Embed" },
|
||||||
|
{ Format: "teletext", Method: "Encode" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const VARYING_SUBTITLE_FORMATS = [
|
||||||
|
"webvtt",
|
||||||
|
"vtt",
|
||||||
|
"srt",
|
||||||
|
"subrip",
|
||||||
|
"ttml",
|
||||||
|
"ass",
|
||||||
|
"ssa",
|
||||||
|
"microdvd",
|
||||||
|
"mov_text",
|
||||||
|
"mpl2",
|
||||||
|
"pjs",
|
||||||
|
"realtext",
|
||||||
|
"scc",
|
||||||
|
"smi",
|
||||||
|
"stl",
|
||||||
|
"sub",
|
||||||
|
"subviewer",
|
||||||
|
"text",
|
||||||
|
"vplayer",
|
||||||
|
"xsub",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getSubtitleProfiles = (secondaryMethod) => {
|
||||||
|
const profiles = [...COMMON_SUBTITLE_PROFILES];
|
||||||
|
for (const format of VARYING_SUBTITLE_FORMATS) {
|
||||||
|
profiles.push({ Format: format, Method: "Embed" });
|
||||||
|
profiles.push({ Format: format, Method: secondaryMethod });
|
||||||
|
}
|
||||||
|
return profiles;
|
||||||
|
};
|
||||||
114
utils/segments.ts
Normal file
114
utils/segments.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { DownloadedItem, MediaTimeSegment } from "@/providers/Downloads/types";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getAuthHeaders } from "./jellyfin/jellyfin";
|
||||||
|
|
||||||
|
interface IntroTimestamps {
|
||||||
|
EpisodeId: string;
|
||||||
|
HideSkipPromptAt: number;
|
||||||
|
IntroEnd: number;
|
||||||
|
IntroStart: number;
|
||||||
|
ShowSkipPromptAt: number;
|
||||||
|
Valid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreditTimestamps {
|
||||||
|
Introduction: {
|
||||||
|
Start: number;
|
||||||
|
End: number;
|
||||||
|
Valid: boolean;
|
||||||
|
};
|
||||||
|
Credits: {
|
||||||
|
Start: number;
|
||||||
|
End: number;
|
||||||
|
Valid: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSegments = (itemId: string, isOffline: boolean) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const { downloadedFiles } = useDownload();
|
||||||
|
const downloadedItem = downloadedFiles?.find(
|
||||||
|
(d: DownloadedItem) => d.item.Id === itemId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["segments", itemId, isOffline],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (isOffline && downloadedItem) {
|
||||||
|
return getSegmentsForItem(downloadedItem);
|
||||||
|
}
|
||||||
|
if (!api) {
|
||||||
|
throw new Error("API client is not available");
|
||||||
|
}
|
||||||
|
return fetchAndParseSegments(itemId, api);
|
||||||
|
},
|
||||||
|
enabled: !!api,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSegmentsForItem = (
|
||||||
|
item: DownloadedItem,
|
||||||
|
): {
|
||||||
|
introSegments: MediaTimeSegment[];
|
||||||
|
creditSegments: MediaTimeSegment[];
|
||||||
|
} => {
|
||||||
|
return {
|
||||||
|
introSegments: item.introSegments || [],
|
||||||
|
creditSegments: item.creditSegments || [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchAndParseSegments = async (
|
||||||
|
itemId: string,
|
||||||
|
api: Api,
|
||||||
|
): Promise<{
|
||||||
|
introSegments: MediaTimeSegment[];
|
||||||
|
creditSegments: MediaTimeSegment[];
|
||||||
|
}> => {
|
||||||
|
const introSegments: MediaTimeSegment[] = [];
|
||||||
|
const creditSegments: MediaTimeSegment[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [introRes, creditRes] = await Promise.allSettled([
|
||||||
|
api.axiosInstance.get<IntroTimestamps>(
|
||||||
|
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
|
||||||
|
{
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
api.axiosInstance.get<CreditTimestamps>(
|
||||||
|
`${api.basePath}/Episode/${itemId}/Timestamps`,
|
||||||
|
{
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
|
||||||
|
introSegments.push({
|
||||||
|
startTime: introRes.value.data.IntroStart,
|
||||||
|
endTime: introRes.value.data.IntroEnd,
|
||||||
|
text: "Intro",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
creditRes.status === "fulfilled" &&
|
||||||
|
creditRes.value.data.Credits.Valid
|
||||||
|
) {
|
||||||
|
creditSegments.push({
|
||||||
|
startTime: creditRes.value.data.Credits.Start,
|
||||||
|
endTime: creditRes.value.data.Credits.End,
|
||||||
|
text: "Credits",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch segments", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { introSegments, creditSegments };
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user