Compare commits

...

134 Commits

Author SHA1 Message Date
Alex Kim
187f504d86 fix: Playback reporting 2025-02-23 09:39:16 -05:00
Fredrik Burmester
e651b975b7 fix: ts error 2025-02-23 15:08:14 +01:00
lostb1t
1c550b1b77 feat: Mark/unmark favorite quick action (#561) 2025-02-23 15:03:52 +01:00
Ahmed Sbai
5bcae81538 fix(511): fixed long named translations for the subtitle can break UI (#564) 2025-02-23 15:03:22 +01:00
Fredrik Burmester
c951725222 chore 2025-02-23 15:02:50 +01:00
Fredrik Burmester
0b966d7c04 fix: add hevc to chromecast h265 profile 2025-02-23 14:50:14 +01:00
Fredrik Burmester
8e0e35afe3 fix: chromecast 2025-02-23 14:35:00 +01:00
Fredrik Burmester
daf7f35196 feat: scroll to top on tab home press 2025-02-22 13:23:33 +01:00
Fredrik Burmester
d5ac30b6d8 feat: scroll to top on tab home press 2025-02-22 13:20:52 +01:00
Fredrik Burmester
81b91bbb97 chore 2025-02-22 13:11:37 +01:00
Fredrik Burmester
af2bd030e9 feat: focus search bar on second tab press (#558) 2025-02-22 13:09:17 +01:00
Fredrik Burmester
5590c2f784 fix: added season and episode + updated icon 2025-02-22 12:08:58 +01:00
Fredrik Burmester
6cc70dd123 fix: type issues 2025-02-22 12:02:33 +01:00
Edmond
fae588b0f0 fix: Improve Chinese (Traditional) Translation (#557) 2025-02-22 11:06:43 +01:00
vuhe
bd2aeb2234 feat: Add Chinese (Simplified) Translation (#556) 2025-02-22 11:06:10 +01:00
sarendsen
cca0bbf42c bigger play button 2025-02-22 10:58:28 +01:00
Fredrik Burmester
06e0eb5c4e Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-02-21 20:38:45 +01:00
Fredrik Burmester
b478fbb6bf fix: tvos fixes 2025-02-21 20:38:31 +01:00
lostb1t
b98a7b0634 Update _layout.tsx 2025-02-21 18:22:05 +01:00
lostb1t
ce38024a3f Update settings.tsx 2025-02-21 14:57:53 +01:00
lostb1t
04dce9265b Update _layout.tsx 2025-02-21 14:56:59 +01:00
lostb1t
5b8418cd82 feat: Sessions view (#537) 2025-02-21 13:14:57 +01:00
tkymmm
b0c5255bd7 feat: add japanese translations (#552) 2025-02-21 11:09:36 +01:00
Fredrik Burmester
73dd171987 chore: version bump 2025-02-20 16:30:36 +01:00
Ahmed Sbai
ff35559687 fix: remove unused imports and optimize keepAwake usage in the player (#548) 2025-02-20 11:18:37 +01:00
Fredrik Burmester
5aadd50946 chore 2025-02-20 11:16:10 +01:00
herrrta
63b5ba2112 feat: add upcoming air dates for episodes 2025-02-19 21:49:28 -05:00
herrrta
8b955578a2 fix: Jellyseerr url input 2025-02-19 21:07:50 -05:00
herrrta
1e5c021c93 fix: Reset ios vout when media is not playing 2025-02-19 20:58:39 -05:00
Fredrik Burmester
0b86f56486 fix: spelling 2025-02-19 20:36:51 +01:00
Fredrik Burmester
728b93f4e5 fix: up stale issue time to 90 days, ignore feature requests 2025-02-19 20:36:31 +01:00
Fredrik Burmester
2fc483b24e Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-02-19 20:19:39 +01:00
Fredrik Burmester
fc901bc01e fix: jellyseerr login without password 2025-02-19 20:19:34 +01:00
lostb1t
2b0884b154 Update README.md 2025-02-19 17:14:01 +01:00
lostb1t
307d20e538 Update README.md 2025-02-19 17:13:12 +01:00
Fredrik Burmester
a2f03908f6 fix: remove splashscreen provider and handle loading in jellyfinprovider 2025-02-19 14:44:39 +01:00
Fredrik Burmester
77aef8877e fix: jellyseerr better login 2025-02-19 11:36:47 +01:00
Fredrik Burmester
0cf930d6e1 test: fix controls loading android? 2025-02-19 11:06:57 +01:00
Fredrik Burmester
4b0b949541 fix: button alignment pip 2025-02-19 10:55:29 +01:00
Fredrik Burmester
14b717f985 fix: half screen black on login 2025-02-19 10:54:05 +01:00
Fredrik Burmester
cfbac538f8 chore: refactor for tv stuff 2025-02-19 10:49:18 +01:00
Fredrik Burmester
1ac6b7e3df fix: chromecast not working 2025-02-18 17:56:10 +01:00
Davide Sirico
c9f6e8676b feat: add italian translations (#545) 2025-02-18 16:10:15 +01:00
Fredrik Burmester
5aab1450cd chore 2025-02-18 16:06:35 +01:00
Fredrik Burmester
1e7080a136 chore 2025-02-18 16:06:32 +01:00
Théo FORTIN
993cec4138 feat: remove stale issues (#515) 2025-02-17 16:54:25 +01:00
Ahmed Sbai
6c524499f9 chore: remove async-storage && moved @types/xxx dependencies to dev-deps (#538) 2025-02-17 16:54:12 +01:00
Maarten
b3463ffdfc feat: add dutch translations (#539)
Co-authored-by: Maarten Schroeven <maarten.schroeven@ae.be>
2025-02-17 16:53:10 +01:00
Edmond
50942b44f1 feat: Add Chinese (Traditional) Translation (#522) 2025-02-17 16:52:58 +01:00
Mustafa
f602f8919f fix: translation de.json grammar (#516) 2025-02-17 16:52:44 +01:00
herrrta
0e86d8a00f fix: IOS video player black screens pt2
- Looks like re-adding subview was not enough. We have to toggle the video tracks selection and play the media to trigger the re-render
2025-02-16 15:05:32 -05:00
Adrián
56b1e1977c fix: Change too long texts in the Spanish translation (#535) 2025-02-16 13:22:13 +01:00
herrrta
30e23b9079 fix: IOS video player black screens
- restores player view when re-entering apps foreground
- added logger
2025-02-15 22:18:16 -05:00
herrrta
d83ecb881b fix: Android PiP support fully working
- fixed black screen on re-entering
- ensured screen stays alive when video is playing
- PiP button states now reflect media status
2025-02-15 15:11:57 -05:00
Fredrik Burmester
4c14c08b35 fix: move from react-native-video -> VLC for transcoded streams (#529)
Co-authored-by: Alex Kim <alexkim5682@gmail.com>
2025-02-16 07:10:36 +11:00
herrrta
ecb9b90163 fix: Stop playback when gesture navigating back 2025-02-15 12:52:09 -05:00
Fredrik Burmester
33a2be24f4 fix: hidden be default ios 2025-02-15 12:00:58 +01:00
Fredrik Burmester
e8b0d52515 feat: change to native searchbar on android 2025-02-15 11:59:17 +01:00
Fredrik Burmester
9faa0de2d6 chore: bump version 2025-02-15 11:48:54 +01:00
Fredrik Burmester
221155d002 fix: deps 2025-02-15 11:34:50 +01:00
Fredrik Burmester
4a37e17324 chore 2025-02-14 13:28:06 +01:00
Fredrik Burmester
52b2a3418e fix: wrong deps 2025-02-13 10:35:58 +01:00
Fredrik Burmester
2753b243e5 chore: remove yarn lock file 2025-02-13 10:33:56 +01:00
Fredrik Burmester
f22b356b7c feat: turkish translations 2025-02-13 10:33:41 +01:00
Fredrik Burmester
d8ba5af8d9 chore: remove old patch 2025-02-13 10:33:34 +01:00
herrrta
505ef39ee7 ios VLCKit 4.0 & All platform PiP support 2025-02-12 23:21:24 -05:00
Théo FORTIN
e71d5cc176 feat: Add default quality setting (#509) 2025-02-12 08:32:26 +01:00
Fredrik Burmester
74e57bbd88 fix: add contributor avatars to readme (#512) 2025-02-12 08:31:55 +01:00
Fredrik Burmester
76eaeb9820 chore 2025-02-12 08:31:38 +01:00
Fredrik Burmester
9a70f98dd5 chore 2025-02-12 08:25:36 +01:00
herrrta
f28f1d8736 Fix android discover page crash 2025-02-11 10:16:36 -05:00
lostb1t
e0f03ccb93 feat: Allow plugin override defaults (#508) 2025-02-10 17:38:01 +01:00
lostb1t
34d1dbb20e Update README.md 2025-02-10 15:39:14 +01:00
Simon Eklundh
e3e2db659d fix: download player (#506) 2025-02-09 13:40:45 +01:00
Fredrik Burmester
528b4ad7ac fix: orientation in video player and app i general 2025-02-09 11:45:32 +01:00
lostb1t
d29501386b chore: expo 52 (#502)
Co-authored-by: herrrta <73949927+herrrta@users.noreply.github.com>
2025-02-09 10:46:05 +01:00
Simon Eklundh
6688469b6c fix: fixes non-optimized downloads (#500) 2025-02-09 10:43:42 +01:00
lostb1t
ae9c30aa6d fix: fix home and header nav not showing (#499) 2025-02-08 17:48:05 +01:00
Fredrik Burmester
364d2e8a51 fix: typescript errors 2025-02-08 10:51:52 +01:00
herrrta
6cc90b46b3 TV: fix navigation on login (#494) 2025-02-07 21:57:13 -05:00
sarendsen
33adea2819 fix more import for tv 2025-02-07 14:22:54 +01:00
Simon Eklundh
9f41861dcf fix: download provider import usage so we can play again (#491) 2025-02-06 23:12:44 +01:00
lostb1t
2b2d23e574 Update README.md 2025-02-06 17:53:01 +01:00
lostb1t
f6e2bcb120 Update README.md 2025-02-06 17:52:02 +01:00
lostb1t
314cd62bee Update README.md 2025-02-06 17:48:25 +01:00
lostb1t
41e7123d1c Update README.md 2025-02-06 17:48:03 +01:00
lostb1t
2af42b39f5 Update README.md 2025-02-06 17:44:31 +01:00
lostb1t
0a06b336c8 Update network_security_config.xml 2025-02-06 12:37:17 +01:00
lostb1t
028c9159f3 Update eas.json 2025-02-06 09:38:38 +01:00
sarendsen
dee4fa07e3 refactor: playbutton for tv 2025-02-05 15:07:11 +01:00
lostb1t
2764f1736a Update eas.json 2025-02-05 13:58:30 +01:00
Fredrik Burmester
d3d1a7bcde Merge pull request #374 from streamyfin/feature/bigscreen
feat: Initial support for tvOs/AndroidTV
2025-02-05 13:41:19 +01:00
sarendsen
7fcd598fa1 wip 2025-02-05 10:04:50 +01:00
sarendsen
0fc1506b11 merge develop 2025-02-05 09:44:03 +01:00
Adrián
e0aa7ea0df fix: Change phone_usage key to device_usage in Spanish translations (#479) 2025-02-04 15:23:41 +01:00
Mustafa
25f77645f8 fix: phone_usage to device_usage due PR #456 for DE language (#478) 2025-02-02 13:05:58 +01:00
Gauvain
1c81091e8b fix(i18n): fix french translation and wrong keys (#456) 2025-02-02 09:20:08 +01:00
Mustafa
94502b558d feat: Add German Translation DE (#477) 2025-02-02 09:18:15 +01:00
Adrián
a7d7d00eb3 feat: Translate app to Spanish (#457) 2025-02-02 09:17:54 +01:00
Fredrik Burmester
3b5e07c1d2 chore 2025-02-01 10:14:09 +01:00
Fredrik Burmester
db10369fb5 chore 2025-02-01 09:29:05 +01:00
Fredrik Burmester
32da5918c7 chore 2025-01-31 15:57:03 +01:00
Fredrik Burmester
dc542021b5 chore 2025-01-31 15:47:03 +01:00
Fredrik Burmester
bfad157a28 Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-01-31 15:36:52 +01:00
Fredrik Burmester
a71a646743 chore 2025-01-31 15:36:49 +01:00
sarendsen
366bc0137e WIP 2025-01-31 13:22:51 +01:00
Tom Heidenreich
3eb60840e6 fix: Rendered more hooks than during the previous render in NextEpisodeCountDownButton (#475) 2025-01-31 10:14:59 +01:00
sarendsen
65c4a1340d WIP 2025-01-30 11:19:36 +01:00
sarendsen
3e90447dd4 WIP 2025-01-30 10:18:07 +01:00
sarendsen
bd0768797e WIP 2025-01-30 09:20:31 +01:00
Max Ward
730ef4616f feat: Mark entire seasons of a show as played (#445) 2025-01-29 10:54:00 +01:00
lostb1t
c4d4475aa9 Create lint-pr.yaml 2025-01-27 14:04:22 +01:00
Fredrik Burmester
d1eb40f2a9 chore 2025-01-27 13:26:23 +01:00
Fredrik Burmester
77518d774e chore 2025-01-27 13:00:40 +01:00
Fredrik Burmester
a6fb7b956d chore 2025-01-27 13:00:16 +01:00
Tom Heidenreich
034ff3f478 Feat/Show Splashcreen until UI loaded (#437) 2025-01-27 10:28:53 +01:00
Max Ward
98ca4e7a6d Fix mark as played sheet logic being reversed (#443) 2025-01-27 08:27:28 +01:00
herrrta
461a276a20 Merge pull request #461 from streamyfin/fix/460
fix: Requesting some seasons not working [Jellyseerr]
2025-01-25 15:06:08 -05:00
sarendsen
3cd8e41000 wip 2025-01-08 15:25:06 +01:00
sarendsen
dd08826931 wip 2025-01-07 12:03:35 +01:00
sarendsen
b681025389 wip 2025-01-07 12:01:55 +01:00
sarendsen
65549428bf wip 2025-01-07 10:45:25 +01:00
sarendsen
cda3b64a2b wip 2025-01-07 10:08:07 +01:00
sarendsen
373d4ca3b1 wip 2025-01-06 15:10:59 +01:00
sarendsen
8bc360d554 wip 2025-01-06 15:04:07 +01:00
sarendsen
3fae21d559 wip 2025-01-06 14:45:42 +01:00
sarendsen
74ce9d7eea wip 2025-01-06 14:28:24 +01:00
sarendsen
5055a700c9 wip 2025-01-06 13:59:56 +01:00
sarendsen
ab33693dd9 wip 2025-01-06 13:25:49 +01:00
Fredrik Burmester
6a4621c377 Merge branch 'develop' into feature/bigscreen 2025-01-05 13:43:23 +01:00
sarendsen
2fb19f601b remove reload 2025-01-05 10:54:14 +01:00
sarendsen
a602c35a8f refactor: Add support for tvos 2025-01-05 10:43:10 +01:00
retardgerman
46ac4a2cc7 fix: auto add feature requests to roadmap 2025-01-05 10:42:08 +01:00
retardgerman
962f65874e fix: removed assignees and modified link to roadmap 2025-01-05 10:42:08 +01:00
146 changed files with 12320 additions and 4256 deletions

View File

@@ -43,6 +43,9 @@ body:
label: Version label: Version
description: What version of Streamyfin are you running? description: What version of Streamyfin are you running?
options: options:
- 0.27.0
- 0.26.1
- 0.26.0
- 0.25.0 - 0.25.0
- 0.24.0 - 0.24.0
- 0.23.0 - 0.23.0

41
.github/workflows/lint-pr.yaml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: "Lint PR"
on:
pull_request_target:
types:
- opened
- edited
- synchronize
- reopened
permissions:
pull-requests: write
jobs:
main:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@v2
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
message: |
Hey there and thank you for opening this pull request! 👋🏼
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
Details:
```
${{ steps.lint_pr_title.outputs.error_message }}
```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pr-title-lint-error
delete: true

39
.github/workflows/main.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Handle Stale Issues
on:
schedule:
- cron: "30 1 * * *" # Runs at 1:30 UTC every day
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
# Issue specific settings
days-before-issue-stale: 90
days-before-issue-close: 7
stale-issue-label: "stale"
stale-issue-message: |
This issue has been automatically marked as stale because it has had no activity in the last 30 days.
If this issue is still relevant, please leave a comment to keep it open.
Otherwise, it will be closed in 7 days if no further activity occurs.
Thank you for your contributions!
close-issue-message: |
This issue has been automatically closed because it has been inactive for 7 days since being marked as stale.
If you believe this issue is still relevant, please feel free to reopen it and add a comment explaining the current status.
# Pull request settings (disabled)
days-before-pr-stale: -1
days-before-pr-close: -1
# Other settings
repo-token: ${{ secrets.GITHUB_TOKEN }}
operations-per-run: 100
exempt-issue-labels: "Roadmap v1,help needed,enhancement"

8
.gitignore vendored
View File

@@ -10,6 +10,7 @@ npm-debug.*
*.orig.* *.orig.*
web-build/ web-build/
modules/vlc-player/android/build modules/vlc-player/android/build
bun.lockb
# macOS # macOS
.DS_Store .DS_Store
@@ -26,6 +27,10 @@ package-lock.json
/ios /ios
/android /android
/iostv
/iosmobile
/androidmobile
/androidtv
modules/player/android modules/player/android
@@ -37,4 +42,5 @@ credentials.json
.vscode/ .vscode/
.idea/ .idea/
.ruby-lsp .ruby-lsp
modules/hls-downloader/android/build

View File

@@ -9,6 +9,7 @@
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"prettier.printWidth": 120,
"[swift]": { "[swift]": {
"editor.defaultFormatter": "sswg.swift-lang" "editor.defaultFormatter": "sswg.swift-lang"
} }

6
Makefile Normal file
View File

@@ -0,0 +1,6 @@
e2e:
maestro start-device --platform android
maestro test login.yaml
e2e-setup:
curl -fsSL "https://get.maestro.mobile.dev" | bash

View File

@@ -18,6 +18,7 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
- 🔊 **Background audio**: Stream music in the background, even when locking the phone. - 🔊 **Background audio**: Stream music in the background, even when locking the phone.
- 📥 **Download media** (Experimental): Save your media locally and watch it offline. - 📥 **Download media** (Experimental): Save your media locally and watch it offline.
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device. - 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
- 🤖 **Jellyseerr integration**: Request media directly in the app. - 🤖 **Jellyseerr integration**: Request media directly in the app.
## 🧪 Experimental Features ## 🧪 Experimental Features
@@ -37,7 +38,7 @@ Chromecast support is still in development, and we're working on improving it. C
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that hold all settings for the client Streamyfin. This allows you to syncronize settings accross all your users, like: The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that hold all settings for the client Streamyfin. This allows you to syncronize settings accross all your users, like:
- Auto log in to Jellyseerr without the user having to do anythin - Auto log in to Jellyseerr without the user having to do anythin
- Choose the default languages - Choose the default languages
- Set download method and search provider - Set download method and search provider
- Customize homescreen - Customize homescreen
- And more... - And more...
@@ -67,7 +68,7 @@ Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/r
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the ⁠🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you. To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the ⁠🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas. **Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
## 🚀 Getting Started ## 🚀 Getting Started
@@ -84,8 +85,14 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
1. Use node `>20` 1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload` 2. Install dependencies `bun i && bun run submodule-reload`
3. Make sure you have xcode and/or android studio installed. 3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
4. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on your computer and run the app. 4. run `npm run prebuild`
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app.
For the TV version suffix the npm commands with `:tv`.
`npm run prebuild:tv`
`npm run ios:tv or npm run android:tv`
## 📄 License ## 📄 License
@@ -116,7 +123,85 @@ Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmest
## ✨ Acknowledgements ## ✨ Acknowledgements
I'd like to thank the following people and projects for their contributions to Streamyfin: ### Core Developers
Thanks to the following contributors for their significant contributions:
<table>
<tr
style="
display: flex;
justify-content: space-around;
align-items: center;
flex-wrap: wrap;
"
>
<td align="center">
<a href="https://github.com/Alexk2309">
<img src="https://github.com/Alexk2309.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Alexk2309</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/herrrta">
<img src="https://github.com/herrrta.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@herrrta</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/lostb1t">
<img src="https://github.com/lostb1t.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@lostb1t</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Simon-Eklundh">
<img src="https://github.com/Simon-Eklundh.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Simon-Eklundh</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/topiga">
<img src="https://github.com/topiga.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@topiga</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/simoncaron">
<img src="https://github.com/simoncaron.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@simoncaron</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/jakequade">
<img src="https://github.com/jakequade.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@jakequade</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Ryan0204">
<img src="https://github.com/Ryan0204.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Ryan0204</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/retardgerman">
<img src="https://github.com/retardgerman.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@retardgerman</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/whoopsi-daisy">
<img src="https://github.com/whoopsi-daisy.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@whoopsi-daisy</b></sub>
</a>
</td>
</tr>
</table>
And all other developers who have contributed to Streamyfin, thank you for your contributions.
I'd also like to thank the following people and projects for their contributions to Streamyfin:
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API. - [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK. - [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.

11
app.config.js Normal file
View File

@@ -0,0 +1,11 @@
module.exports = ({ config }) => {
if (process.env.EXPO_TV != "1") {
config.plugins.push([
"react-native-google-cast",
{ useDefaultExpandedMediaControls: true },
]);
}
return {
...config,
};
};

View File

@@ -2,16 +2,11 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.25.0", "version": "0.27.0",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#2E2E2E"
},
"jsEngine": "hermes", "jsEngine": "hermes",
"assetBundlePatterns": ["**/*"], "assetBundlePatterns": ["**/*"],
"ios": { "ios": {
@@ -36,7 +31,7 @@
}, },
"android": { "android": {
"jsEngine": "hermes", "jsEngine": "hermes",
"versionCode": 50, "versionCode": 53,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png" "foregroundImage": "./assets/images/adaptive_icon.png"
}, },
@@ -48,15 +43,10 @@
] ]
}, },
"plugins": [ "plugins": [
"@react-native-tvos/config-tv",
"expo-router", "expo-router",
"expo-font", "expo-font",
"@config-plugins/ffmpeg-kit-react-native", "@config-plugins/ffmpeg-kit-react-native",
[
"react-native-google-cast",
{
"useDefaultExpandedMediaControls": true
}
],
[ [
"react-native-video", "react-native-video",
{ {
@@ -78,18 +68,19 @@
"useFrameworks": "static" "useFrameworks": "static"
}, },
"android": { "android": {
"android": { "compileSdkVersion": 35,
"compileSdkVersion": 34, "targetSdkVersion": 35,
"targetSdkVersion": 34, "buildToolsVersion": "35.0.0",
"buildToolsVersion": "34.0.0" "kotlinVersion": "2.0.21",
},
"minSdkVersion": 24, "minSdkVersion": 24,
"usesCleartextTraffic": true, "usesCleartextTraffic": true,
"packagingOptions": { "packagingOptions": {
"jniLibs": { "jniLibs": {
"useLegacyPackaging": true "useLegacyPackaging": true
} }
} },
"useAndroidX": true,
"enableJetifier": true
} }
} }
], ],
@@ -109,12 +100,25 @@
"expo-asset", "expo-asset",
[ [
"react-native-edge-to-edge", "react-native-edge-to-edge",
{ "android": { "parentTheme": "Material3" } } {
"android": {
"parentTheme": "Material3"
}
}
], ],
["react-native-bottom-tabs"], ["react-native-bottom-tabs"],
["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withGoogleCastActivity.js"], ["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"] ["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
[
"expo-splash-screen",
{
"backgroundColor": "#2e2e2e",
"image": "./assets/images/StreamyFinFinal.png",
"imageWidth": 100
}
]
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true
@@ -133,6 +137,7 @@
}, },
"updates": { "updates": {
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68" "url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
} },
"newArchEnabled": false
} }
} }

View File

@@ -1,14 +1,16 @@
import { Platform } from "react-native";
import { FlatList, TouchableOpacity, View } from "react-native"; import { FlatList, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { useAtom } from "jotai/index"; import { useAtom } from "jotai/index";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import * as WebBrowser from "expo-web-browser";
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
export interface MenuLink { export interface MenuLink {
name: string; name: string;
url: string; url: string;
@@ -52,7 +54,13 @@ export default function menuLinks() {
}} }}
data={menuLinks} data={menuLinks}
renderItem={({ item }) => ( renderItem={({ item }) => (
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url)}> <TouchableOpacity
onPress={() => {
if (!Platform.isTV) {
WebBrowser.openBrowserAsync(item.url);
}
}}
>
<ListItem <ListItem
title={item.name} title={item.name}
iconAfter={<Ionicons name="link" size={24} color="white" />} iconAfter={<Ionicons name="link" size={24} color="white" />}

View File

@@ -1,14 +1,18 @@
import { Chromecast } from "@/components/Chromecast";
import { Text } from "@/components/common/Text";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Feather } from "@expo/vector-icons"; import { Ionicons, Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null;
import { useAtom } from "jotai";
import { userAtom } from "@/providers/JellyfinProvider";
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
export default function IndexLayout() { export default function IndexLayout() {
const router = useRouter(); const router = useRouter();
const [user] = useAtom(userAtom);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack> <Stack>
<Stack.Screen <Stack.Screen
@@ -25,14 +29,15 @@ export default function IndexLayout() {
headerShadowVisible: false, headerShadowVisible: false,
headerRight: () => ( headerRight: () => (
<View className="flex flex-row items-center space-x-2"> <View className="flex flex-row items-center space-x-2">
<Chromecast /> {!Platform.isTV && (
<TouchableOpacity <>
onPress={() => { <Chromecast.Chromecast />
router.push("/(auth)/settings"); {user && user.Policy?.IsAdministrator && (
}} <SessionsButton />
> )}
<Feather name="settings" color={"white"} size={22} /> <SettingsButton />
</TouchableOpacity> </>
)}
</View> </View>
), ),
}} }}
@@ -49,6 +54,12 @@ export default function IndexLayout() {
title: t("home.downloads.tvseries"), title: t("home.downloads.tvseries"),
}} }}
/> />
<Stack.Screen
name="sessions/index"
options={{
title: t("home.sessions.title"),
}}
/>
<Stack.Screen <Stack.Screen
name="settings" name="settings"
options={{ options={{
@@ -67,6 +78,12 @@ export default function IndexLayout() {
title: "", title: "",
}} }}
/> />
<Stack.Screen
name="settings/dashboard/sessions"
options={{
title: t("home.settings.dashboard.sessions_title"),
}}
/>
<Stack.Screen <Stack.Screen
name="settings/jellyseerr/page" name="settings/jellyseerr/page"
options={{ options={{
@@ -109,3 +126,38 @@ export default function IndexLayout() {
</Stack> </Stack>
); );
} }
const SettingsButton = () => {
const router = useRouter();
return (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/settings");
}}
>
<Feather name="settings" color={"white"} size={22} />
</TouchableOpacity>
);
};
const SessionsButton = () => {
const router = useRouter();
const { sessions = [], _ } = useSessions({} as useSessionsProps);
return (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/sessions");
}}
>
<View className="mr-4">
<Ionicons
name="play-circle"
color={sessions.length === 0 ? "white" : "#9333ea"}
size={25}
/>
</View>
</TouchableOpacity>
);
};

View File

@@ -1,478 +1,5 @@
import { Button } from "@/components/Button"; import { HomeIndex } from "@/components/settings/HomeIndex";
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 { HomeSectionStyle, useSettings } from "@/utils/atoms/settings";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk";
import {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
type ScrollingCollectionListSection = { export default function page() {
type: "ScrollingCollectionList"; return <HomeIndex />;
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[]>;
orientation?: "horizontal" | "vertical";
};
type MediaListSection = {
type: "MediaListSection";
queryKey: (string | undefined)[];
queryFn: QueryFunction<BaseItemDto>;
};
type Section = ScrollingCollectionListSection | MediaListSection;
export default function index() {
const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const [
settings,
updateSettings,
pluginSettings,
setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings();
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
const { downloadedFiles, cleanCacheDirectory } = useDownload();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
useEffect(() => {
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className="p-2"
>
<Feather
name="download"
color={hasDownloads ? Colors.primary : "white"}
size={22}
/>
</TouchableOpacity>
),
});
}, [downloadedFiles, navigation, router]);
const checkConnection = useCallback(async () => {
setLoadingRetry(true);
const state = await NetInfo.fetch();
setIsConnected(state.isConnected);
setLoadingRetry(false);
}, []);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected == false || state.isInternetReachable === false)
setIsConnected(false);
else setIsConnected(true);
});
NetInfo.fetch().then((state) => {
setIsConnected(state.isConnected);
});
cleanCacheDirectory().catch((e) =>
console.error("Something went wrong cleaning cache directory")
);
return () => {
unsubscribe();
};
}, []);
const {
data,
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries]
);
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
userViews?.filter(
(c) => c.CollectionType && allow.includes(c.CollectionType)
) || []
);
}, [userViews]);
const invalidateCache = useInvalidatePlaybackProgressCache();
const refetch = useCallback(async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
}, []);
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined
): ScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async () => {
if (!api) return [];
return (
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 20,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
).data || []
);
},
type: "ScrollingCollectionList",
}),
[api, user?.Id]
);
let sections: Section[] = [];
if (!settings?.home || !settings?.home?.sections) {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey = [
"home",
"recentlyAddedIn" + c.CollectionType,
user?.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id
);
});
const ss: Section[] = [
{
title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"],
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
{
title: t("home.next_up"),
queryKey: ["home", "nextUp-all"],
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
...latestMediaViews,
// ...(mediaListCollections?.map(
// (ml) =>
// ({
// title: ml.Name,
// queryKey: ["home", "mediaList", ml.Id!],
// queryFn: async () => ml,
// type: "MediaListSection",
// orientation: "vertical",
// } as Section)
// ) || []),
{
title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async () =>
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
limit: 10,
mediaType: ["Video"],
type: ["Movie"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "vertical",
},
{
title: t("home.suggested_episodes"),
queryKey: ["home", "suggestedEpisodes", user?.Id],
queryFn: async () => {
try {
const suggestions = await getSuggestions(api, user.Id);
const nextUpPromises = suggestions.map((series) =>
getNextUp(api, user.Id, series.Id)
);
const nextUpResults = await Promise.all(nextUpPromises);
return nextUpResults.filter((item) => item !== null) || [];
} catch (error) {
console.error("Error fetching data:", error);
return [];
}
},
type: "ScrollingCollectionList",
orientation: "horizontal",
},
];
return ss;
}, [api, user?.Id, collections]);
} else {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const ss: Section[] = [];
for (const key in settings.home?.sections) {
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 || [];
} else 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 || false,
enableRewatching: section.items?.enableRewatching || false,
});
return response.data.Items || [];
}
return [];
},
type: "ScrollingCollectionList",
orientation: section?.orientation || "vertical",
});
}
return ss;
}, [api, user?.Id, settings.home?.sections]);
}
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
<Text className="text-center opacity-70">
{t("home.no_internet_message")}
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
{t("home.go_to_downloads")}
</Button>
<Button
color="black"
onPress={() => {
checkConnection();
}}
justify="center"
className="mt-2"
iconRight={
loadingRetry ? null : (
<Ionicons name="refresh" size={20} color="white" />
)
}
>
{loadingRetry ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
"Retry"
)}
</Button>
</View>
</View>
);
}
if (e1)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
<Text className="text-center opacity-70">
{t("home.error_message")}
</Text>
</View>
);
if (l1)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<View className="flex flex-col space-y-4">
<LargeMovieCarousel />
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
/>
);
} else if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</View>
</ScrollView>
);
}
// Function to get suggestions
async function getSuggestions(api: Api, userId: string | undefined) {
if (!userId) return [];
const response = await getSuggestionsApi(api).getSuggestions({
userId,
limit: 10,
mediaType: ["Unknown"],
type: ["Series"],
});
return response.data.Items ?? [];
}
// Function to get the next up TV show for a series
async function getNextUp(
api: Api,
userId: string | undefined,
seriesId: string | undefined
) {
if (!userId || !seriesId) return null;
const response = await getTvShowsApi(api).getNextUp({
userId,
seriesId,
limit: 1,
});
return response.data.Items?.[0] ?? null;
} }

View File

@@ -5,7 +5,7 @@ import { Feather, Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useFocusEffect, useRouter } from "expo-router"; import { useFocusEffect, useRouter } from "expo-router";
import { useCallback } from "react"; import { useCallback } from "react";
import {useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Linking, TouchableOpacity, View } from "react-native"; import { Linking, TouchableOpacity, View } from "react-native";
export default function page() { export default function page() {
@@ -30,10 +30,10 @@ export default function page() {
</View> </View>
<View> <View>
<Text className="text-lg font-bold">{t("home.intro.features_title")}</Text> <Text className="text-lg font-bold">
<Text className="text-xs"> {t("home.intro.features_title")}
{t("home.intro.features_description")}
</Text> </Text>
<Text className="text-xs">{t("home.intro.features_description")}</Text>
<View className="flex flex-row items-center mt-4"> <View className="flex flex-row items-center mt-4">
<Image <Image
source={require("@/assets/icons/jellyseerr-logo.svg")} source={require("@/assets/icons/jellyseerr-logo.svg")}
@@ -60,7 +60,9 @@ export default function page() {
<Ionicons name="cloud-download-outline" size={32} color="white" /> <Ionicons name="cloud-download-outline" size={32} color="white" />
</View> </View>
<View className="shrink ml-2"> <View className="shrink ml-2">
<Text className="font-bold mb-1">{t("home.intro.downloads_feature_title")}</Text> <Text className="font-bold mb-1">
{t("home.intro.downloads_feature_title")}
</Text>
<Text className="shrink text-xs"> <Text className="shrink text-xs">
{t("home.intro.downloads_feature_description")} {t("home.intro.downloads_feature_description")}
</Text> </Text>
@@ -94,7 +96,9 @@ export default function page() {
<Feather name="settings" size={28} color={"white"} /> <Feather name="settings" size={28} color={"white"} />
</View> </View>
<View className="shrink ml-2"> <View className="shrink ml-2">
<Text className="font-bold mb-1">{t("home.intro.centralised_settings_plugin_title")}</Text> <Text className="font-bold mb-1">
{t("home.intro.centralised_settings_plugin_title")}
</Text>
<Text className="shrink text-xs"> <Text className="shrink text-xs">
{t("home.intro.centralised_settings_plugin_description")}{" "} {t("home.intro.centralised_settings_plugin_description")}{" "}
<Text <Text
@@ -127,7 +131,9 @@ export default function page() {
}} }}
className="mt-4" className="mt-4"
> >
<Text className="text-purple-600 text-center">{t("home.intro.go_to_settings_button")}</Text> <Text className="text-purple-600 text-center">
{t("home.intro.go_to_settings_button")}
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>

View File

@@ -0,0 +1,361 @@
import { Text } from "@/components/common/Text";
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
import { FlashList } from "@shopify/flash-list";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Loader } from "@/components/Loader";
import { SessionInfoDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import Poster from "@/components/posters/Poster";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { useInterval } from "@/hooks/useInterval";
import React, { useEffect, useMemo, useState } from "react";
import { formatTimeString } from "@/utils/time";
import { formatBitrate } from "@/utils/bitrate";
import {
Ionicons,
Entypo,
AntDesign,
MaterialCommunityIcons,
} from "@expo/vector-icons";
import { Badge } from "@/components/Badge";
export default function page() {
const { sessions, isLoading } = useSessions({} as useSessionsProps);
const { t } = useTranslation();
if (isLoading)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
if (!sessions || sessions.length == 0)
return (
<View className="h-full w-full flex justify-center items-center">
<Text className="text-lg text-neutral-500">
{t("home.sessions.no_active_sessions")}
</Text>
</View>
);
return (
<FlashList
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{
paddingTop: 17,
paddingHorizontal: 17,
paddingBottom: 150,
}}
data={sessions}
renderItem={({ item }) => <SessionCard session={item} />}
keyExtractor={(item) => item.Id || ""}
estimatedItemSize={200}
/>
);
}
interface SessionCardProps {
session: SessionInfoDto;
}
const SessionCard = ({ session }: SessionCardProps) => {
const api = useAtomValue(apiAtom);
const [remainingTicks, setRemainingTicks] = useState<number>(0);
const tick = () => {
if (session.PlayState?.IsPaused) return;
setRemainingTicks(remainingTicks - 10000000);
};
const getProgressPercentage = () => {
if (!session.NowPlayingItem || !session.NowPlayingItem.RunTimeTicks) {
return 0;
}
return Math.round(
(100 / session.NowPlayingItem?.RunTimeTicks) *
(session.NowPlayingItem?.RunTimeTicks - remainingTicks)
);
};
useEffect(() => {
const currentTime = session.PlayState?.PositionTicks;
const duration = session.NowPlayingItem?.RunTimeTicks;
if (
duration !== null &&
duration !== undefined &&
currentTime !== null &&
currentTime !== undefined
) {
const remainingTimeTicks = duration - currentTime;
setRemainingTicks(remainingTimeTicks);
}
}, [session]);
useInterval(tick, 1000);
return (
<View className="flex flex-col shadow-md bg-neutral-900 rounded-2xl mb-4">
<View className="flex flex-row p-4">
<View className="w-20 pr-4">
<Poster
id={session.NowPlayingItem?.Id}
url={getPrimaryImageUrl({ api, item: session.NowPlayingItem })}
/>
</View>
<View className="w-full flex-1">
<View className="flex flex-row justify-between">
<View className="flex-1 pr-4">
{session.NowPlayingItem?.Type === "Episode" ? (
<>
<Text className="font-bold">
{session.NowPlayingItem?.Name}
</Text>
<Text numberOfLines={1} className="text-xs opacity-50">
{`S${session.NowPlayingItem.ParentIndexNumber?.toString()}:E${session.NowPlayingItem.IndexNumber?.toString()}`}
{" - "}
{session.NowPlayingItem.SeriesName}
</Text>
</>
) : (
<>
<Text className="font-bold">
{session.NowPlayingItem?.Name}
</Text>
<Text className="text-xs opacity-50">
{session.NowPlayingItem?.ProductionYear}
</Text>
<Text className="text-xs opacity-50">
{session.NowPlayingItem?.SeriesName}
</Text>
</>
)}
</View>
<Text className="text-xs opacity-50 align-right text-right">
{session.UserName}
{"\n"}
{session.Client}
{"\n"}
{session.DeviceName}
</Text>
</View>
<View className="flex-1" />
<View className="flex flex-col align-bottom">
<View className="flex flex-row justify-between align-bottom mb-1">
<Text className="-ml-0.5 text-xs opacity-50 align-left text-left">
{!session.PlayState?.IsPaused ? (
<Ionicons name="play" size={14} color="white" />
) : (
<Ionicons name="pause" size={14} color="white" />
)}
</Text>
<Text className="text-xs opacity-50 align-right text-right">
{formatTimeString(remainingTicks, "tick")} left
</Text>
</View>
<View className="align-bottom bg-gray-800 h-1">
<View
className={`bg-purple-600 h-full`}
style={{
width: `${getProgressPercentage()}%`,
}}
/>
</View>
</View>
</View>
</View>
<TranscodingView session={session} />
</View>
);
};
interface TranscodingBadgesProps {
properties: StreamProps;
}
const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
const iconMap = {
bitrate: <Ionicons name="speedometer-outline" size={12} color="white" />,
codec: <Ionicons name="layers-outline" size={12} color="white" />,
videoRange: (
<Ionicons name="color-palette-outline" size={12} color="white" />
),
resolution: <Ionicons name="film-outline" size={12} color="white" />,
language: <Ionicons name="language-outline" size={12} color="white" />,
audioChannels: <Ionicons name="mic-outline" size={12} color="white" />,
} as const;
const icon = (val: string) => {
return (
iconMap[val as keyof typeof iconMap] ?? (
<Ionicons name="layers-outline" size={12} color="white" />
)
);
};
const formatVal = (key: string, val: any) => {
switch (key) {
case "bitrate":
return formatBitrate(val);
default:
return val;
}
};
return Object.entries(properties)
.filter(([_, value]) => value !== undefined && value !== null)
.map(([key]) => (
<Badge
key={key}
variant="gray"
className="m-0 p-0 pt-0.5 mr-1"
text={formatVal(key, properties[key as keyof StreamProps])}
iconLeft={icon(key)}
/>
));
};
interface StreamProps {
resolution?: string | null | undefined;
language?: string | null | undefined;
codec?: string | null | undefined;
bitrate?: number | null | undefined;
videoRange?: string | null | undefined;
audioChannels?: string | null | undefined;
}
interface TranscodingStreamViewProps {
title: string | undefined;
value?: string;
isTranscoding: Boolean;
transcodeValue?: string | undefined | null;
properties: StreamProps;
transcodeProperties?: StreamProps;
}
const TranscodingStreamView = ({
title,
isTranscoding,
properties,
transcodeProperties,
value,
transcodeValue,
}: TranscodingStreamViewProps) => {
return (
<View className="flex flex-col pt-2 first:pt-0">
<View className="flex flex-row">
<Text className="text-xs opacity-50 w-20 font-bold text-right pr-4">
{title}
</Text>
<Text className="flex-1">
<TranscodingBadges properties={properties} />
</Text>
</View>
{isTranscoding && transcodeProperties ? (
<>
<View className="flex flex-row">
<Text className="-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4">
<MaterialCommunityIcons
name="arrow-right-bottom"
size={14}
color="white"
/>
</Text>
<Text className="flex-1 text-sm mt-1">
<TranscodingBadges properties={transcodeProperties} />
</Text>
</View>
</>
) : null}
</View>
);
};
const TranscodingView = ({ session }: SessionCardProps) => {
const videoStream = useMemo(() => {
return session.NowPlayingItem?.MediaStreams?.filter(
(s) => s.Type == "Video"
)[0];
}, [session]);
const audioStream = useMemo(() => {
const index = session.PlayState?.AudioStreamIndex;
return index !== null && index !== undefined
? session.NowPlayingItem?.MediaStreams?.[index]
: undefined;
}, [session.PlayState?.AudioStreamIndex]);
const subtitleStream = useMemo(() => {
const index = session.PlayState?.SubtitleStreamIndex;
return index !== null && index !== undefined
? session.NowPlayingItem?.MediaStreams?.[index]
: undefined;
}, [session.PlayState?.SubtitleStreamIndex]);
const isTranscoding = useMemo(() => {
return session.PlayState?.PlayMethod == "Transcode";
}, [session.PlayState?.PlayMethod]);
const videoStreamTitle = () => {
return videoStream?.DisplayTitle?.split(" ")[0];
};
return (
<View className="flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2">
<TranscodingStreamView
title="Video"
properties={{
resolution: videoStreamTitle(),
bitrate: videoStream?.BitRate,
codec: videoStream?.Codec,
}}
transcodeProperties={{
bitrate: session.TranscodingInfo?.Bitrate,
codec: session.TranscodingInfo?.VideoCodec,
}}
isTranscoding={
isTranscoding && !session.TranscodingInfo?.IsVideoDirect
? true
: false
}
/>
<TranscodingStreamView
title="Audio"
properties={{
language: audioStream?.Language,
bitrate: audioStream?.BitRate,
codec: audioStream?.Codec,
audioChannels: audioStream?.ChannelLayout,
}}
transcodeProperties={{
bitrate: session.TranscodingInfo?.Bitrate,
codec: session.TranscodingInfo?.AudioCodec,
audioChannels: session.TranscodingInfo?.AudioChannels?.toString(),
}}
isTranscoding={
isTranscoding && !session.TranscodingInfo?.IsVideoDirect
? true
: false
}
/>
{subtitleStream && (
<>
<TranscodingStreamView
title="Subtitle"
isTranscoding={false}
properties={{
language: subtitleStream?.Language,
codec: subtitleStream?.Codec,
}}
transcodeValue={null}
/>
</>
)}
</View>
);
};

View File

@@ -1,8 +1,9 @@
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { AudioToggles } from "@/components/settings/AudioToggles"; import { AudioToggles } from "@/components/settings/AudioToggles";
import { DownloadSettings } from "@/components/settings/DownloadSettings"; import DownloadSettings from "@/components/settings/DownloadSettings";
import { MediaProvider } from "@/components/settings/MediaContext"; import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles"; import { MediaToggles } from "@/components/settings/MediaToggles";
import { OtherSettings } from "@/components/settings/OtherSettings"; import { OtherSettings } from "@/components/settings/OtherSettings";
@@ -10,21 +11,24 @@ import { PluginSettings } from "@/components/settings/PluginSettings";
import { QuickConnect } from "@/components/settings/QuickConnect"; import { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings"; import { StorageSettings } from "@/components/settings/StorageSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles"; import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { UserInfo } from "@/components/settings/UserInfo"; import { UserInfo } from "@/components/settings/UserInfo";
import { useHaptic } from "@/hooks/useHaptic";
import { useJellyfin } from "@/providers/JellyfinProvider"; import { useJellyfin } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log"; import { clearLogs } from "@/utils/log";
import { useHaptic } from "@/hooks/useHaptic"; import { storage } from "@/utils/mmkv";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native"; import { ScrollView, Switch, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { storage } from "@/utils/mmkv"; import { useAtom } from "jotai";
import { userAtom } from "@/providers/JellyfinProvider";
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
export default function settings() { export default function settings() {
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom);
const { logout } = useJellyfin(); const { logout } = useJellyfin();
const successHapticFeedback = useHaptic("success"); const successHapticFeedback = useHaptic("success");
@@ -42,7 +46,9 @@ export default function settings() {
logout(); logout();
}} }}
> >
<Text className="text-red-600">{t("home.settings.log_out_button")}</Text> <Text className="text-red-600">
{t("home.settings.log_out_button")}
</Text>
</TouchableOpacity> </TouchableOpacity>
), ),
}); });
@@ -57,6 +63,7 @@ export default function settings() {
> >
<View className="p-4 flex flex-col gap-y-4"> <View className="p-4 flex flex-col gap-y-4">
<UserInfo /> <UserInfo />
<QuickConnect className="mb-4" /> <QuickConnect className="mb-4" />
<MediaProvider> <MediaProvider>
@@ -66,11 +73,14 @@ export default function settings() {
</MediaProvider> </MediaProvider>
<OtherSettings /> <OtherSettings />
<DownloadSettings /> <DownloadSettings />
<PluginSettings /> <PluginSettings />
<AppLanguageSelector/> <AppLanguageSelector />
<ChromecastSettings />
<ListGroup title={"Intro"}> <ListGroup title={"Intro"}>
<ListItem <ListItem

View File

@@ -29,7 +29,7 @@ import {
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { FlatList, View } from "react-native"; import { FlatList, View } from "react-native";

View File

@@ -29,13 +29,19 @@ import {
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import React, {useCallback, useEffect, useMemo, useRef, useState} from "react"; import React, {
import { TouchableOpacity, View } from "react-native"; useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as DropdownMenu from "zeego/dropdown-menu"; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import RequestModal from "@/components/jellyseerr/RequestModal"; import RequestModal from "@/components/jellyseerr/RequestModal";
import {ANIME_KEYWORD_ID} from "@/utils/jellyseerr/server/api/themoviedb/constants"; import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
const Page: React.FC = () => { const Page: React.FC = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -79,7 +85,8 @@ const Page: React.FC = () => {
}, },
}); });
const [canRequest, hasAdvancedRequestPermission] = useJellyseerrCanRequest(details); const [canRequest, hasAdvancedRequestPermission] =
useJellyseerrCanRequest(details);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => ( (props: BottomSheetBackdropProps) => (
@@ -112,20 +119,22 @@ const Page: React.FC = () => {
seasons: (details as TvDetails)?.seasons seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0) ?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber), ?.map?.((s) => s.seasonNumber),
} };
if (hasAdvancedRequestPermission) { if (hasAdvancedRequestPermission) {
advancedReqModalRef?.current?.present?.(body) advancedReqModalRef?.current?.present?.(body);
return return;
} }
requestMedia(mediaTitle, body, refetch); requestMedia(mediaTitle, body, refetch);
}, [details, result, requestMedia, hasAdvancedRequestPermission]); }, [details, result, requestMedia, hasAdvancedRequestPermission]);
const isAnime = useMemo( const isAnime = useMemo(
() => (details?.keywords.some(k => k.id === ANIME_KEYWORD_ID) || false) && result.mediaType === MediaType.TV, () =>
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
result.mediaType === MediaType.TV,
[details] [details]
) );
useEffect(() => { useEffect(() => {
if (details) { if (details) {
@@ -247,7 +256,7 @@ const Page: React.FC = () => {
hasAdvancedRequest={hasAdvancedRequestPermission} hasAdvancedRequest={hasAdvancedRequestPermission}
onAdvancedRequest={(data) => onAdvancedRequest={(data) =>
advancedReqModalRef?.current?.present(data) advancedReqModalRef?.current?.present(data)
} }
/> />
)} )}
<DetailFacts <DetailFacts
@@ -265,8 +274,8 @@ const Page: React.FC = () => {
type={result.mediaType as MediaType} type={result.mediaType as MediaType}
isAnime={isAnime} isAnime={isAnime}
onRequested={() => { onRequested={() => {
advancedReqModalRef?.current?.close() advancedReqModalRef?.current?.close();
refetch() refetch();
}} }}
/> />
<BottomSheetModal <BottomSheetModal
@@ -313,7 +322,9 @@ const Page: React.FC = () => {
collisionPadding={0} collisionPadding={0}
sideOffset={0} sideOffset={0}
> >
<DropdownMenu.Label>{t("jellyseerr.types")}</DropdownMenu.Label> <DropdownMenu.Label>
{t("jellyseerr.types")}
</DropdownMenu.Label>
{Object.entries(IssueTypeName) {Object.entries(IssueTypeName)
.reverse() .reverse()
.map(([key, value], idx) => ( .map(([key, value], idx) => (

View File

@@ -19,7 +19,7 @@ export default function page() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { t } = useTranslation(); const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser } = useJellyseerr(); const { jellyseerrApi, jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
const { personId } = local as { personId: string }; const { personId } = local as { personId: string };
@@ -32,15 +32,6 @@ export default function page() {
enabled: !!jellyseerrApi && !!personId, enabled: !!jellyseerrApi && !!personId,
}); });
const locale = useMemo(() => {
return jellyseerrUser?.settings?.locale || "en";
}, [jellyseerrUser]);
const region = useMemo(
() => jellyseerrUser?.settings?.region || "US",
[jellyseerrUser]
);
const castedRoles: PersonCreditCast[] = useMemo( const castedRoles: PersonCreditCast[] = useMemo(
() => () =>
uniqBy(orderBy( uniqBy(orderBy(

View File

@@ -15,7 +15,7 @@ import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useEffect, useMemo } from "react"; import React, { useEffect, useMemo } from "react";
import { View } from "react-native"; import { Platform, View } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const page: React.FC = () => { const page: React.FC = () => {
@@ -84,22 +84,26 @@ const page: React.FC = () => {
allEpisodes && allEpisodes &&
allEpisodes.length > 0 && ( allEpisodes.length > 0 && (
<View className="flex flex-row items-center space-x-2"> <View className="flex flex-row items-center space-x-2">
<AddToFavorites item={item} type="series" /> <AddToFavorites item={item} />
<DownloadItems {!Platform.isTV && (
size="large" <>
title={t("item_card.download.download_series")} <DownloadItems
items={allEpisodes || []} size="large"
MissingDownloadIconComponent={() => ( title={t("item_card.download.download_series")}
<Ionicons name="download" size={22} color="white" /> items={allEpisodes || []}
)} MissingDownloadIconComponent={() => (
DownloadedIconComponent={() => ( <Ionicons name="download" size={22} color="white" />
<Ionicons )}
name="checkmark-done-outline" DownloadedIconComponent={() => (
size={24} <Ionicons
color="#9333ea" name="checkmark-done-outline"
size={24}
color="#9333ea"
/>
)}
/> />
)} </>
/> )}
</View> </View>
), ),
}); });

View File

@@ -1,6 +1,6 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react"; import React, { useCallback, useEffect, useMemo } from "react";
import { FlatList, useWindowDimensions, View } from "react-native"; import { FlatList, useWindowDimensions, View } from "react-native";

View File

@@ -3,7 +3,7 @@ import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { Platform } from "react-native"; import { Platform } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export default function IndexLayout() { export default function IndexLayout() {
@@ -27,166 +27,171 @@ export default function IndexLayout() {
}, },
headerTransparent: Platform.OS === "ios" ? true : false, headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false, headerShadowVisible: false,
headerRight: () => ( headerRight: () =>
!pluginSettings?.libraryOptions?.locked && !pluginSettings?.libraryOptions?.locked &&
<DropdownMenu.Root> !Platform.isTV && (
<DropdownMenu.Trigger> <DropdownMenu.Root>
<Ionicons <DropdownMenu.Trigger>
name="ellipsis-horizontal-outline" <Ionicons
size={24} name="ellipsis-horizontal-outline"
color="white" size={24}
/> color="white"
</DropdownMenu.Trigger> />
<DropdownMenu.Content </DropdownMenu.Trigger>
align={"end"} <DropdownMenu.Content
alignOffset={-10} align={"end"}
avoidCollisions={false} alignOffset={-10}
collisionPadding={0} avoidCollisions={false}
loop={false} collisionPadding={0}
side={"bottom"} loop={false}
sideOffset={10} side={"bottom"}
> sideOffset={10}
<DropdownMenu.Label>{t("library.options.display")}</DropdownMenu.Label> >
<DropdownMenu.Group key="display-group"> <DropdownMenu.Label>
<DropdownMenu.Sub> {t("library.options.display")}
<DropdownMenu.SubTrigger key="image-style-trigger"> </DropdownMenu.Label>
{t("library.options.display")} <DropdownMenu.Group key="display-group">
</DropdownMenu.SubTrigger> <DropdownMenu.Sub>
<DropdownMenu.SubContent <DropdownMenu.SubTrigger key="image-style-trigger">
alignOffset={-10} {t("library.options.display")}
avoidCollisions={true} </DropdownMenu.SubTrigger>
collisionPadding={0} <DropdownMenu.SubContent
loop={true} alignOffset={-10}
sideOffset={10} avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
<DropdownMenu.CheckboxItem
key="display-option-1"
value={settings.libraryOptions.display === "row"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "row",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="display-title-1">
{t("library.options.row")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key="display-option-2"
value={settings.libraryOptions.display === "list"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "list",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="display-title-2">
{t("library.options.list")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="image-style-trigger">
{t("library.options.image_style")}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
<DropdownMenu.CheckboxItem
key="poster-option"
value={
settings.libraryOptions.imageStyle === "poster"
}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "poster",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="poster-title">
{t("library.options.poster")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key="cover-option"
value={settings.libraryOptions.imageStyle === "cover"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "cover",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="cover-title">
{t("library.options.cover")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Group>
<DropdownMenu.Group key="show-titles-group">
<DropdownMenu.CheckboxItem
disabled={settings.libraryOptions.imageStyle === "poster"}
key="show-titles-option"
value={settings.libraryOptions.showTitles}
onValueChange={(newValue: string) => {
if (settings.libraryOptions.imageStyle === "poster")
return;
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showTitles: newValue === "on" ? true : false,
},
});
}}
> >
<DropdownMenu.CheckboxItem <DropdownMenu.ItemIndicator />
key="display-option-1" <DropdownMenu.ItemTitle key="show-titles-title">
value={settings.libraryOptions.display === "row"} {t("library.options.show_titles")}
onValueChange={() => </DropdownMenu.ItemTitle>
updateSettings({ </DropdownMenu.CheckboxItem>
libraryOptions: { <DropdownMenu.CheckboxItem
...settings.libraryOptions, key="show-stats-option"
display: "row", value={settings.libraryOptions.showStats}
}, onValueChange={(newValue: string) => {
}) updateSettings({
} libraryOptions: {
> ...settings.libraryOptions,
<DropdownMenu.ItemIndicator /> showStats: newValue === "on" ? true : false,
<DropdownMenu.ItemTitle key="display-title-1"> },
{t("library.options.row")} });
</DropdownMenu.ItemTitle> }}
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key="display-option-2"
value={settings.libraryOptions.display === "list"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "list",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="display-title-2">
{t("library.options.list")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="image-style-trigger">
{t("library.options.image_style")}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
> >
<DropdownMenu.CheckboxItem <DropdownMenu.ItemIndicator />
key="poster-option" <DropdownMenu.ItemTitle key="show-stats-title">
value={settings.libraryOptions.imageStyle === "poster"} {t("library.options.show_stats")}
onValueChange={() => </DropdownMenu.ItemTitle>
updateSettings({ </DropdownMenu.CheckboxItem>
libraryOptions: { </DropdownMenu.Group>
...settings.libraryOptions,
imageStyle: "poster",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="poster-title">
{t("library.options.poster")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key="cover-option"
value={settings.libraryOptions.imageStyle === "cover"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "cover",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="cover-title">
{t("library.options.cover")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Group>
<DropdownMenu.Group key="show-titles-group">
<DropdownMenu.CheckboxItem
disabled={settings.libraryOptions.imageStyle === "poster"}
key="show-titles-option"
value={settings.libraryOptions.showTitles}
onValueChange={(newValue) => {
if (settings.libraryOptions.imageStyle === "poster")
return;
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showTitles: newValue === "on" ? true : false,
},
});
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="show-titles-title">
{t("library.options.show_titles")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key="show-stats-option"
value={settings.libraryOptions.showStats}
onValueChange={(newValue) => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showStats: newValue === "on" ? true : false,
},
});
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="show-stats-title">
{t("library.options.show_stats")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.Group>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
), ),
}} }}
/> />
<Stack.Screen <Stack.Screen

View File

@@ -38,9 +38,18 @@ export default function SearchLayout() {
}} }}
/> />
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} /> <Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
<Stack.Screen name="jellyseerr/person/[personId]" options={commonScreenOptions} /> <Stack.Screen
<Stack.Screen name="jellyseerr/company/[companyId]" options={commonScreenOptions} /> name="jellyseerr/person/[personId]"
<Stack.Screen name="jellyseerr/genre/[genreId]" options={commonScreenOptions} /> options={commonScreenOptions}
/>
<Stack.Screen
name="jellyseerr/company/[companyId]"
options={commonScreenOptions}
/>
<Stack.Screen
name="jellyseerr/genre/[genreId]"
options={commonScreenOptions}
/>
</Stack> </Stack>
); );
} }

View File

@@ -26,12 +26,14 @@ import React, {
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from "react"; } from "react";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { eventBus } from "@/utils/eventBus";
type SearchType = "Library" | "Discover"; type SearchType = "Library" | "Discover";
@@ -50,7 +52,7 @@ export default function search() {
const { t } = useTranslation(); const { t } = useTranslation();
const { q, prev } = params as { q: string; prev: Href<string> }; const { q } = params as { q: string };
const [searchType, setSearchType] = useState<SearchType>("Library"); const [searchType, setSearchType] = useState<SearchType>("Library");
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
@@ -120,22 +122,44 @@ export default function search() {
[api, searchEngine, settings] [api, searchEngine, settings]
); );
type HeaderSearchBarRef = {
focus: () => void;
blur: () => void;
setText: (text: string) => void;
clearText: () => void;
cancelSearch: () => void;
};
const searchBarRef = useRef<HeaderSearchBarRef>(null);
const navigation = useNavigation(); const navigation = useNavigation();
useLayoutEffect(() => { useLayoutEffect(() => {
if (Platform.OS === "ios") navigation.setOptions({
navigation.setOptions({ headerSearchBarOptions: {
headerSearchBarOptions: { ref: searchBarRef,
placeholder: t("search.search"), placeholder: t("search.search"),
onChangeText: (e: any) => { onChangeText: (e: any) => {
router.setParams({ q: "" }); router.setParams({ q: "" });
setSearch(e.nativeEvent.text); setSearch(e.nativeEvent.text);
},
hideWhenScrolling: false,
autoFocus: true,
}, },
}); hideWhenScrolling: false,
autoFocus: false,
},
});
}, [navigation]); }, [navigation]);
useEffect(() => {
const unsubscribe = eventBus.on("searchTabPressed", () => {
// Screen not actuve
if (!searchBarRef.current) return;
// Screen is active, focus search bar
searchBarRef.current?.focus();
});
return () => {
unsubscribe();
};
}, []);
const { data: movies, isFetching: l1 } = useQuery({ const { data: movies, isFetching: l1 } = useQuery({
queryKey: ["search", "movies", debouncedSearch], queryKey: ["search", "movies", debouncedSearch],
queryFn: () => queryFn: () =>
@@ -210,19 +234,12 @@ export default function search() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<View className="flex flex-col"> <View
{Platform.OS === "android" && ( className="flex flex-col"
<View className="mb-4 px-4"> style={{
<Input marginTop: Platform.OS === "android" ? 16 : 0,
autoCorrect={false} }}
returnKeyType="done" >
keyboardType="web-search"
placeholder={t("search.search_here")}
value={search}
onChangeText={(text) => setSearch(text)}
/>
</View>
)}
{jellyseerrApi && ( {jellyseerrApi && (
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2"> <View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
<TouchableOpacity onPress={() => setSearchType("Library")}> <TouchableOpacity onPress={() => setSearchType("Library")}>

View File

@@ -10,7 +10,6 @@ import {
} from "@bottom-tabs/react-navigation"; } from "@bottom-tabs/react-navigation";
const { Navigator } = createNativeBottomTabNavigator(); const { Navigator } = createNativeBottomTabNavigator();
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs"; import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
@@ -21,6 +20,7 @@ import type {
TabNavigationState, TabNavigationState,
} from "@react-navigation/native"; } from "@react-navigation/native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { eventBus } from "@/utils/eventBus";
export const NativeTabs = withLayoutContext< export const NativeTabs = withLayoutContext<
BottomTabNavigationOptions, BottomTabNavigationOptions,
@@ -55,12 +55,19 @@ export default function TabLayout() {
<NativeTabs <NativeTabs
sidebarAdaptable={false} sidebarAdaptable={false}
ignoresTopSafeArea ignoresTopSafeArea
barTintColor={Platform.OS === "android" ? "#121212" : undefined} tabBarStyle={{
backgroundColor: "#121212",
}}
tabBarActiveTintColor={Colors.primary} tabBarActiveTintColor={Colors.primary}
scrollEdgeAppearance="default" scrollEdgeAppearance="default"
> >
<NativeTabs.Screen redirect name="index" /> <NativeTabs.Screen redirect name="index" />
<NativeTabs.Screen <NativeTabs.Screen
listeners={({ navigation }) => ({
tabPress: (e) => {
eventBus.emit("scrollToTop");
},
})}
name="(home)" name="(home)"
options={{ options={{
title: t("tabs.home"), title: t("tabs.home"),
@@ -75,6 +82,11 @@ export default function TabLayout() {
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
listeners={({ navigation }) => ({
tabPress: (e) => {
eventBus.emit("searchTabPressed");
},
})}
name="(search)" name="(search)"
options={{ options={{
title: t("tabs.search"), title: t("tabs.search"),

View File

@@ -1,8 +1,33 @@
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import React from "react"; import React, { useEffect } from "react";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useSettings } from "@/utils/atoms/settings";
import { Platform } from "react-native";
export default function Layout() { export default function Layout() {
const [settings] = useSettings();
useEffect(() => {
if (Platform.isTV) return;
if (settings.defaultVideoOrientation) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
}
return () => {
if (Platform.isTV) return;
if (settings.autoRotate === true) {
ScreenOrientation.unlockAsync();
} else {
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
}
};
}, [settings]);
return ( return (
<> <>
<SystemBars hidden /> <SystemBars hidden />
@@ -16,15 +41,6 @@ export default function Layout() {
animation: "fade", animation: "fade",
}} }}
/> />
<Stack.Screen
name="transcoding-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
</Stack> </Stack>
</> </>
); );

View File

@@ -3,59 +3,40 @@ import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls"; import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener"; import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets"; import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules/vlc-player"; import { VlcPlayerView } from "@/modules/vlc-player";
import { import {
PipStartedPayload,
PlaybackStatePayload, PlaybackStatePayload,
ProgressUpdatePayload, ProgressUpdatePayload,
VlcPlayerViewRef, VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types"; } from "@/modules/vlc-player/src/VlcPlayer.types";
import { useDownload } from "@/providers/DownloadProvider"; const downloadProvider = !Platform.isTV ? require("@/providers/DownloadProvider") : null;
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import native from "@/utils/profiles/native"; import native from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
import { Api } from "@jellyfin/sdk"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { getPlaystateApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useFocusEffect, useGlobalSearchParams } from "expo-router"; import { useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { import React, { useCallback, useMemo, useRef, useState, useEffect } from "react";
useCallback, import { Alert, View, Platform } from "react-native";
useMemo,
useRef,
useState,
useEffect,
} from "react";
import {
Alert,
BackHandler,
View,
AppState,
AppStateStatus,
Platform,
} from "react-native";
import { useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
import settings from "../(tabs)/(home)/settings";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BaseItemDto, MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client";
export default function page() { export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null); const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const navigation = useNavigation();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
@@ -63,12 +44,16 @@ export default function page() {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true); const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false); const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [isPipStarted, setIsPipStarted] = useState(false);
const progress = useSharedValue(0); const progress = useSharedValue(0);
const isSeeking = useSharedValue(false); const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0); const cacheProgress = useSharedValue(0);
let getDownloadedItem = null;
if (!Platform.isTV) {
getDownloadedItem = downloadProvider.useDownload();
}
const { getDownloadedItem } = useDownload();
const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
@@ -94,145 +79,116 @@ export default function page() {
offline: string; offline: string;
}>(); }>();
const [settings] = useSettings(); const [settings] = useSettings();
const insets = useSafeAreaInsets();
const offline = offlineStr === "true"; const offline = offlineStr === "true";
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined; const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1; const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
const bitrateValue = bitrateValueStr const bitrateValue = bitrateValueStr ? parseInt(bitrateValueStr, 10) : BITRATES[0].value;
? parseInt(bitrateValueStr, 10)
: BITRATES[0].value;
const { const [item, setItem] = useState<BaseItemDto | null>(null);
data: item, const [itemStatus, setItemStatus] = useState({
isLoading: isLoadingItem, isLoading: true,
isError: isErrorItem, isError: false,
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
if (offline) {
const item = await getDownloadedItem(itemId);
if (item) return item.item;
}
const res = await getUserLibraryApi(api!).getItem({
itemId,
userId: user?.Id,
});
return res.data;
},
enabled: !!itemId,
staleTime: 0,
}); });
const { useEffect(() => {
data: stream, const fetchItemData = async () => {
isLoading: isLoadingStreamUrl, setItemStatus({ isLoading: true, isError: false });
isError: isErrorStreamUrl, try {
} = useQuery({ let fetchedItem: BaseItemDto | null = null;
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue], if (offline && !Platform.isTV) {
queryFn: async () => { const data = await getDownloadedItem.getDownloadedItem(itemId);
if (offline) { if (data) fetchedItem = data.item as BaseItemDto;
const data = await getDownloadedItem(itemId); } else {
if (!data?.mediaSource) return null; const res = await getUserLibraryApi(api!).getItem({
itemId,
const url = await getDownloadedFileUrl(data.item.Id!); userId: user?.Id,
});
if (item) fetchedItem = res.data;
return { }
mediaSource: data.mediaSource, setItem(fetchedItem);
url, } catch (error) {
sessionId: undefined, console.error("Failed to fetch item:", error);
}; setItemStatus({ isLoading: false, isError: true });
} finally {
setItemStatus({ isLoading: false, isError: false });
} }
};
const res = await getStreamUrl({ if (itemId) {
api, fetchItemData();
item, }
startTimeTicks: item?.UserData?.PlaybackPositionTicks!, }, [itemId, offline, api, user?.Id]);
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: native,
});
if (!res) return null; interface Stream {
mediaSource: MediaSourceInfo;
sessionId: string;
url: string;
}
const { mediaSource, sessionId, url } = res; const [stream, setStream] = useState<Stream | null>(null);
const [streamStatus, setStreamStatus] = useState({
if (!sessionId || !mediaSource || !url) { isLoading: true,
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url")); isError: false,
return null;
}
return {
mediaSource,
sessionId,
url,
};
},
enabled: !!itemId && !!item,
staleTime: 0,
}); });
useEffect(() => {
const fetchStreamData = async () => {
try {
let result: Stream | null = null;
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (!data?.mediaSource) return;
const url = await getDownloadedFileUrl(data.item.Id!);
if (item) {
result = { mediaSource: data.mediaSource, sessionId: "", url };
}
} else {
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: native,
});
if (!res) return;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
return;
}
result = { mediaSource, sessionId, url };
}
setStream(result);
} catch (error) {
console.error("Failed to fetch stream:", error);
setStreamStatus({ isLoading: false, isError: true });
} finally {
setStreamStatus({ isLoading: false, isError: false });
}
};
fetchStreamData();
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
const togglePlay = useCallback(async () => { const togglePlay = useCallback(async () => {
if (!api) return; if (!api) return;
lightHapticFeedback(); lightHapticFeedback();
if (isPlaying) { if (isPlaying) {
await videoRef.current?.pause(); await videoRef.current?.pause();
if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.value),
isPaused: true,
playMethod: stream.url?.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
} else { } else {
videoRef.current?.play(); videoRef.current?.play();
if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.value),
isPaused: false,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
} }
}, [ }, [isPlaying, api, item, stream, videoRef, audioIndex, subtitleIndex, mediaSourceId, offline, progress]);
isPlaying,
api,
item,
stream,
videoRef,
audioIndex,
subtitleIndex,
mediaSourceId,
offline,
progress.value,
]);
const reportPlaybackStopped = useCallback(async () => { const reportPlaybackStopped = useCallback(async () => {
if (offline) return; if (offline) return;
const currentTimeInTicks = msToTicks(progress.get());
const currentTimeInTicks = msToTicks(progress.value);
await getPlaystateApi(api!).onPlaybackStopped({ await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!, itemId: item?.Id!,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
@@ -249,33 +205,23 @@ export default function page() {
videoRef.current?.stop(); videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]); }, [videoRef, reportPlaybackStopped]);
// TODO: unused should remove. useEffect(() => {
const reportPlaybackStart = useCallback(async () => { const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
if (offline) return; return () => {
beforeRemoveListener();
if (!stream) return; };
await getPlaystateApi(api!).onPlaybackStart({ }, [navigation, stop]);
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
});
}, [api, item, mediaSourceId, stream]);
const onProgress = useCallback( const onProgress = useCallback(
async (data: ProgressUpdatePayload) => { async (data: ProgressUpdatePayload) => {
if (isSeeking.value === true) return; if (isSeeking.get() || isPlaybackStopped) return;
if (isPlaybackStopped === true) return;
const { currentTime } = data.nativeEvent; const { currentTime } = data.nativeEvent;
if (isBuffering) { if (isBuffering) {
setIsBuffering(false); setIsBuffering(false);
} }
progress.value = currentTime; progress.set(currentTime);
if (offline) return; if (offline) return;
@@ -294,11 +240,56 @@ export default function page() {
playSessionId: stream.sessionId, playSessionId: stream.sessionId,
}); });
}, },
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex] [item?.Id, audioIndex, subtitleIndex, mediaSourceId, isPlaying, stream, isSeeking, isPlaybackStopped, isBuffering]
); );
useOrientation(); const onPipStarted = useCallback((e: PipStartedPayload) => {
useOrientationSettings(); const { pipStarted } = e.nativeEvent;
setIsPipStarted(pipStarted);
}, []);
const changePlaybackState = useCallback(
async (isPlaying: boolean) => {
if (!api || offline || !stream) return;
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.get()),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
});
},
[api, offline, stream, item?.Id, audioIndex, subtitleIndex, mediaSourceId, progress]
);
const startPosition = useMemo(() => {
if (offline) return 0;
return item?.UserData?.PlaybackPositionTicks ? ticksToSeconds(item.UserData.PlaybackPositionTicks) : 0;
}, [item]);
const reportPlaybackStart = useCallback(async () => {
if (offline || !stream) return;
await getPlaystateApi(api!).onPlaybackStart({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
});
hasReportedRef.current = true;
}, [api, item, stream]);
const hasReportedRef = useRef(false);
useEffect(() => {
if (stream && !hasReportedRef.current) {
reportPlaybackStart();
hasReportedRef.current = true; // Mark as reported
}
}, [stream]);
useWebSocket({ useWebSocket({
isPlaying: isPlaying, isPlaying: isPlaying,
@@ -307,125 +298,82 @@ export default function page() {
offline, offline,
}); });
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => { const onPlaybackStateChanged = useCallback(
const { state, isBuffering, isPlaying } = e.nativeEvent; async (e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") { if (state === "Playing") {
setIsPlaying(true); setIsPlaying(true);
return; await changePlaybackState(true);
} if (!Platform.isTV) await activateKeepAwakeAsync();
return;
if (state === "Paused") {
setIsPlaying(false);
return;
}
if (isPlaying) {
setIsPlaying(true);
setIsBuffering(false);
} else if (isBuffering) {
setIsBuffering(true);
}
}, []);
const startPosition = useMemo(() => {
if (offline) return 0;
return item?.UserData?.PlaybackPositionTicks
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
: 0;
}, [item]);
useFocusEffect(
React.useCallback(() => {
return async () => {
stop();
};
}, [])
);
const [appState, setAppState] = useState(AppState.currentState);
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (appState.match(/inactive|background/) && nextAppState === "active") {
// Handle app coming to the foreground
} else if (nextAppState.match(/inactive|background/)) {
// Handle app going to the background
if (videoRef.current && videoRef.current.pause) {
videoRef.current.pause();
}
} }
setAppState(nextAppState);
};
// Use AppState.addEventListener and return a cleanup function if (state === "Paused") {
const subscription = AppState.addEventListener( setIsPlaying(false);
"change", await changePlaybackState(false);
handleAppStateChange if (!Platform.isTV) await deactivateKeepAwake();
); return;
}
return () => { if (isPlaying) {
// Cleanup the event listener when the component is unmounted setIsPlaying(true);
subscription.remove(); setIsBuffering(false);
}; } else if (isBuffering) {
}, [appState]); setIsBuffering(true);
}
// Preselection of audio and subtitle tracks. },
[changePlaybackState]
if (!settings) return null;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
let externalTrack = { name: "", DeliveryUrl: "" };
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle"
) || [];
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex
); );
const allAudio =
stream?.mediaSource.MediaStreams?.filter( const allAudio = stream?.mediaSource.MediaStreams?.filter((audio) => audio.Type === "Audio") || [];
(audio) => audio.Type === "Audio"
// Move all the external subtitles last, because vlc places them last.
const allSubs =
stream?.mediaSource.MediaStreams?.filter((sub) => sub.Type === "Subtitle").sort(
(a, b) => Number(a.IsExternal) - Number(b.IsExternal)
) || []; ) || [];
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
}));
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
const chosenSubtitleTrack = allSubs.find((sub) => sub.Index === subtitleIndex);
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex); const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
// Direct playback CASE const notTranscoding = !stream?.mediaSource.TranscodingUrl;
if (!bitrateValue) { let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
// If Subtitle is embedded we can use the position to select it straight away. if (chosenSubtitleTrack && (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)) {
if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) { const finalIndex = notTranscoding ? allSubs.indexOf(chosenSubtitleTrack) : textSubs.indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`); initOptions.push(`--sub-track=${finalIndex}`);
} else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) {
// If Subtitle is external we need to pass the URL to the player.
externalTrack = {
name: chosenSubtitleTrack.DisplayTitle || "",
DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
};
}
if (chosenAudioTrack)
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
} else {
// Transcoded playback CASE
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
externalTrack = {
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
DeliveryUrl: "",
};
}
} }
const insets = useSafeAreaInsets(); if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
if (!item || isLoadingItem || isLoadingStreamUrl || !stream) const [isMounted, setIsMounted] = useState(false);
// Add useEffect to handle mounting
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []);
if (itemStatus.isLoading || streamStatus.isLoading) {
return ( return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black"> <View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader /> <Loader />
</View> </View>
); );
}
if (isErrorItem || isErrorStreamUrl) if (!item || !stream || itemStatus.isError || streamStatus.isError)
return ( return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black"> <View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">{t("player.error")}</Text> <Text className="text-white">{t("player.error")}</Text>
@@ -449,32 +397,29 @@ export default function page() {
<VlcPlayerView <VlcPlayerView
ref={videoRef} ref={videoRef}
source={{ source={{
uri: stream.url, uri: stream?.url || "",
autoplay: true, autoplay: true,
isNetwork: true, isNetwork: true,
startPosition, startPosition,
externalTrack, externalSubtitles,
initOptions, initOptions,
}} }}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
onVideoProgress={onProgress} onVideoProgress={onProgress}
progressUpdateInterval={1000} progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged} onVideoStateChange={onPlaybackStateChanged}
onVideoLoadStart={() => {}} onPipStarted={onPipStarted}
onVideoLoadEnd={() => { onVideoLoadEnd={() => {
setIsVideoLoaded(true); setIsVideoLoaded(true);
}} }}
onVideoError={(e) => { onVideoError={(e) => {
console.error("Video Error:", e.nativeEvent); console.error("Video Error:", e.nativeEvent);
Alert.alert( Alert.alert(t("player.error"), t("player.an_error_occured_while_playing_the_video"));
t("player.error"),
t("player.an_error_occured_while_playing_the_video")
);
writeToLog("ERROR", "Video Error", e.nativeEvent); writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}
/> />
</View> </View>
{videoRef.current && ( {videoRef.current && !isPipStarted && isMounted === true ? (
<Controls <Controls
mediaSource={stream?.mediaSource} mediaSource={stream?.mediaSource}
item={item} item={item}
@@ -490,6 +435,7 @@ export default function page() {
setIgnoreSafeAreas={setIgnoreSafeAreas} setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas} ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded} isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef?.current?.startPictureInPicture}
play={videoRef.current?.play} play={videoRef.current?.play}
pause={videoRef.current?.pause} pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo} seek={videoRef.current?.seekTo}
@@ -500,29 +446,9 @@ export default function page() {
setSubtitleTrack={videoRef.current.setSubtitleTrack} setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL} setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack} setAudioTrack={videoRef.current.setAudioTrack}
stop={stop}
isVlc isVlc
/> />
)} ) : null}
</View> </View>
); );
} }
export function usePoster(
item: BaseItemDto,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!item || !api) return undefined;
return item.Type === "Audio"
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: item,
quality: 70,
width: 200,
});
}, [item, api]);
return poster ?? undefined;
}

View File

@@ -1,547 +0,0 @@
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { TrackInfo } from "@/modules/vlc-player";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import transcoding from "@/utils/profiles/transcoding";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useHaptic } from "@/hooks/useHaptic";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, {
OnProgressData,
SelectedTrack,
SelectedTrackType,
VideoRef,
} from "react-native-video";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useTranslation } from "react-i18next";
const Player = () => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [settings] = useSettings();
const videoRef = useRef<VideoRef | null>(null);
const { t } = useTranslation();
const firstTime = useRef(true);
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light");
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const setShowControls = useCallback((show: boolean) => {
_setShowControls(show);
lightHapticFeedback();
}, []);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const {
itemId,
audioIndex: audioIndexStr,
subtitleIndex: subtitleIndexStr,
mediaSourceId,
bitrateValue: bitrateValueStr,
} = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
const subtitleIndex = subtitleIndexStr
? parseInt(subtitleIndexStr, 10)
: undefined;
const bitrateValue = bitrateValueStr
? parseInt(bitrateValueStr, 10)
: undefined;
const {
data: item,
isLoading: isLoadingItem,
isError: isErrorItem,
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
if (!api) {
throw new Error("No api");
}
if (!itemId) {
console.warn("No itemId");
return null;
}
const res = await getUserLibraryApi(api).getItem({
itemId,
userId: user?.Id,
});
return res.data;
},
staleTime: 0,
});
// TODO: NEED TO FIND A WAY TO FROM SWITCHING TO IMAGE BASED TO TEXT BASED SUBTITLES, THERE IS A BUG.
// MOST LIKELY LIKELY NEED A MASSIVE REFACTOR.
const {
data: stream,
isLoading: isLoadingStreamUrl,
isError: isErrorStreamUrl,
} = useQuery({
queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId, audioIndex],
queryFn: async () => {
if (!api) {
throw new Error("No api");
}
if (!item) {
console.warn("No item", itemId, item);
return null;
}
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: transcoding,
});
if (!res) return null;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
console.warn("No sessionId or mediaSource or url", url);
return null;
}
return {
mediaSource,
sessionId,
url,
};
},
enabled: !!item,
staleTime: 0,
});
const poster = usePoster(item, api);
const videoSource = useVideoSource(item, api, poster, stream?.url);
const togglePlay = useCallback(async () => {
lightHapticFeedback();
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(progress.value),
isPaused: true,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
} else {
videoRef.current?.resume();
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(progress.value),
isPaused: false,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
}
}, [
isPlaying,
api,
item,
videoRef,
settings,
stream,
audioIndex,
subtitleIndex,
mediaSourceId,
]);
const play = useCallback(() => {
videoRef.current?.resume();
reportPlaybackStart();
}, [videoRef]);
const pause = useCallback(() => {
videoRef.current?.pause();
}, [videoRef]);
const seek = useCallback(
(seconds: number) => {
videoRef.current?.seek(seconds);
},
[videoRef]
);
const reportPlaybackStopped = async () => {
if (!item?.Id) return;
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item.Id,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(progress.value),
playSessionId: stream?.sessionId,
});
revalidateProgressCache();
};
const stop = useCallback(() => {
reportPlaybackStopped();
videoRef.current?.pause();
setIsPlaybackStopped(true);
}, [videoRef, reportPlaybackStopped]);
const reportPlaybackStart = async () => {
if (!item?.Id) return;
await getPlaystateApi(api!).onPlaybackStart({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
};
const onProgress = useCallback(
async (data: OnProgressData) => {
if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return;
const ticks = secondsToTicks(data.currentTime);
progress.value = ticks;
cacheProgress.value = secondsToTicks(data.playableDuration);
// TODO: Use this when streaming with HLS url, but NOT when direct playing
// TODO: since playable duration is always 0 then.
setIsBuffering(data.playableDuration === 0);
if (!item?.Id || data.currentTime === 0) {
return;
}
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.round(ticks),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
},
[
item,
isPlaying,
api,
isPlaybackStopped,
isSeeking,
stream,
mediaSourceId,
audioIndex,
subtitleIndex,
]
);
useOrientation();
useOrientationSettings();
useWebSocket({
isPlaying: isPlaying,
togglePlay: togglePlay,
stopPlayback: stop,
offline: false,
});
const [selectedTextTrack, setSelectedTextTrack] = useState<
SelectedTrack | undefined
>();
const [embededTextTracks, setEmbededTextTracks] = useState<
{
index: number;
language?: string | undefined;
selected?: boolean | undefined;
title?: string | undefined;
type: any;
}[]
>([]);
const [audioTracks, setAudioTracks] = useState<TrackInfo[]>([]);
const [selectedAudioTrack, setSelectedAudioTrack] = useState<
SelectedTrack | undefined
>(undefined);
useEffect(() => {
if (selectedTextTrack === undefined) {
const subtitleHelper = new SubtitleHelper(
stream?.mediaSource.MediaStreams ?? []
);
const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex(
subtitleIndex!
);
// Most likely the subtitle is burned in.
if (embeddedTrackIndex === -1) return;
setSelectedTextTrack({
type: SelectedTrackType.INDEX,
value: embeddedTrackIndex,
});
}
}, [embededTextTracks]);
const getAudioTracks = (): TrackInfo[] => {
return audioTracks.map((t) => ({
name: t.name,
index: t.index,
}));
};
const getSubtitleTracks = (): TrackInfo[] => {
return embededTextTracks.map((t) => ({
name: t.title ?? "",
index: t.index,
language: t.language,
}));
};
useFocusEffect(
React.useCallback(() => {
return async () => {
stop();
};
}, [])
);
if (isLoadingItem || isLoadingStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
if (isErrorItem || isErrorStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">{t("player.error")}</Text>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: "black" }}>
<View
style={{
display: "flex",
width: "100%",
height: "100%",
position: "relative",
flexDirection: "column",
justifyContent: "center",
}}
>
{videoSource ? (
<>
<Video
ref={videoRef}
source={videoSource}
style={{
height: "100%",
width: "100%",
}}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={(e) => {
console.error("Error playing video", e);
}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
progressUpdateInterval={500}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
}}
onTextTracks={(data) => {
setEmbededTextTracks(data.textTracks as any);
}}
onBuffer={(e) => {
setIsBuffering(e.isBuffering);
}}
onAudioTracks={(e) => {
setAudioTracks(
e.audioTracks.map((t) => ({
index: t.index,
name: t.title ?? "",
language: t.language,
}))
);
}}
selectedTextTrack={selectedTextTrack}
selectedAudioTrack={selectedAudioTrack}
/>
</>
) : (
<Text>{t("player.no_video_source")}</Text>
)}
</View>
{item && (
<Controls
mediaSource={stream?.mediaSource}
videoRef={videoRef}
enableTrickplay={true}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
seek={seek}
play={play}
pause={pause}
stop={stop}
getSubtitleTracks={getSubtitleTracks}
setSubtitleTrack={(i) => {
if (i === -1) {
setSelectedTextTrack({
type: SelectedTrackType.DISABLED,
value: undefined,
});
return;
}
setSelectedTextTrack({
type: SelectedTrackType.INDEX,
value: i,
});
}}
getAudioTracks={getAudioTracks}
setAudioTrack={(i) => {
setSelectedAudioTrack({
type: SelectedTrackType.INDEX,
value: i,
});
}}
/>
)}
</View>
);
};
export function usePoster(
item: BaseItemDto | null | undefined,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!item || !api) return undefined;
return item.Type === "Audio"
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: item,
quality: 70,
width: 200,
});
}, [item, api]);
return poster ?? undefined;
}
export function useVideoSource(
item: BaseItemDto | null | undefined,
api: Api | null,
poster: string | undefined,
url?: string | null
) {
const videoSource = useMemo(() => {
if (!item || !api || !url) {
return null;
}
const startPosition = item?.UserData?.PlaybackPositionTicks
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
title: item?.Name || "Unknown",
description: item?.Overview ?? undefined,
imageUri: poster,
subtitle: item?.Album ?? undefined,
},
};
}, [item, api, poster, url]);
return videoSource;
}
export default Player;

View File

@@ -1,5 +1,6 @@
import "@/augmentations"; import "@/augmentations";
import { Text } from "@/components/common/Text"; import { Platform } from "react-native";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
import { import {
getOrSetDeviceId, getOrSetDeviceId,
@@ -9,7 +10,6 @@ import {
import { JobQueueProvider } from "@/providers/JobQueueProvider"; import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { orientationAtom } from "@/utils/atoms/orientation";
import { Settings, useSettings } from "@/utils/atoms/settings"; import { Settings, useSettings } from "@/utils/atoms/settings";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks"; import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
import { LogProvider, writeToLog } from "@/utils/log"; import { LogProvider, writeToLog } from "@/utils/log";
@@ -18,64 +18,73 @@ 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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { const BackGroundDownloader = !Platform.isTV
checkForExistingDownloads, ? require("@kesha-antonov/react-native-background-downloader")
completeHandler, : null;
download,
} from "@kesha-antonov/react-native-background-downloader";
import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as BackgroundFetch from "expo-background-fetch"; const BackgroundFetch = !Platform.isTV
? require("expo-background-fetch")
: null;
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import { useFonts } from "expo-font"; const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import { useKeepAwake } from "expo-keep-awake";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { router, Stack } from "expo-router"; import { router, Stack } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import * as SplashScreen from "expo-splash-screen"; import * as SplashScreen from "expo-splash-screen";
import * as TaskManager from "expo-task-manager"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { Provider as JotaiProvider, useAtom } from "jotai"; const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
import { getLocales } from "expo-localization";
import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { Appearance, AppState, TouchableOpacity } from "react-native"; import { I18nextProvider } from "react-i18next";
import { Appearance, AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import { I18nextProvider, useTranslation } from "react-i18next";
import i18n from "@/i18n";
import { getLocales } from "expo-localization";
import "react-native-reanimated"; import "react-native-reanimated";
import { Toaster } from "sonner-native"; import { Toaster } from "sonner-native";
if (!Platform.isTV) {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
}
// Keep the splash screen visible while we fetch resources
SplashScreen.preventAutoHideAsync(); SplashScreen.preventAutoHideAsync();
Notifications.setNotificationHandler({ // Set the animation options. This is optional.
handleNotification: async () => ({ SplashScreen.setOptions({
shouldShowAlert: true, duration: 500,
shouldPlaySound: true, fade: true,
shouldSetBadge: false,
}),
}); });
function useNotificationObserver() { function useNotificationObserver() {
if (Platform.isTV) return;
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
function redirect(notification: Notifications.Notification) { function redirect(notification: typeof Notifications.Notification) {
const url = notification.request.content.data?.url; const url = notification.request.content.data?.url;
if (url) { if (url) {
router.push(url); router.push(url);
} }
} }
Notifications.getLastNotificationResponseAsync().then((response) => { Notifications.getLastNotificationResponseAsync().then(
if (!isMounted || !response?.notification) { (response: { notification: any }) => {
return; if (!isMounted || !response?.notification) {
return;
}
redirect(response?.notification);
} }
redirect(response?.notification); );
});
const subscription = Notifications.addNotificationResponseReceivedListener( const subscription = Notifications.addNotificationResponseReceivedListener(
(response) => { (response: { notification: any }) => {
redirect(response.notification); redirect(response.notification);
} }
); );
@@ -87,99 +96,101 @@ function useNotificationObserver() {
}, []); }, []);
} }
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { if (!Platform.isTV) {
console.log("TaskManager ~ trigger"); TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger");
const now = Date.now(); 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; const url = settings?.optimizedVersionsServerUrl;
if (!settings?.autoDownload || !url) if (!settings?.autoDownload || !url)
return BackgroundFetch.BackgroundFetchResult.NoData; return BackgroundFetch.BackgroundFetchResult.NoData;
const token = getTokenFromStorage(); const token = getTokenFromStorage();
const deviceId = getOrSetDeviceId(); const deviceId = getOrSetDeviceId();
const baseDirectory = FileSystem.documentDirectory; const baseDirectory = FileSystem.documentDirectory;
if (!token || !deviceId || !baseDirectory) if (!token || !deviceId || !baseDirectory)
return BackgroundFetch.BackgroundFetchResult.NoData; return BackgroundFetch.BackgroundFetchResult.NoData;
const jobs = await getAllJobsByDeviceId({ const jobs = await getAllJobsByDeviceId({
deviceId, deviceId,
authHeader: token, authHeader: token,
url, url,
}); });
console.log("TaskManager ~ Active jobs: ", jobs.length); console.log("TaskManager ~ Active jobs: ", jobs.length);
for (let job of jobs) { for (let job of jobs) {
if (job.status === "completed") { if (job.status === "completed") {
const downloadUrl = url + "download/" + job.id; const downloadUrl = url + "download/" + job.id;
const tasks = await checkForExistingDownloads(); const tasks = await BackGroundDownloader.checkForExistingDownloads();
if (tasks.find((task) => task.id === job.id)) { if (tasks.find((task: { id: string }) => task.id === job.id)) {
console.log("TaskManager ~ Download already in progress: ", job.id); console.log("TaskManager ~ Download already in progress: ", job.id);
continue; continue;
}
BackGroundDownloader.download({
id: job.id,
url: downloadUrl,
destination: `${baseDirectory}${job.item.Id}.mp4`,
headers: {
Authorization: token,
},
})
.begin(() => {
console.log("TaskManager ~ Download started: ", job.id);
})
.done(() => {
console.log("TaskManager ~ Download completed: ", job.id);
saveDownloadedItemInfo(job.item);
BackGroundDownloader.completeHandler(job.id);
cancelJobById({
authHeader: token,
id: job.id,
url: url,
});
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download completed",
data: {
url: `/downloads`,
},
},
trigger: null,
});
})
.error((error: any) => {
console.log("TaskManager ~ Download error: ", job.id, error);
BackGroundDownloader.completeHandler(job.id);
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download failed",
data: {
url: `/downloads`,
},
},
trigger: null,
});
});
} }
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);
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) => {
console.log("TaskManager ~ Download error: ", job.id, error);
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()}`); 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;
}); });
}
const checkAndRequestPermissions = async () => { const checkAndRequestPermissions = async () => {
try { try {
@@ -213,28 +224,18 @@ const checkAndRequestPermissions = async () => {
}; };
export default function RootLayout() { export default function RootLayout() {
const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
Appearance.setColorScheme("dark"); Appearance.setColorScheme("dark");
if (!loaded) {
return null;
}
return ( return (
<JotaiProvider> <GestureHandlerRootView style={{ flex: 1 }}>
<I18nextProvider i18n={i18n}> <JotaiProvider>
<Layout /> <ActionSheetProvider>
</I18nextProvider> <I18nextProvider i18n={i18n}>
</JotaiProvider> <Layout />
</I18nextProvider>
</ActionSheetProvider>
</JotaiProvider>
</GestureHandlerRootView>
); );
} }
@@ -251,26 +252,8 @@ const queryClient = new QueryClient({
}); });
function Layout() { function Layout() {
const [settings, updateSettings] = useSettings(); const [settings] = useSettings();
const [orientation, setOrientation] = useAtom(orientationAtom); const appState = useRef(AppState.currentState);
useKeepAwake();
useNotificationObserver();
const { i18n } = useTranslation();
useEffect(() => {
checkAndRequestPermissions();
}, []);
useEffect(() => {
if (settings?.autoRotate === true)
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
else
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
}, [settings]);
useEffect(() => { useEffect(() => {
i18n.changeLanguage( i18n.changeLanguage(
@@ -278,112 +261,108 @@ function Layout() {
); );
}, [settings?.preferedLanguage, i18n]); }, [settings?.preferedLanguage, i18n]);
const appState = useRef(AppState.currentState); if (!Platform.isTV) {
useNotificationObserver();
useEffect(() => { useEffect(() => {
const subscription = AppState.addEventListener("change", (nextAppState) => { checkAndRequestPermissions();
if ( }, []);
appState.current.match(/inactive|background/) &&
nextAppState === "active" useEffect(() => {
) { // If the user has auto rotate enabled, unlock the orientation
checkForExistingDownloads(); if (Platform.isTV) return;
if (settings.autoRotate === true) {
ScreenOrientation.unlockAsync();
} else {
// If the user has auto rotate disabled, lock the orientation to portrait
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
} }
}); }, [settings]);
checkForExistingDownloads(); useEffect(() => {
const subscription = AppState.addEventListener(
"change",
(nextAppState) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
BackGroundDownloader.checkForExistingDownloads();
}
}
);
return () => { BackGroundDownloader.checkForExistingDownloads();
subscription.remove();
};
}, []);
useEffect(() => { return () => {
const subscription = ScreenOrientation.addOrientationChangeListener( subscription.remove();
(event) => { };
setOrientation(event.orientationInfo.orientation); }, []);
}
);
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
setOrientation(initialOrientation);
});
return () => {
ScreenOrientation.removeOrientationChangeListener(subscription);
};
}, []);
const url = Linking.useURL();
if (url) {
const { hostname, path, queryParams } = Linking.parse(url);
} }
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <QueryClientProvider client={queryClient}>
<QueryClientProvider client={queryClient}> <JobQueueProvider>
<ActionSheetProvider> <JellyfinProvider>
<JobQueueProvider> <PlaySettingsProvider>
<JellyfinProvider> <LogProvider>
<PlaySettingsProvider> <WebSocketProvider>
<LogProvider> <DownloadProvider>
<WebSocketProvider> <BottomSheetModalProvider>
<DownloadProvider> <SystemBars style="light" hidden={false} />
<BottomSheetModalProvider> <ThemeProvider value={DarkTheme}>
<SystemBars style="light" hidden={false} /> <Stack initialRouteName="(auth)/(tabs)">
<ThemeProvider value={DarkTheme}> <Stack.Screen
<Stack initialRouteName="/home"> name="(auth)/(tabs)"
<Stack.Screen options={{
name="(auth)/(tabs)" headerShown: false,
options={{ title: "",
headerShown: false, header: () => null,
title: "", }}
header: () => null, />
}} <Stack.Screen
/> name="(auth)/player"
<Stack.Screen options={{
name="(auth)/player" headerShown: false,
options={{ title: "",
headerShown: false, header: () => null,
title: "", }}
header: () => null, />
}} <Stack.Screen
/> name="login"
<Stack.Screen options={{
name="login" headerShown: true,
options={{ title: "",
headerShown: true, headerTransparent: true,
title: "", }}
headerTransparent: true, />
}} <Stack.Screen name="+not-found" />
/> </Stack>
<Stack.Screen name="+not-found" /> <Toaster
</Stack> duration={4000}
<Toaster toastOptions={{
duration={4000} style: {
toastOptions={{ backgroundColor: "#262626",
style: { borderColor: "#363639",
backgroundColor: "#262626", borderWidth: 1,
borderColor: "#363639", },
borderWidth: 1, titleStyle: {
}, color: "white",
titleStyle: { },
color: "white", }}
}, closeButton
}} />
closeButton </ThemeProvider>
/> </BottomSheetModalProvider>
</ThemeProvider> </DownloadProvider>
</BottomSheetModalProvider> </WebSocketProvider>
</DownloadProvider> </LogProvider>
</WebSocketProvider> </PlaySettingsProvider>
</LogProvider> </JellyfinProvider>
</PlaySettingsProvider> </JobQueueProvider>
</JellyfinProvider> </QueryClientProvider>
</JobQueueProvider>
</ActionSheetProvider>
</QueryClientProvider>
</GestureHandlerRootView>
); );
} }

View File

@@ -9,7 +9,7 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { import {
Alert, Alert,
@@ -19,17 +19,20 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { Keyboard } from "react-native";
import { z } from "zod"; import { z } from "zod";
import { t } from 'i18next'; import { t } from "i18next";
const CredentialsSchema = z.object({ const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")),}); username: z.string().min(1, t("login.username_required")),
});
const Login: React.FC = () => { const Login: React.FC = () => {
const api = useAtomValue(apiAtom);
const navigation = useNavigation();
const params = useLocalSearchParams();
const { setServer, login, removeServer, initiateQuickConnect } = const { setServer, login, removeServer, initiateQuickConnect } =
useJellyfin(); useJellyfin();
const [api] = useAtom(apiAtom);
const params = useLocalSearchParams();
const { const {
apiUrl: _apiUrl, apiUrl: _apiUrl,
@@ -37,6 +40,8 @@ const CredentialsSchema = z.object({
password: _password, password: _password,
} = params as { apiUrl: string; username: string; password: string }; } = params as { apiUrl: string; username: string; password: string };
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [serverURL, setServerURL] = useState<string>(_apiUrl); const [serverURL, setServerURL] = useState<string>(_apiUrl);
const [serverName, setServerName] = useState<string>(""); const [serverName, setServerName] = useState<string>("");
const [credentials, setCredentials] = useState<{ const [credentials, setCredentials] = useState<{
@@ -47,10 +52,11 @@ const CredentialsSchema = z.object({
password: _password, password: _password,
}); });
/**
* A way to auto login based on a link
*/
useEffect(() => { useEffect(() => {
(async () => { (async () => {
// we might re-use the checkUrl function here to check the url as well
// however, I don't think it should be necessary for now
if (_apiUrl) { if (_apiUrl) {
setServer({ setServer({
address: _apiUrl, address: _apiUrl,
@@ -66,7 +72,6 @@ const CredentialsSchema = z.object({
})(); })();
}, [_apiUrl, _username, _password]); }, [_apiUrl, _username, _password]);
const navigation = useNavigation();
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerTitle: serverName, headerTitle: serverName,
@@ -79,15 +84,17 @@ const CredentialsSchema = z.object({
className="flex flex-row items-center" className="flex flex-row items-center"
> >
<Ionicons name="chevron-back" size={18} color={Colors.primary} /> <Ionicons name="chevron-back" size={18} color={Colors.primary} />
<Text className="ml-2 text-purple-600">{t("login.change_server")}</Text> <Text className="ml-2 text-purple-600">
{t("login.change_server")}
</Text>
</TouchableOpacity> </TouchableOpacity>
) : null, ) : null,
}); });
}, [serverName, navigation, api?.basePath]); }, [serverName, navigation, api?.basePath]);
const [loading, setLoading] = useState<boolean>(false);
const handleLogin = async () => { const handleLogin = async () => {
Keyboard.dismiss();
setLoading(true); setLoading(true);
try { try {
const result = CredentialsSchema.safeParse(credentials); const result = CredentialsSchema.safeParse(credentials);
@@ -98,15 +105,16 @@ const CredentialsSchema = z.object({
if (error instanceof Error) { if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message); Alert.alert(t("login.connection_failed"), error.message);
} else { } else {
Alert.alert(t("login.connection_failed"), t("login.an_unexpected_error_occured")); Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occured")
);
} }
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
/** /**
* Checks the availability and validity of a Jellyfin server URL. * Checks the availability and validity of a Jellyfin server URL.
* *
@@ -180,14 +188,21 @@ const CredentialsSchema = z.object({
try { try {
const code = await initiateQuickConnect(); const code = await initiateQuickConnect();
if (code) { if (code) {
Alert.alert(t("login.quick_connect"), t("login.enter_code_to_login", {code: code}), [ Alert.alert(
{ t("login.quick_connect"),
text: t("login.got_it"), t("login.enter_code_to_login", { code: code }),
}, [
]); {
text: t("login.got_it"),
},
]
);
} }
} catch (error) { } catch (error) {
Alert.alert(t("login.error_title"), t("login.failed_to_initiate_quick_connect")); Alert.alert(
t("login.error_title"),
t("login.failed_to_initiate_quick_connect")
);
} }
}; };
@@ -201,16 +216,18 @@ const CredentialsSchema = z.object({
<View className="flex flex-col h-full relative items-center justify-center"> <View className="flex flex-col h-full relative items-center justify-center">
<View className="px-4 -mt-20 w-full"> <View className="px-4 -mt-20 w-full">
<View className="flex flex-col space-y-2"> <View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold -mb-2"> <Text className="text-2xl font-bold -mb-2">
<> <>
{serverName ? ( {serverName ? (
<> <>
{t("login.login_to_title") + " "} {t("login.login_to_title") + " "}
<Text className="text-purple-600">{serverName}</Text> <Text className="text-purple-600">{serverName}</Text>
</> </>
) : t("login.login_title")} ) : (
</> t("login.login_title")
</Text> )}
</>
</Text>
<Text className="text-xs text-neutral-400"> <Text className="text-xs text-neutral-400">
{api.basePath} {api.basePath}
</Text> </Text>
@@ -220,7 +237,6 @@ const CredentialsSchema = z.object({
setCredentials({ ...credentials, username: text }) setCredentials({ ...credentials, username: text })
} }
value={credentials.username} value={credentials.username}
autoFocus
secureTextEntry={false} secureTextEntry={false}
keyboardType="default" keyboardType="default"
returnKeyType="done" returnKeyType="done"
@@ -300,7 +316,9 @@ const CredentialsSchema = z.object({
<Button <Button
loading={loadingServerCheck} loading={loadingServerCheck}
disabled={loadingServerCheck} disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)} onPress={async () => {
await handleConnect(serverURL);
}}
className="w-full grow" className="w-full grow"
> >
{t("server.connect_button")} {t("server.connect_button")}

2929
bun.lock Normal file

File diff suppressed because it is too large Load Diff

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,113 +1,23 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { useFavorite } from "@/hooks/useFavorite";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { View } from "react-native";
import { useAtom } from "jotai"; import { RoundButton } from "@/components/RoundButton";
import { useMemo } from "react";
import { TouchableOpacityProps, View, ViewProps } from "react-native";
import { RoundButton } from "./RoundButton";
interface Props extends ViewProps { interface Props extends ViewProps {
item: BaseItemDto; item: BaseItemDto;
type: "item" | "series";
} }
export const AddToFavorites: React.FC<Props> = ({ item, type, ...props }) => { export const AddToFavorites = ({ item, ...props }) => {
const queryClient = useQueryClient(); const { isFavorite, toggleFavorite, _} = useFavorite(item);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const isFavorite = useMemo(() => {
return item.UserData?.IsFavorite;
}, [item.UserData?.IsFavorite]);
const updateItemInQueries = (newData: Partial<BaseItemDto>) => {
queryClient.setQueryData<BaseItemDto | undefined>(
[type, item.Id],
(old) => {
if (!old) return old;
return {
...old,
...newData,
UserData: { ...old.UserData, ...newData.UserData },
};
}
);
};
const markFavoriteMutation = useMutation({
mutationFn: async () => {
if (api && user) {
await getUserLibraryApi(api).markFavoriteItem({
userId: user.Id,
itemId: item.Id!,
});
}
},
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
const previousItem = queryClient.getQueryData<BaseItemDto>([
type,
item.Id,
]);
updateItemInQueries({ UserData: { IsFavorite: true } });
return { previousItem };
},
onError: (err, variables, context) => {
if (context?.previousItem) {
queryClient.setQueryData([type, item.Id], context.previousItem);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
},
});
const unmarkFavoriteMutation = useMutation({
mutationFn: async () => {
if (api && user) {
await getUserLibraryApi(api).unmarkFavoriteItem({
userId: user.Id,
itemId: item.Id!,
});
}
},
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
const previousItem = queryClient.getQueryData<BaseItemDto>([
type,
item.Id,
]);
updateItemInQueries({ UserData: { IsFavorite: false } });
return { previousItem };
},
onError: (err, variables, context) => {
if (context?.previousItem) {
queryClient.setQueryData([type, item.Id], context.previousItem);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
},
});
return ( return (
<View {...props}> <View {...props}>
<RoundButton <RoundButton
size="large" size="large"
icon={isFavorite ? "heart" : "heart-outline"} icon={isFavorite ? "heart" : "heart-outline"}
fillColor={isFavorite ? "primary" : undefined} fillColor={isFavorite ? "primary" : undefined}
onPress={() => { onPress={toggleFavorite}
if (isFavorite) {
unmarkFavoriteMutation.mutate();
} else {
markFavoriteMutation.mutate();
}
}}
/> />
</View> </View>
); );

View File

@@ -1,7 +1,7 @@
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react"; import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -17,6 +17,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
selected, selected,
...props ...props
}) => { }) => {
if (Platform.isTV) return null;
const audioStreams = useMemo( const audioStreams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"), () => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
[source] [source]
@@ -39,7 +40,9 @@ export const AudioTrackSelector: React.FC<Props> = ({
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col" {...props}> <View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">{t("item_card.audio")}</Text> <Text className="opacity-50 mb-1 text-xs">
{t("item_card.audio")}
</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"> <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="" numberOfLines={1}> <Text className="" numberOfLines={1}>
{selectedAudioSteam?.DisplayTitle} {selectedAudioSteam?.DisplayTitle}

View File

@@ -1,5 +1,5 @@
import { TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -54,6 +54,7 @@ export const BitrateSelector: React.FC<Props> = ({
inverted, inverted,
...props ...props
}) => { }) => {
if (Platform.isTV) return null;
const sorted = useMemo(() => { const sorted = useMemo(() => {
if (inverted) if (inverted)
return BITRATES.sort( return BITRATES.sort(
@@ -77,7 +78,9 @@ export const BitrateSelector: React.FC<Props> = ({
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col" {...props}> <View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">{t("item_card.quality")}</Text> <Text className="opacity-50 mb-1 text-xs">
{t("item_card.quality")}
</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"> <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}> <Text style={{}} className="" numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key} {BITRATES.find((b) => b.value === selected?.value)?.key}

View File

@@ -1,6 +1,6 @@
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import React, { PropsWithChildren, ReactNode, useMemo } from "react"; import React, { PropsWithChildren, ReactNode, useMemo } from "react";
import { Text, TouchableOpacity, View } from "react-native"; import { Platform, Text, TouchableOpacity, View } from "react-native";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
export interface ButtonProps export interface ButtonProps

View File

@@ -1,5 +1,4 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import React, { useCallback, useEffect } from "react"; import React, { useCallback, useEffect } from "react";
import { Platform, TouchableOpacity, ViewProps } from "react-native"; import { Platform, TouchableOpacity, ViewProps } from "react-native";
import GoogleCast, { import GoogleCast, {
@@ -18,12 +17,12 @@ interface Props extends ViewProps {
background?: "blur" | "transparent"; background?: "blur" | "transparent";
} }
export const Chromecast: React.FC<Props> = ({ export function Chromecast({
width = 48, width = 48,
height = 48, height = 48,
background = "transparent", background = "transparent",
...props ...props
}) => { }) {
const client = useRemoteMediaClient(); const client = useRemoteMediaClient();
const castDevice = useCastDevice(); const castDevice = useCastDevice();
const devices = useDevices(); const devices = useDevices();
@@ -83,4 +82,4 @@ export const Chromecast: React.FC<Props> = ({
<Feather name="cast" size={22} color={"white"} /> <Feather name="cast" size={22} color={"white"} />
</RoundButton> </RoundButton>
); );
}; }

View File

View File

@@ -0,0 +1 @@
export * from "zeego/context-menu";

View File

View File

@@ -2,7 +2,7 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue"; import { queueActions, queueAtom } from "@/utils/atoms/queue";
import {DownloadMethod, useSettings} from "@/utils/atoms/settings"; import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server"; import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
@@ -21,7 +21,7 @@ import {
import { Href, router, useFocusEffect } from "expo-router"; import { Href, router, useFocusEffect } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react"; import React, { useCallback, useMemo, useRef, useState } from "react";
import { Alert, View, ViewProps } from "react-native"; import { Alert, Platform, View, ViewProps } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { AudioTrackSelector } from "./AudioTrackSelector"; import { AudioTrackSelector } from "./AudioTrackSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector"; import { Bitrate, BitrateSelector } from "./BitrateSelector";
@@ -66,10 +66,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1); const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] = const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0); useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({ const [maxBitrate, setMaxBitrate] = useState<Bitrate>(
key: "Max", settings?.defaultBitrate ?? {
value: undefined, key: "Max",
}); value: undefined,
}
);
const userCanDownload = useMemo( const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading, () => user?.Policy?.EnableContentDownloading,
@@ -162,7 +164,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
); );
} }
} else { } else {
toast.error(t("home.downloads.toasts.you_are_not_allowed_to_download_files")); toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files")
);
} }
}, [ }, [
queue, queue,
@@ -194,10 +198,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
for (const item of items) { for (const item of items) {
if (itemsNotDownloaded.length > 1) { if (itemsNotDownloaded.length > 1) {
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings( const defaults = getDefaultPlaySettings(item, settings!);
item, mediaSource = defaults.mediaSource;
settings! audioIndex = defaults.audioIndex;
)); subtitleIndex = defaults.subtitleIndex;
// Keep using the selected bitrate for consistency across all downloads
} }
const res = await getStreamUrl({ const res = await getStreamUrl({
@@ -332,7 +337,10 @@ export const DownloadItems: React.FC<DownloadProps> = ({
{title} {title}
</Text> </Text>
<Text className="text-neutral-300"> <Text className="text-neutral-300">
{subtitle || t("item_card.download.download_x_item", {item_count: itemsNotDownloaded.length})} {subtitle ||
t("item_card.download.download_x_item", {
item_count: itemsNotDownloaded.length,
})}
</Text> </Text>
</View> </View>
<View className="flex flex-col space-y-2 w-full items-start"> <View className="flex flex-col space-y-2 w-full items-start">
@@ -390,12 +398,16 @@ export const DownloadSingleItem: React.FC<{
size?: "default" | "large"; size?: "default" | "large";
item: BaseItemDto; item: BaseItemDto;
}> = ({ item, size = "default" }) => { }> = ({ item, size = "default" }) => {
if (Platform.isTV) return;
return ( return (
<DownloadItems <DownloadItems
size={size} size={size}
title={item.Type == "Episode" title={
? t("item_card.download.download_episode") item.Type == "Episode"
: t("item_card.download.download_movie")} ? t("item_card.download.download_episode")
: t("item_card.download.download_movie")
}
subtitle={item.Name!} subtitle={item.Name!}
items={[item]} items={[item]}
MissingDownloadIconComponent={() => ( MissingDownloadIconComponent={() => (

View File

@@ -3,6 +3,7 @@ import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { DownloadSingleItem } from "@/components/DownloadItem"; import { DownloadSingleItem } from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
import { PlayButton } from "@/components/PlayButton"; import { PlayButton } from "@/components/PlayButton";
import { PlayedStatus } from "@/components/PlayedStatus"; import { PlayedStatus } from "@/components/PlayedStatus";
import { SimilarItems } from "@/components/SimilarItems"; import { SimilarItems } from "@/components/SimilarItems";
@@ -14,8 +15,8 @@ import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarous
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors"; import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { import {
@@ -24,17 +25,16 @@ import {
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { 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 { Chromecast } from "./Chromecast"; 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 { AddToFavorites } from "./AddToFavorites"; const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
export type SelectedOptions = { export type SelectedOptions = {
bitrate: Bitrate; bitrate: Bitrate;
@@ -81,23 +81,31 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
defaultMediaSource, defaultMediaSource,
]); ]);
useEffect(() => { if (!Platform.isTV) {
navigation.setOptions({ useEffect(() => {
headerRight: () => navigation.setOptions({
item && ( headerRight: () =>
<View className="flex flex-row items-center space-x-2"> item && (
<Chromecast background="blur" width={22} height={22} /> <View className="flex flex-row items-center space-x-2">
{item.Type !== "Program" && ( <Chromecast.Chromecast
<View className="flex flex-row items-center space-x-2"> background="blur"
<DownloadSingleItem item={item} size="large" /> width={22}
<PlayedStatus item={item} /> height={22}
<AddToFavorites item={item} type="item" /> />
</View> {item.Type !== "Program" && (
)} <View className="flex flex-row items-center space-x-2">
</View> {!Platform.isTV && (
), <DownloadSingleItem item={item} size="large" />
}); )}
}, [item]); <PlayedStatus items={[item]} size="large" />
<AddToFavorites item={item} />
</View>
)}
</View>
),
});
}, [item]);
}
useEffect(() => { useEffect(() => {
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
@@ -111,37 +119,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
const loading = useMemo(() => { const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo); return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]); }, [loadingLogo, logoUrl]);
const [isTranscoding, setIsTranscoding] = useState(false);
const [previouslyChosenSubtitleIndex, setPreviouslyChosenSubtitleIndex] =
useState<number | undefined>(selectedOptions?.subtitleIndex);
useEffect(() => {
const isTranscoding = Boolean(selectedOptions?.bitrate.value);
if (isTranscoding) {
setPreviouslyChosenSubtitleIndex(selectedOptions?.subtitleIndex);
const subHelper = new SubtitleHelper(
selectedOptions?.mediaSource?.MediaStreams ?? []
);
const newSubtitleIndex = subHelper.getMostCommonSubtitleByName(
selectedOptions?.subtitleIndex
);
setSelectedOptions((prev) => ({
...prev!,
subtitleIndex: newSubtitleIndex ?? -1,
}));
}
if (!isTranscoding && previouslyChosenSubtitleIndex !== undefined) {
setSelectedOptions((prev) => ({
...prev!,
subtitleIndex: previouslyChosenSubtitleIndex,
}));
}
setIsTranscoding(isTranscoding);
}, [selectedOptions?.bitrate]);
if (!selectedOptions) return null; if (!selectedOptions) return null;
return ( return (
@@ -191,7 +168,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<View className="flex flex-col bg-transparent shrink"> <View className="flex flex-col bg-transparent shrink">
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink"> <View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
<ItemHeader item={item} className="mb-4" /> <ItemHeader item={item} className="mb-4" />
{item.Type !== "Program" && ( {item.Type !== "Program" && !Platform.isTV && (
<View className="flex flex-row items-center justify-start w-full h-16"> <View className="flex flex-row items-center justify-start w-full h-16">
<BitrateSelector <BitrateSelector
className="mr-1" className="mr-1"
@@ -231,7 +208,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
selected={selectedOptions.audioIndex} selected={selectedOptions.audioIndex}
/> />
<SubtitleTrackSelector <SubtitleTrackSelector
isTranscoding={isTranscoding}
source={selectedOptions.mediaSource} source={selectedOptions.mediaSource}
onChange={(val) => onChange={(val) =>
setSelectedOptions( setSelectedOptions(

View File

@@ -16,6 +16,7 @@ import {
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { Button } from "./Button"; import { Button } from "./Button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatBitrate } from "@/utils/bitrate";
interface Props { interface Props {
source?: MediaSourceInfo; source?: MediaSourceInfo;
@@ -54,14 +55,18 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
<BottomSheetScrollView> <BottomSheetScrollView>
<View className="flex flex-col space-y-2 p-4 mb-4"> <View className="flex flex-col space-y-2 p-4 mb-4">
<View className=""> <View className="">
<Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text> <Text className="text-lg font-bold mb-4">
{t("item_card.video")}
</Text>
<View className="flex flex-row space-x-2"> <View className="flex flex-row space-x-2">
<VideoStreamInfo source={source} /> <VideoStreamInfo source={source} />
</View> </View>
</View> </View>
<View className=""> <View className="">
<Text className="text-lg font-bold mb-2">{t("item_card.audio")}</Text> <Text className="text-lg font-bold mb-2">
{t("item_card.audio")}
</Text>
<AudioStreamInfo <AudioStreamInfo
audioStreams={ audioStreams={
source?.MediaStreams?.filter( source?.MediaStreams?.filter(
@@ -72,7 +77,9 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
</View> </View>
<View className=""> <View className="">
<Text className="text-lg font-bold mb-2">{t("item_card.subtitles")}</Text> <Text className="text-lg font-bold mb-2">
{t("item_card.subtitles")}
</Text>
<SubtitleStreamInfo <SubtitleStreamInfo
subtitleStreams={ subtitleStreams={
source?.MediaStreams?.filter( source?.MediaStreams?.filter(
@@ -229,12 +236,3 @@ const formatFileSize = (bytes?: number | null) => {
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString()); const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i]; return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
}; };
const formatBitrate = (bitrate?: number | null) => {
if (!bitrate) return "N/A";
const sizes = ["bps", "Kbps", "Mbps", "Gbps", "Tbps"];
if (bitrate === 0) return "0 bps";
const i = parseInt(Math.floor(Math.log(bitrate) / Math.log(1000)).toString());
return Math.round((bitrate / Math.pow(1000, i)) * 100) / 100 + " " + sizes[i];
};

View File

@@ -3,8 +3,8 @@ import {
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react"; import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -20,6 +20,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
selected, selected,
...props ...props
}) => { }) => {
if (Platform.isTV) return null;
const selectedName = useMemo( const selectedName = useMemo(
() => () =>
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find( item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
@@ -61,7 +62,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col" {...props}> <View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">{t("item_card.video")}</Text> <Text className="opacity-50 mb-1 text-xs">
{t("item_card.video")}
</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center"> <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
<Text numberOfLines={1}>{selectedName}</Text> <Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity> </TouchableOpacity>

View File

@@ -1,3 +1,4 @@
import { Platform, Pressable } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
@@ -31,7 +32,8 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Button } from "./Button"; import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent"; import { SelectedOptions } from "./ItemContent";
import { chromecastProfile } from "@/utils/profiles/chromecast"; import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
@@ -69,17 +71,14 @@ export const PlayButton: React.FC<Props> = ({
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback( const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => { (q: string) => {
if (!bitrateValue) { router.push(`/player/direct-player?${q}`);
router.push(`/player/direct-player?${q}`);
return;
}
router.push(`/player/transcoding-player?${q}`);
}, },
[router] [router]
); );
const onPress = useCallback(async () => { const onPress = useCallback(async () => {
console.log("onPress");
if (!item) return; if (!item) return;
lightHapticFeedback(); lightHapticFeedback();
@@ -95,7 +94,7 @@ export const PlayButton: React.FC<Props> = ({
const queryString = queryParams.toString(); const queryString = queryParams.toString();
if (!client) { if (!client) {
goToPlayer(queryString, selectedOptions.bitrate?.value); goToPlayer(queryString);
return; return;
} }
@@ -115,101 +114,110 @@ export const PlayButton: React.FC<Props> = ({
switch (selectedIndex) { switch (selectedIndex) {
case 0: case 0:
await CastContext.getPlayServicesState().then(async (state) => { await CastContext.getPlayServicesState().then(async (state) => {
if (state && state !== PlayServicesState.SUCCESS) if (state && state !== PlayServicesState.SUCCESS) {
CastContext.showPlayServicesErrorDialog(state); CastContext.showPlayServicesErrorDialog(state);
else { } else {
// Get a new URL with the Chromecast device profile: // Check if user wants H265 for Chromecast
const data = await getStreamUrl({ const enableH265 = settings.enableH265ForChromecast;
api,
item,
deviceProfile: chromecastProfile,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
if (!data?.url) { // Get a new URL with the Chromecast device profile
console.warn("No URL returned from getStreamUrl", data); try {
Alert.alert( const data = await getStreamUrl({
t("player.client_error"), api,
t("player.could_not_create_stream_for_chromecast") item,
); deviceProfile: enableH265 ? chromecasth265 : chromecast,
return; startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
} userId: user?.Id,
audioStreamIndex: selectedOptions.audioIndex,
client maxStreamingBitrate: selectedOptions.bitrate?.value,
.loadMedia({ mediaSourceId: selectedOptions.mediaSource?.Id,
mediaInfo: { subtitleStreamIndex: selectedOptions.subtitleIndex,
contentUrl: data?.url,
contentType: "video/mp4",
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
},
},
startTime: 0,
})
.then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
CastContext.showExpandedControls();
}); });
console.log("URL: ", data?.url, enableH265);
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast")
);
return;
}
client
.loadMedia({
mediaInfo: {
contentUrl: data?.url,
contentType: "video/mp4",
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
},
},
startTime: 0,
})
.then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
CastContext.showExpandedControls();
});
} catch (e) {
console.log(e);
}
} }
}); });
break; break;
case 1: case 1:
goToPlayer(queryString, selectedOptions.bitrate?.value); goToPlayer(queryString);
break; break;
case cancelButtonIndex: case cancelButtonIndex:
break; break;
@@ -318,75 +326,62 @@ export const PlayButton: React.FC<Props> = ({
*/ */
return ( return (
<View> <TouchableOpacity
<TouchableOpacity disabled={!item}
disabled={!item} accessibilityLabel="Play button"
accessibilityLabel="Play button" accessibilityHint="Tap to play the media"
accessibilityHint="Tap to play the media" onPress={onPress}
onPress={onPress} className={`relative`}
className={`relative`} {...props}
{...props} >
> <View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
<Animated.View
style={[
animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/>
</View>
<Animated.View <Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]} style={[
className="absolute w-full h-full top-0 left-0 rounded-xl" animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/> />
<View </View>
style={{
borderWidth: 1, <Animated.View
borderColor: colorAtom.primary, style={[animatedAverageStyle, { opacity: 0.5 }]}
borderStyle: "solid", className="absolute w-full h-full top-0 left-0 rounded-xl"
}} />
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full " <View
> style={{
<View className="flex flex-row items-center space-x-2"> borderWidth: 1,
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}> borderColor: colorAtom.primary,
{runtimeTicksToMinutes(item?.RunTimeTicks)} borderStyle: "solid",
</Animated.Text> }}
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
>
<View className="flex flex-row items-center space-x-2">
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Ionicons name="play-circle" size={24} />
</Animated.Text>
{client && (
<Animated.Text style={animatedTextStyle}> <Animated.Text style={animatedTextStyle}>
<Ionicons name="play-circle" size={24} /> <Feather name="cast" size={22} />
<CastButton tintColor="transparent" />
</Animated.Text> </Animated.Text>
{client && ( )}
<Animated.Text style={animatedTextStyle}> {!client && settings?.openInVLC && (
<Feather name="cast" size={22} /> <Animated.Text style={animatedTextStyle}>
<CastButton tintColor="transparent" /> <MaterialCommunityIcons
</Animated.Text> name="vlc"
)} size={18}
{!client && settings?.openInVLC && ( color={animatedTextStyle.color}
<Animated.Text style={animatedTextStyle}> />
<MaterialCommunityIcons </Animated.Text>
name="vlc" )}
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</View> </View>
</TouchableOpacity> </View>
{/* <View className="mt-2 flex flex-row items-center"> </TouchableOpacity>
<Ionicons
name="information-circle"
size={12}
className=""
color={"#9BA1A6"}
/>
<Text className="text-neutral-500 ml-1">
{directStream ? "Direct stream" : "Transcoded stream"}
</Text>
</View> */}
</View>
); );
}; };

View File

@@ -0,0 +1,226 @@
import { Platform } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
import { Alert, TouchableOpacity, View } from "react-native";
import Animated, {
Easing,
interpolate,
interpolateColor,
useAnimatedReaction,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent";
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
selectedOptions: SelectedOptions;
}
const ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({
item,
selectedOptions,
...props
}: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
const { t } = useTranslation();
const [colorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const router = useRouter();
const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0);
const endColor = useSharedValue(colorAtom);
const startColor = useSharedValue(colorAtom);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const [settings] = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
(q: string) => {
router.push(`/player/direct-player?${q}`);
},
[router]
);
const onPress = () => {
console.log("onpress");
if (!item) return;
lightHapticFeedback();
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
});
const queryString = queryParams.toString();
goToPlayer(queryString);
return;
};
const derivedTargetWidth = useDerivedValue(() => {
if (!item || !item.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData && userData.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0
? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH
)
: 0;
}
return 0;
}, [item]);
useAnimatedReaction(
() => derivedTargetWidth.value,
(newWidth) => {
targetWidth.value = newWidth;
widthProgress.value = 0;
widthProgress.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
});
},
[item]
);
useAnimatedReaction(
() => colorAtom,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
colorChangeProgress.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
[colorAtom]
);
useEffect(() => {
const timeout_2 = setTimeout(() => {
startColor.value = colorAtom;
startWidth.value = targetWidth.value;
}, ANIMATION_DURATION);
return () => {
clearTimeout(timeout_2);
};
}, [colorAtom, item]);
/**
* ANIMATED STYLES
*/
const animatedAverageStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.primary, endColor.value.primary]
),
}));
const animatedPrimaryStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.primary, endColor.value.primary]
),
}));
const animatedWidthStyle = useAnimatedStyle(() => ({
width: `${interpolate(
widthProgress.value,
[0, 1],
[startWidth.value, targetWidth.value]
)}%`,
}));
const animatedTextStyle = useAnimatedStyle(() => ({
color: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.text, endColor.value.text]
),
}));
/**
* *********************
*/
return (
<TouchableOpacity
accessibilityLabel="Play button"
accessibilityHint="Tap to play the media"
onPress={onPress}
className={`relative`}
{...props}
>
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
<Animated.View
style={[
animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/>
</View>
<Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]}
className="absolute w-full h-full top-0 left-0 rounded-xl"
/>
<View
style={{
borderWidth: 1,
borderColor: colorAtom.primary,
borderStyle: "solid",
}}
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
>
<View className="flex flex-row items-center space-x-2">
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Ionicons name="play-circle" size={24} />
</Animated.Text>
{settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name="vlc"
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</View>
</TouchableOpacity>
);
};

View File

@@ -6,16 +6,19 @@ import { View, ViewProps } from "react-native";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
interface Props extends ViewProps { interface Props extends ViewProps {
item: BaseItemDto; items: BaseItemDto[];
size?: "default" | "large";
} }
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => { export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidateQueries = () => { const invalidateQueries = () => {
queryClient.invalidateQueries({ items.forEach((item) => {
queryKey: ["item", item.Id], queryClient.invalidateQueries({
}); queryKey: ["item", item.Id],
});
})
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["resumeItems"], queryKey: ["resumeItems"],
}); });
@@ -39,15 +42,20 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
}); });
}; };
const markAsPlayedStatus = useMarkAsPlayed(item); const allPlayed = items.every((item) => item.UserData?.Played);
const markAsPlayedStatus = useMarkAsPlayed(items);
return ( return (
<View {...props}> <View {...props}>
<RoundButton <RoundButton
fillColor={item.UserData?.Played ? "primary" : undefined} fillColor={allPlayed ? "primary" : undefined}
icon={item.UserData?.Played ? "checkmark" : "checkmark"} icon={allPlayed ? "checkmark" : "checkmark"}
onPress={() => markAsPlayedStatus(item.UserData?.Played || false)} onPress={async () => {
size="large" console.log(allPlayed);
await markAsPlayedStatus(!allPlayed)
}}
size={props.size}
/> />
</View> </View>
); );

View File

@@ -2,41 +2,33 @@ import { tc } from "@/utils/textTools";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react"; import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo; source?: MediaSourceInfo;
onChange: (value: number) => void; onChange: (value: number) => void;
selected?: number | undefined; selected?: number | undefined;
isTranscoding?: boolean;
} }
export const SubtitleTrackSelector: React.FC<Props> = ({ export const SubtitleTrackSelector: React.FC<Props> = ({
source, source,
onChange, onChange,
selected, selected,
isTranscoding,
...props ...props
}) => { }) => {
if (Platform.isTV) return null;
const subtitleStreams = useMemo(() => { const subtitleStreams = useMemo(() => {
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []); return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
}, [source]);
if (isTranscoding && Platform.OS === "ios") {
return subtitleHelper.getUniqueSubtitles();
}
return subtitleHelper.getSubtitles();
}, [source, isTranscoding]);
const selectedSubtitleSteam = useMemo( const selectedSubtitleSteam = useMemo(
() => subtitleStreams.find((x) => x.Index === selected), () => subtitleStreams?.find((x) => x.Index === selected),
[subtitleStreams, selected] [subtitleStreams, selected]
); );
if (subtitleStreams.length === 0) return null; if (subtitleStreams?.length === 0) return null;
const { t } = useTranslation(); const { t } = useTranslation();
@@ -51,7 +43,9 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col " {...props}> <View className="flex flex-col " {...props}>
<Text className="opacity-50 mb-1 text-xs">{t("item_card.subtitles")}</Text> <Text numberOfLines={1} className="opacity-50 mb-1 text-xs">
{t("item_card.subtitles")}
</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"> <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text className=" "> <Text className=" ">
{selectedSubtitleSteam {selectedSubtitleSteam

View File

@@ -1,22 +1,27 @@
import * as DropdownMenu from "zeego/dropdown-menu"; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import {TouchableOpacity, View, ViewProps} from "react-native"; import { Platform, TouchableOpacity, View, ViewProps } from "react-native";
import {Text} from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import React, {PropsWithChildren, ReactNode, useEffect, useState} from "react"; import React, {
PropsWithChildren,
ReactNode,
useEffect,
useState,
} from "react";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
interface Props<T> { interface Props<T> {
data: T[] data: T[];
disabled?: boolean disabled?: boolean;
placeholderText?: string, placeholderText?: string;
keyExtractor: (item: T) => string keyExtractor: (item: T) => string;
titleExtractor: (item: T) => string | undefined titleExtractor: (item: T) => string | undefined;
title: string | ReactNode, title: string | ReactNode;
label: string, label: string;
onSelected: (...item: T[]) => void onSelected: (...item: T[]) => void;
multi?: boolean multi?: boolean;
} }
const Dropdown = <T extends unknown>({ const Dropdown = <T extends unknown>({
data, data,
disabled, disabled,
placeholderText, placeholderText,
@@ -28,38 +33,32 @@ const Dropdown = <T extends unknown>({
multi = false, multi = false,
...props ...props
}: PropsWithChildren<Props<T> & ViewProps>) => { }: PropsWithChildren<Props<T> & ViewProps>) => {
if (Platform.isTV) return null;
const [selected, setSelected] = useState<T[]>(); const [selected, setSelected] = useState<T[]>();
useEffect(() => { useEffect(() => {
if (selected !== undefined) { if (selected !== undefined) {
onSelected(...selected) onSelected(...selected);
} }
}, [selected]); }, [selected]);
return ( return (
<DisabledSetting <DisabledSetting disabled={disabled === true} showText={false} {...props}>
disabled={disabled === true}
showText={false}
{...props}
>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
{typeof title === 'string' ? ( {typeof title === "string" ? (
<View className="flex flex-col"> <View className="flex flex-col">
<Text className="opacity-50 mb-1 text-xs"> <Text className="opacity-50 mb-1 text-xs">{title}</Text>
{title} <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
</Text>
<TouchableOpacity
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}> <Text style={{}} className="" numberOfLines={1}>
{selected?.length !== undefined ? selected.map(titleExtractor).join(",") : placeholderText} {selected?.length !== undefined
? selected.map(titleExtractor).join(",")
: placeholderText}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : ( ) : (
<> <>{title}</>
{title}
</>
)} )}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
@@ -72,37 +71,48 @@ const Dropdown = <T extends unknown>({
sideOffset={0} sideOffset={0}
> >
<DropdownMenu.Label>{label}</DropdownMenu.Label> <DropdownMenu.Label>{label}</DropdownMenu.Label>
{data.map((item, idx) => ( {data.map((item, idx) =>
multi ? ( multi ? (
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
value={selected?.some(s => keyExtractor(s) == keyExtractor(item)) ? 'on' : 'off'} value={
key={keyExtractor(item)} selected?.some((s) => keyExtractor(s) == keyExtractor(item))
onValueChange={(next, previous) => ? "on"
setSelected((p) => { : "off"
const prev = p || [] }
if (next == 'on') { key={keyExtractor(item)}
return [...prev, item] onValueChange={(next, previous) =>
} setSelected((p) => {
return [...prev.filter(p => keyExtractor(p) !== keyExtractor(item))] const prev = p || [];
}) if (next == "on") {
} return [...prev, item];
> }
<DropdownMenu.ItemTitle>{titleExtractor(item)}</DropdownMenu.ItemTitle> return [
</DropdownMenu.CheckboxItem> ...prev.filter(
) (p) => keyExtractor(p) !== keyExtractor(item)
: ( ),
<DropdownMenu.Item ];
key={keyExtractor(item)} })
onSelect={() => setSelected([item])} }
> >
<DropdownMenu.ItemTitle>{titleExtractor(item)}</DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>
</DropdownMenu.Item> {titleExtractor(item)}
) </DropdownMenu.ItemTitle>
))} </DropdownMenu.CheckboxItem>
) : (
<DropdownMenu.Item
key={keyExtractor(item)}
onSelect={() => setSelected([item])}
>
<DropdownMenu.ItemTitle>
{titleExtractor(item)}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
)
)}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
</DisabledSetting> </DisabledSetting>
) );
}; };
export default Dropdown; export default Dropdown;

View File

@@ -1,10 +1,24 @@
import React from "react"; import React from "react";
import { TextInput, TextInputProps } from "react-native"; import {Platform, TextInput, TextInputProps, TouchableOpacity} from "react-native";
export function Input(props: TextInputProps) { export function Input(props: TextInputProps) {
const { style, ...otherProps } = props; const { style, ...otherProps } = props;
const inputRef = React.useRef<TextInput>(null); const inputRef = React.useRef<TextInput>(null);
return ( return Platform.isTV ? (
<TouchableOpacity
onFocus={() => inputRef?.current?.focus?.()}
>
<TextInput
ref={inputRef}
className="p-4 rounded-xl bg-neutral-900"
allowFontScaling={false}
style={[{ color: "white" }, style]}
placeholderTextColor={"#9CA3AF"}
clearButtonMode="while-editing"
{...otherProps}
/>
</TouchableOpacity>
) : (
<TextInput <TextInput
ref={inputRef} ref={inputRef}
className="p-4 rounded-xl bg-neutral-900" className="p-4 rounded-xl bg-neutral-900"
@@ -14,5 +28,5 @@ export function Input(props: TextInputProps) {
clearButtonMode="while-editing" clearButtonMode="while-editing"
{...otherProps} {...otherProps}
/> />
); )
} }

View File

@@ -1,11 +1,14 @@
import {useRouter, useSegments} from "expo-router"; import { useRouter, useSegments } from "expo-router";
import React, {PropsWithChildren, useCallback, useMemo} from "react"; import React, { PropsWithChildren, useCallback, useMemo } from "react";
import {TouchableOpacity, TouchableOpacityProps} from "react-native"; import { TouchableOpacity, TouchableOpacityProps } from "react-native";
import * as ContextMenu from "zeego/context-menu"; import * as ContextMenu from "@/components/ContextMenu";
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search"; import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import {useJellyseerr} from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions"; import {
import {MediaType} from "@/utils/jellyseerr/server/constants/media"; hasPermission,
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
result: MovieResult | TvResult; result: MovieResult | TvResult;
@@ -26,26 +29,27 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
}) => { }) => {
const router = useRouter(); const router = useRouter();
const segments = useSegments(); const segments = useSegments();
const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr() const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const from = segments[2]; const from = segments[2];
const autoApprove = useMemo(() => { const autoApprove = useMemo(() => {
return jellyseerrUser && hasPermission( return (
Permission.AUTO_APPROVE, jellyseerrUser &&
jellyseerrUser.permissions, hasPermission(Permission.AUTO_APPROVE, jellyseerrUser.permissions, {
{type: 'or'} type: "or",
) })
}, [jellyseerrApi, jellyseerrUser]) );
}, [jellyseerrApi, jellyseerrUser]);
const request = useCallback(() => const request = useCallback(
() =>
requestMedia(mediaTitle, { requestMedia(mediaTitle, {
mediaId: result.id, mediaId: result.id,
mediaType: result.mediaType mediaType: result.mediaType,
} }),
),
[jellyseerrApi, result] [jellyseerrApi, result]
) );
if (from === "(home)" || from === "(search)" || from === "(libraries)") if (from === "(home)" || from === "(search)" || from === "(libraries)")
return ( return (
@@ -55,7 +59,16 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
// @ts-ignore // @ts-ignore
router.push({pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, params: {...result, mediaTitle, releaseYear, canRequest, posterSrc}}); router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
params: {
...result,
mediaTitle,
releaseYear,
canRequest,
posterSrc,
},
});
}} }}
{...props} {...props}
> >
@@ -71,31 +84,33 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
> >
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label> <ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
{canRequest && result.mediaType === MediaType.MOVIE && ( {canRequest && result.mediaType === MediaType.MOVIE && (
<ContextMenu.Item <ContextMenu.Item
key="item-1" key="item-1"
onSelect={() => { onSelect={() => {
if (autoApprove) { if (autoApprove) {
request() request();
} }
}}
shouldDismissMenuOnSelect
>
<ContextMenu.ItemTitle key="item-1-title">
Request
</ContextMenu.ItemTitle>
<ContextMenu.ItemIcon
ios={{
name: "arrow.down.to.line",
pointSize: 18,
weight: "semibold",
scale: "medium",
hierarchicalColor: {
dark: "purple",
light: "purple",
},
}} }}
shouldDismissMenuOnSelect androidIconName="download"
> />
<ContextMenu.ItemTitle key="item-1-title">Request</ContextMenu.ItemTitle> </ContextMenu.Item>
<ContextMenu.ItemIcon )}
ios={{
name: "arrow.down.to.line",
pointSize: 18,
weight: "semibold",
scale: "medium",
hierarchicalColor: {
dark: "purple",
light: "purple",
},
}}
androidIconName="download"
/>
</ContextMenu.Item>
)}
</ContextMenu.Content> </ContextMenu.Content>
</ContextMenu.Root> </ContextMenu.Root>
</> </>

View File

@@ -1,19 +1,27 @@
import React from "react"; import React from "react";
import { TextProps } from "react-native"; import { Platform, TextProps } from "react-native";
import { UITextView } from "react-native-uitextview"; import { UITextView } from "react-native-uitextview";
import { Text as RNText } from "react-native";
export function Text( export function Text(
props: TextProps & { props: TextProps & {
uiTextView?: boolean; uiTextView?: boolean;
} }
) { ) {
const { style, ...otherProps } = props; const { style, ...otherProps } = props;
if (Platform.isTV)
return ( return (
<UITextView <RNText
allowFontScaling={false} allowFontScaling={false}
style={[{ color: "white" }, style]} style={[{ color: "white" }, style]}
{...otherProps} {...otherProps}
/> />
); );
else
return (
<UITextView
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}
/>
);
} }

View File

@@ -1,4 +1,5 @@
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { useFavorite } from "@/hooks/useFavorite";
import { import {
BaseItemDto, BaseItemDto,
BaseItemPerson, BaseItemPerson,
@@ -6,9 +7,7 @@ import {
import { useRouter, useSegments } from "expo-router"; import { useRouter, useSegments } from "expo-router";
import { PropsWithChildren, useCallback } from "react"; import { PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, TouchableOpacityProps } from "react-native"; import { TouchableOpacity, TouchableOpacityProps } from "react-native";
import * as ContextMenu from "zeego/context-menu";
import { useActionSheet } from "@expo/react-native-action-sheet"; import { useActionSheet } from "@expo/react-native-action-sheet";
import * as Haptics from "expo-haptics";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
item: BaseItemDto; item: BaseItemDto;
@@ -57,15 +56,15 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
const router = useRouter(); const router = useRouter();
const segments = useSegments(); const segments = useSegments();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed(item); const markAsPlayedStatus = useMarkAsPlayed([item]);
const { isFavorite, toggleFavorite } = useFavorite(item);
const from = segments[2]; const from = segments[2];
const showActionSheet = useCallback(() => { const showActionSheet = useCallback(() => {
if (!(item.Type === "Movie" || item.Type === "Episode")) return; if (!(item.Type === "Movie" || item.Type === "Episode" || item.Type === "Series")) return;
const options = ["Mark as Played", "Mark as Not Played", isFavorite ? "Unmark as Favorite" : "Mark as Favorite", "Cancel"];
const options = ["Mark as Played", "Mark as Not Played", "Cancel"]; const cancelButtonIndex = 3;
const cancelButtonIndex = 2;
showActionSheetWithOptions( showActionSheetWithOptions(
{ {
@@ -75,14 +74,14 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
async (selectedIndex) => { async (selectedIndex) => {
if (selectedIndex === 0) { if (selectedIndex === 0) {
await markAsPlayedStatus(true); await markAsPlayedStatus(true);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} else if (selectedIndex === 1) { } else if (selectedIndex === 1) {
await markAsPlayedStatus(false); await markAsPlayedStatus(false);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } else if (selectedIndex === 2) {
toggleFavorite()
} }
} }
); );
}, [showActionSheetWithOptions, markAsPlayedStatus]); }, [showActionSheetWithOptions, isFavorite, markAsPlayedStatus]);
if ( if (
from === "(home)" || from === "(home)" ||

View File

@@ -1,15 +1,18 @@
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import {DownloadMethod, useSettings} from "@/utils/atoms/settings"; import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { storage } from "@/utils/mmkv";
import { JobStatus } from "@/utils/optimize-server"; import { JobStatus } from "@/utils/optimize-server";
import { formatTimeString } from "@/utils/time"; import { formatTimeString } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { FFmpegKit } from "ffmpeg-kit-react-native"; import { t } from "i18next";
import { useMemo } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Platform,
TouchableOpacity, TouchableOpacity,
TouchableOpacityProps, TouchableOpacityProps,
View, View,
@@ -17,10 +20,12 @@ import {
} from "react-native"; } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Button } from "../Button"; import { Button } from "../Button";
import { Image } from "expo-image"; const BackGroundDownloader = !Platform.isTV
import { useMemo } from "react"; ? require("@kesha-antonov/react-native-background-downloader")
import { storage } from "@/utils/mmkv"; : null;
import { t } from "i18next"; const FFmpegKitProvider = !Platform.isTV
? require("ffmpeg-kit-react-native")
: null;
interface Props extends ViewProps {} interface Props extends ViewProps {}
@@ -29,16 +34,22 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
if (processes?.length === 0) if (processes?.length === 0)
return ( return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl"> <View {...props} className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">{t("home.downloads.active_download")}</Text> <Text className="text-lg font-bold">
<Text className="opacity-50">{t("home.downloads.no_active_downloads")}</Text> {t("home.downloads.active_download")}
</Text>
<Text className="opacity-50">
{t("home.downloads.no_active_downloads")}
</Text>
</View> </View>
); );
return ( return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl"> <View {...props} className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold mb-2">{t("home.downloads.active_downloads")}</Text> <Text className="text-lg font-bold mb-2">
{t("home.downloads.active_downloads")}
</Text>
<View className="space-y-2"> <View className="space-y-2">
{processes?.map((p) => ( {processes?.map((p: JobStatus) => (
<DownloadCard key={p.item.Id} process={p} /> <DownloadCard key={p.item.Id} process={p} />
))} ))}
</View> </View>
@@ -63,7 +74,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
if (settings?.downloadMethod === DownloadMethod.Optimized) { if (settings?.downloadMethod === DownloadMethod.Optimized) {
try { try {
const tasks = await checkForExistingDownloads(); const tasks = await BackGroundDownloader.checkForExistingDownloads();
for (const task of tasks) { for (const task of tasks) {
if (task.id === id) { if (task.id === id) {
task.stop(); task.stop();
@@ -76,8 +87,10 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
await queryClient.refetchQueries({ queryKey: ["jobs"] }); await queryClient.refetchQueries({ queryKey: ["jobs"] });
} }
} else { } else {
FFmpegKit.cancel(Number(id)); FFmpegKitProvider.FFmpegKit.cancel(Number(id));
setProcesses((prev) => prev.filter((p) => p.id !== id)); setProcesses((prev: any[]) =>
prev.filter((p: { id: string }) => p.id !== id)
);
} }
}, },
onSuccess: () => { onSuccess: () => {
@@ -152,7 +165,9 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text> <Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
)} )}
{eta(process) && ( {eta(process) && (
<Text className="text-xs">{t("home.downloads.eta", {eta: eta(process)})}</Text> <Text className="text-xs">
{t("home.downloads.eta", { eta: eta(process) })}
</Text>
)} )}
</View> </View>

View File

@@ -19,7 +19,7 @@ interface Release {
type: number; type: number;
} }
const dateOpts: Intl.DateTimeFormatOptions = { export const dateOpts: Intl.DateTimeFormatOptions = {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
@@ -50,18 +50,9 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
const DetailFacts: React.FC< const DetailFacts: React.FC<
{ details?: MovieDetails | TvDetails } & ViewProps { details?: MovieDetails | TvDetails } & ViewProps
> = ({ details, className, ...props }) => { > = ({ details, className, ...props }) => {
const { jellyseerrUser } = useJellyseerr(); const { jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
const { t } = useTranslation(); const { t } = useTranslation();
const locale = useMemo(() => {
return jellyseerrUser?.settings?.locale || "en";
}, [jellyseerrUser]);
const region = useMemo(
() => jellyseerrUser?.settings?.region || "US",
[jellyseerrUser]
);
const releases = useMemo( const releases = useMemo(
() => () =>
(details as MovieDetails)?.releases?.results.find( (details as MovieDetails)?.releases?.results.find(

View File

@@ -48,7 +48,7 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
className="w-28 rounded-lg overflow-hidden border border-neutral-900" className="w-28 rounded-lg overflow-hidden border border-neutral-900"
id={item.id.toString()} id={item.id.toString()}
title={item.name} title={item.name}
colors={[]} colors={['transparent', 'transparent']}
contentFit={"cover"} contentFit={"cover"}
url={jellyseerrApi?.imageProxy( url={jellyseerrApi?.imageProxy(
item.backdrops?.[0], item.backdrops?.[0],

View File

@@ -29,7 +29,7 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
</Text> </Text>
<View <View
style={[]} style={[]}
className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900" className="flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900"
> >
{Children.map(childrenArray, (child, index) => { {Children.map(childrenArray, (child, index) => {
if (isValidElement<{ style?: ViewStyle }>(child)) { if (isValidElement<{ style?: ViewStyle }>(child)) {

View File

@@ -36,7 +36,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity <TouchableOpacity
disabled={disabled} disabled={disabled}
onPress={onPress} onPress={onPress}
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 ${ className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 pl-4 ${
disabled ? "opacity-50" : "" disabled ? "opacity-50" : ""
}`} }`}
{...props} {...props}
@@ -55,7 +55,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
); );
return ( return (
<View <View
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 ${ className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 pl-4 ${
disabled ? "opacity-50" : "" disabled ? "opacity-50" : ""
}`} }`}
{...props} {...props}

View File

@@ -23,6 +23,8 @@ import { Loader } from "../Loader";
import { t } from "i18next"; import { t } from "i18next";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie"; import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
import {dateOpts} from "@/components/jellyseerr/DetailFacts";
const JellyseerrSeasonEpisodes: React.FC<{ const JellyseerrSeasonEpisodes: React.FC<{
details: TvDetails; details: TvDetails;
@@ -52,26 +54,51 @@ const JellyseerrSeasonEpisodes: React.FC<{
}; };
const RenderItem = ({ item, index }: any) => { const RenderItem = ({ item, index }: any) => {
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const upcomingAirDate = useMemo(() => {
const airDate = item.airDate;
if (airDate) {
let airDateObj = new Date(airDate);
if (new Date() < airDateObj) {
return airDateObj.toLocaleDateString(
`${locale}-${region}`,
dateOpts
);
}
}
}, [item]);
return ( return (
<View className="flex flex-col w-44 mt-2"> <View className="flex flex-col w-44 mt-2">
<View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800"> <View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
{!imageError ? ( {!imageError ? (
<Image <>
key={item.id} <Image
id={item.id} key={item.id}
source={{ id={item.id}
uri: jellyseerrApi?.imageProxy(item.stillPath), source={{
}} uri: jellyseerrApi?.imageProxy(item.stillPath),
cachePolicy={"memory-disk"} }}
contentFit="cover" cachePolicy={"memory-disk"}
className="w-full h-full" contentFit="cover"
onError={(e) => { className="w-full h-full"
setImageError(true); onError={(e) => {
}} setImageError(true);
/> }}
/>
{upcomingAirDate && (
<View className="absolute justify-center bottom-0 right-0.5 items-center">
<View className="rounded-full bg-purple-600/30 p-1">
<Text className="text-center text-xs" style={textShadowStyle.shadow}>
{upcomingAirDate}
</Text>
</View>
</View>
)}
</>
) : ( ) : (
<View className="flex flex-col w-full h-full items-center justify-center border border-neutral-800 bg-neutral-900"> <View className="flex flex-col w-full h-full items-center justify-center border border-neutral-800 bg-neutral-900">
<Ionicons <Ionicons

View File

@@ -1,7 +1,7 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { t } from "i18next"; import { t } from "i18next";
@@ -30,6 +30,8 @@ export const SeasonDropdown: React.FC<Props> = ({
state, state,
onSelect, onSelect,
}) => { }) => {
if (Platform.isTV) return null;
const keys = useMemo<SeasonKeys>( const keys = useMemo<SeasonKeys>(
() => () =>
item.Type === "Episode" item.Type === "Episode"
@@ -92,7 +94,9 @@ export const SeasonDropdown: React.FC<Props> = ({
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-row"> <View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"> <TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>{t("item_card.season")} {seasonIndex}</Text> <Text>
{t("item_card.season")} {seasonIndex}
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>

View File

@@ -17,7 +17,9 @@ import {
SeasonIndexState, SeasonIndexState,
} from "@/components/series/SeasonDropdown"; } from "@/components/series/SeasonDropdown";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { PlayedStatus } from "../PlayedStatus";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type Props = { type Props = {
item: BaseItemDto; item: BaseItemDto;
initialSeasonIndex?: number; initialSeasonIndex?: number;
@@ -145,17 +147,20 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
}} }}
/> />
{episodes?.length || 0 > 0 ? ( {episodes?.length || 0 > 0 ? (
<DownloadItems <View className="flex flex-row items-center space-x-2">
title={t("item_card.download.download_season")} <DownloadItems
className="ml-2" title={t("item_card.download.download_season")}
items={episodes || []} className="ml-2"
MissingDownloadIconComponent={() => ( items={episodes || []}
<Ionicons name="download" size={20} color="white" /> MissingDownloadIconComponent={() => (
)} <Ionicons name="download" size={20} color="white" />
DownloadedIconComponent={() => ( )}
<Ionicons name="download" size={20} color="#9333ea" /> DownloadedIconComponent={() => (
)} <Ionicons name="download" size={20} color="#9333ea" />
/> )}
/>
<PlayedStatus items={episodes || []} />
</View>
) : null} ) : null}
</View> </View>
<View className="px-4 flex flex-col mt-4"> <View className="px-4 flex flex-col mt-4">

View File

@@ -1,5 +1,5 @@
import * as DropdownMenu from "zeego/dropdown-menu"; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { TouchableOpacity, View, ViewProps } from "react-native"; import { Platform, TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
@@ -10,6 +10,7 @@ import { APP_LANGUAGES } from "@/i18n";
interface Props extends ViewProps {} interface Props extends ViewProps {}
export const AppLanguageSelector: React.FC<Props> = ({ ...props }) => { export const AppLanguageSelector: React.FC<Props> = ({ ...props }) => {
if (Platform.isTV) return null;
const [settings, updateSettings] = useSettings(); const [settings, updateSettings] = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -17,60 +18,58 @@ export const AppLanguageSelector: React.FC<Props> = ({ ...props }) => {
return ( return (
<View> <View>
<ListGroup <ListGroup title={t("home.settings.languages.title")}>
title={t("home.settings.languages.title")}
>
<ListItem title={t("home.settings.languages.app_language")}> <ListItem title={t("home.settings.languages.app_language")}>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"> <TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text> <Text>
{APP_LANGUAGES.find( {APP_LANGUAGES.find(
(l) => l.value === settings?.preferedLanguage (l) => l.value === settings?.preferedLanguage
)?.label || t("home.settings.languages.system")} )?.label || t("home.settings.languages.system")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
loop={true} loop={true}
side="bottom" side="bottom"
align="start" align="start"
alignOffset={0} alignOffset={0}
avoidCollisions={true} avoidCollisions={true}
collisionPadding={8} collisionPadding={8}
sideOffset={8} sideOffset={8}
>
<DropdownMenu.Label>
{t("home.settings.languages.title")}
</DropdownMenu.Label>
<DropdownMenu.Item
key={"unknown"}
onSelect={() => {
updateSettings({
preferedLanguage: undefined,
});
}}
> >
<DropdownMenu.ItemTitle> <DropdownMenu.Label>
{t("home.settings.languages.system")} {t("home.settings.languages.title")}
</DropdownMenu.ItemTitle> </DropdownMenu.Label>
</DropdownMenu.Item>
{APP_LANGUAGES?.map((l) => (
<DropdownMenu.Item <DropdownMenu.Item
key={l?.value ?? "unknown"} key={"unknown"}
onSelect={() => { onSelect={() => {
updateSettings({ updateSettings({
preferedLanguage: l.value, preferedLanguage: undefined,
}); });
}} }}
> >
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>
{t("home.settings.languages.system")}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item> </DropdownMenu.Item>
))} {APP_LANGUAGES?.map((l) => (
</DropdownMenu.Content> <DropdownMenu.Item
</DropdownMenu.Root> key={l?.value ?? "unknown"}
</ListItem> onSelect={() => {
</ListGroup> updateSettings({
</View> preferedLanguage: l.value,
});
}}
>
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem>
</ListGroup>
</View>
); );
}; };

View File

@@ -1,5 +1,5 @@
import { TouchableOpacity, View, ViewProps } from "react-native"; import { Platform, TouchableOpacity, View, ViewProps } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { useMedia } from "./MediaContext"; import { useMedia } from "./MediaContext";
import { Switch } from "react-native-gesture-handler"; import { Switch } from "react-native-gesture-handler";
@@ -7,11 +7,12 @@ import { useTranslation } from "react-i18next";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import {useSettings} from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
interface Props extends ViewProps {} interface Props extends ViewProps {}
export const AudioToggles: React.FC<Props> = ({ ...props }) => { export const AudioToggles: React.FC<Props> = ({ ...props }) => {
if (Platform.isTV) return null;
const media = useMedia(); const media = useMedia();
const [_, __, pluginSettings] = useSettings(); const [_, __, pluginSettings] = useSettings();
const { settings, updateSettings } = media; const { settings, updateSettings } = media;
@@ -47,7 +48,8 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3 "> <TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3 ">
<Text className="mr-1 text-[#8E8D91]"> <Text className="mr-1 text-[#8E8D91]">
{settings?.defaultAudioLanguage?.DisplayName || t("home.settings.audio.none")} {settings?.defaultAudioLanguage?.DisplayName ||
t("home.settings.audio.none")}
</Text> </Text>
<Ionicons <Ionicons
name="chevron-expand-sharp" name="chevron-expand-sharp"
@@ -65,7 +67,9 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
collisionPadding={8} collisionPadding={8}
sideOffset={8} sideOffset={8}
> >
<DropdownMenu.Label>{t("home.settings.audio.language")}</DropdownMenu.Label> <DropdownMenu.Label>
{t("home.settings.audio.language")}
</DropdownMenu.Label>
<DropdownMenu.Item <DropdownMenu.Item
key={"none-audio"} key={"none-audio"}
onSelect={() => { onSelect={() => {
@@ -74,7 +78,9 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
}); });
}} }}
> >
<DropdownMenu.ItemTitle>{t("home.settings.audio.none")}</DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>
{t("home.settings.audio.none")}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item> </DropdownMenu.Item>
{cultures?.map((l) => ( {cultures?.map((l) => (
<DropdownMenu.Item <DropdownMenu.Item

View File

@@ -0,0 +1,22 @@
import { Switch, View } from "react-native";
import { ListGroup } from "../list/ListGroup";
import { useSettings } from "@/utils/atoms/settings";
import { ListItem } from "../list/ListItem";
export const ChromecastSettings: React.FC = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
return (
<View {...props}>
<ListGroup title={"Chromecast"}>
<ListItem title={"Enable H265 for Chromecast"}>
<Switch
value={settings.enableH265ForChromecast}
onValueChange={(enableH265ForChromecast) =>
updateSettings({ enableH265ForChromecast })
}
/>
</ListItem>
</ListGroup>
</View>
);
};

View File

@@ -0,0 +1,30 @@
import { useSettings } from "@/utils/atoms/settings";
import { useRouter } from "expo-router";
import React from "react";
import { View } from "react-native";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
export const Dashboard = () => {
const [settings, updateSettings] = useSettings();
const { sessions = [], isLoading } = useSessions({} as useSessionsProps);
const router = useRouter();
const { t } = useTranslation();
if (!settings) return null;
return (
<View>
<ListGroup title={t("home.settings.dashboard.title")} className="mt-4">
<ListItem
className={sessions.length != 0 ? "bg-purple-900" : ""}
onPress={() => router.push("/settings/dashboard/sessions")}
title={t("home.settings.dashboard.sessions_title")}
showArrow
/>
</ListGroup>
</View>
);
};

View File

@@ -5,15 +5,15 @@ import { Ionicons } from "@expo/vector-icons";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { Switch, TouchableOpacity } from "react-native"; import { Platform, Switch, TouchableOpacity } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "../common/Text"; 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";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
export const DownloadSettings: React.FC = ({ ...props }) => { export default function DownloadSettings({ ...props }) {
const [settings, updateSettings, pluginSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
const { setProcesses } = useDownload(); const { setProcesses } = useDownload();
const router = useRouter(); const router = useRouter();
@@ -61,7 +61,9 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
collisionPadding={8} collisionPadding={8}
sideOffset={8} sideOffset={8}
> >
<DropdownMenu.Label>{t("home.settings.downloads.methods")}</DropdownMenu.Label> <DropdownMenu.Label>
{t("home.settings.downloads.download_method")}
</DropdownMenu.Label>
<DropdownMenu.Item <DropdownMenu.Item
key="1" key="1"
onSelect={() => { onSelect={() => {
@@ -69,7 +71,9 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
setProcesses([]); setProcesses([]);
}} }}
> >
<DropdownMenu.ItemTitle>{t("home.settings.downloads.default")}</DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>
{t("home.settings.downloads.default")}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
key="2" key="2"
@@ -79,7 +83,9 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
queryClient.invalidateQueries({ queryKey: ["search"] }); queryClient.invalidateQueries({ queryKey: ["search"] });
}} }}
> >
<DropdownMenu.ItemTitle>{t("home.settings.downloads.optimized")}</DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>
{t("home.settings.downloads.optimized")}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
@@ -134,4 +140,4 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
</ListGroup> </ListGroup>
</DisabledSetting> </DisabledSetting>
); );
}; }

View File

@@ -0,0 +1,5 @@
import React from "react";
export default function DownloadSettings({ ...props }) {
return <></>;
}

View File

@@ -0,0 +1,507 @@
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";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk";
import {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery } from "@tanstack/react-query";
import {
useNavigation,
usePathname,
useRouter,
useSegments,
} from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
type ScrollingCollectionListSection = {
type: "ScrollingCollectionList";
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[]>;
orientation?: "horizontal" | "vertical";
};
type MediaListSection = {
type: "MediaListSection";
queryKey: (string | undefined)[];
queryFn: QueryFunction<BaseItemDto>;
};
type Section = ScrollingCollectionListSection | MediaListSection;
export const HomeIndex = () => {
const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const [
settings,
updateSettings,
pluginSettings,
setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings();
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const scrollViewRef = useRef<ScrollView>(null);
const { downloadedFiles, cleanCacheDirectory } = useDownload();
useEffect(() => {
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className="p-2"
>
<Feather
name="download"
color={hasDownloads ? Colors.primary : "white"}
size={22}
/>
</TouchableOpacity>
),
});
}, [downloadedFiles, navigation, router]);
useEffect(() => {
cleanCacheDirectory().catch((e) =>
console.error("Something went wrong cleaning cache directory")
);
}, []);
const segments = useSegments();
useEffect(() => {
const unsubscribe = eventBus.on("scrollToTop", () => {
if (segments[2] === "(home)")
scrollViewRef.current?.scrollTo({ y: -152, animated: true });
});
return () => {
unsubscribe();
};
}, [segments]);
const checkConnection = useCallback(async () => {
setLoadingRetry(true);
const state = await NetInfo.fetch();
setIsConnected(state.isConnected);
setLoadingRetry(false);
}, []);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected == false || state.isInternetReachable === false)
setIsConnected(false);
else setIsConnected(true);
});
NetInfo.fetch().then((state) => {
setIsConnected(state.isConnected);
});
// cleanCacheDirectory().catch((e) =>
// console.error("Something went wrong cleaning cache directory")
// );
return () => {
unsubscribe();
};
}, []);
const {
data,
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries]
);
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
userViews?.filter(
(c) => c.CollectionType && allow.includes(c.CollectionType)
) || []
);
}, [userViews]);
const invalidateCache = useInvalidatePlaybackProgressCache();
const refetch = useCallback(async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
}, []);
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined
): ScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async () => {
if (!api) return [];
return (
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 20,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
).data || []
);
},
type: "ScrollingCollectionList",
}),
[api, user?.Id]
);
let sections: Section[] = [];
if (!settings?.home || !settings?.home?.sections) {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey = [
"home",
"recentlyAddedIn" + c.CollectionType,
user?.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id
);
});
const ss: Section[] = [
{
title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"],
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
{
title: t("home.next_up"),
queryKey: ["home", "nextUp-all"],
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
...latestMediaViews,
// ...(mediaListCollections?.map(
// (ml) =>
// ({
// title: ml.Name,
// queryKey: ["home", "mediaList", ml.Id!],
// queryFn: async () => ml,
// type: "MediaListSection",
// orientation: "vertical",
// } as Section)
// ) || []),
{
title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async () =>
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
limit: 10,
mediaType: ["Video"],
type: ["Movie"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "vertical",
},
{
title: t("home.suggested_episodes"),
queryKey: ["home", "suggestedEpisodes", user?.Id],
queryFn: async () => {
try {
const suggestions = await getSuggestions(api, user.Id);
const nextUpPromises = suggestions.map((series) =>
getNextUp(api, user.Id, series.Id)
);
const nextUpResults = await Promise.all(nextUpPromises);
return nextUpResults.filter((item) => item !== null) || [];
} catch (error) {
console.error("Error fetching data:", error);
return [];
}
},
type: "ScrollingCollectionList",
orientation: "horizontal",
},
];
return ss;
}, [api, user?.Id, collections]);
} else {
sections = 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 || [];
} else 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 || false,
enableRewatching: section.items?.enableRewatching || false,
});
return response.data.Items || [];
}
return [];
},
type: "ScrollingCollectionList",
orientation: section?.orientation || "vertical",
});
}
return ss;
}, [api, user?.Id, settings.home?.sections]);
}
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
<Text className="text-center opacity-70">
{t("home.no_internet_message")}
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
{t("home.go_to_downloads")}
</Button>
<Button
color="black"
onPress={() => {
checkConnection();
}}
justify="center"
className="mt-2"
iconRight={
loadingRetry ? null : (
<Ionicons name="refresh" size={20} color="white" />
)
}
>
{loadingRetry ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
"Retry"
)}
</Button>
</View>
</View>
);
}
if (e1)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
<Text className="text-center opacity-70">
{t("home.error_message")}
</Text>
</View>
);
if (l1)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
return (
<ScrollView
scrollToOverflowEnabled={true}
ref={scrollViewRef}
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<View className="flex flex-col space-y-4">
<LargeMovieCarousel />
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
/>
);
} else if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</View>
</ScrollView>
);
};
// Function to get suggestions
async function getSuggestions(api: Api, userId: string | undefined) {
if (!userId) return [];
const response = await getSuggestionsApi(api).getSuggestions({
userId,
limit: 10,
mediaType: ["Unknown"],
type: ["Series"],
});
return response.data.Items ?? [];
}
// Function to get the next up TV show for a series
async function getNextUp(
api: Api,
userId: string | undefined,
seriesId: string | undefined
) {
if (!userId || !seriesId) return null;
const response = await getTvShowsApi(api).getNextUp({
userId,
seriesId,
limit: 1,
});
return response.data.Items?.[0] ?? null;
}

View File

@@ -0,0 +1,453 @@
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 { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk";
import {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
type ScrollingCollectionListSection = {
type: "ScrollingCollectionList";
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[]>;
orientation?: "horizontal" | "vertical";
};
type MediaListSection = {
type: "MediaListSection";
queryKey: (string | undefined)[];
queryFn: QueryFunction<BaseItemDto>;
};
type Section = ScrollingCollectionListSection | MediaListSection;
export const HomeIndex = () => {
const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const [
settings,
updateSettings,
pluginSettings,
setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings();
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
const insets = useSafeAreaInsets();
const checkConnection = useCallback(async () => {
setLoadingRetry(true);
const state = await NetInfo.fetch();
setIsConnected(state.isConnected);
setLoadingRetry(false);
}, []);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected == false || state.isInternetReachable === false)
setIsConnected(false);
else setIsConnected(true);
});
NetInfo.fetch().then((state) => {
setIsConnected(state.isConnected);
});
// cleanCacheDirectory().catch((e) =>
// console.error("Something went wrong cleaning cache directory")
// );
return () => {
unsubscribe();
};
}, []);
const {
data,
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries]
);
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
userViews?.filter(
(c) => c.CollectionType && allow.includes(c.CollectionType)
) || []
);
}, [userViews]);
const invalidateCache = useInvalidatePlaybackProgressCache();
const refetch = useCallback(async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
}, []);
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined
): ScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async () => {
if (!api) return [];
return (
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 20,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
).data || []
);
},
type: "ScrollingCollectionList",
}),
[api, user?.Id]
);
let sections: Section[] = [];
if (!settings?.home || !settings?.home?.sections) {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey = [
"home",
"recentlyAddedIn" + c.CollectionType,
user?.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id
);
});
const ss: Section[] = [
{
title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"],
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
{
title: t("home.next_up"),
queryKey: ["home", "nextUp-all"],
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
...latestMediaViews,
// ...(mediaListCollections?.map(
// (ml) =>
// ({
// title: ml.Name,
// queryKey: ["home", "mediaList", ml.Id!],
// queryFn: async () => ml,
// type: "MediaListSection",
// orientation: "vertical",
// } as Section)
// ) || []),
{
title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async () =>
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
limit: 10,
mediaType: ["Video"],
type: ["Movie"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "vertical",
},
{
title: t("home.suggested_episodes"),
queryKey: ["home", "suggestedEpisodes", user?.Id],
queryFn: async () => {
try {
const suggestions = await getSuggestions(api, user.Id);
const nextUpPromises = suggestions.map((series) =>
getNextUp(api, user.Id, series.Id)
);
const nextUpResults = await Promise.all(nextUpPromises);
return nextUpResults.filter((item) => item !== null) || [];
} catch (error) {
console.error("Error fetching data:", error);
return [];
}
},
type: "ScrollingCollectionList",
orientation: "horizontal",
},
];
return ss;
}, [api, user?.Id, collections]);
} else {
sections = 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 || [];
} else 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 || false,
enableRewatching: section.items?.enableRewatching || false,
});
return response.data.Items || [];
}
return [];
},
type: "ScrollingCollectionList",
orientation: section?.orientation || "vertical",
});
}
return ss;
}, [api, user?.Id, settings.home?.sections]);
}
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
<Text className="text-center opacity-70">
{t("home.no_internet_message")}
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
{t("home.go_to_downloads")}
</Button>
<Button
color="black"
onPress={() => {
checkConnection();
}}
justify="center"
className="mt-2"
iconRight={
loadingRetry ? null : (
<Ionicons name="refresh" size={20} color="white" />
)
}
>
{loadingRetry ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
"Retry"
)}
</Button>
</View>
</View>
);
}
if (e1)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
<Text className="text-center opacity-70">
{t("home.error_message")}
</Text>
</View>
);
if (l1)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<View className="flex flex-col space-y-4">
<LargeMovieCarousel />
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
/>
);
} else if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</View>
</ScrollView>
);
};
// Function to get suggestions
async function getSuggestions(api: Api, userId: string | undefined) {
if (!userId) return [];
const response = await getSuggestionsApi(api).getSuggestions({
userId,
limit: 10,
mediaType: ["Unknown"],
type: ["Series"],
});
return response.data.Items ?? [];
}
// Function to get the next up TV show for a series
async function getNextUp(
api: Api,
userId: string | undefined,
seriesId: string | undefined
) {
if (!userId || !seriesId) return null;
const response = await getTvShowsApi(api).getNextUp({
userId,
seriesId,
limit: 1,
});
return response.data.Items?.[0] ?? null;
}

View File

@@ -26,9 +26,6 @@ export const JellyseerrSettings = () => {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [settings, updateSettings, pluginSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
useState<boolean>(false);
const [jellyseerrPassword, setJellyseerrPassword] = useState< const [jellyseerrPassword, setJellyseerrPassword] = useState<
string | undefined string | undefined
>(undefined); >(undefined);
@@ -39,11 +36,16 @@ export const JellyseerrSettings = () => {
const loginToJellyseerrMutation = useMutation({ const loginToJellyseerrMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!jellyseerrServerUrl || !user?.Name || !jellyseerrPassword) { if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
throw new Error("Missing server url");
if (!user?.Name)
throw new Error("Missing required information for login"); throw new Error("Missing required information for login");
} const jellyseerrTempApi = new JellyseerrApi(
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl); jellyseerrServerUrl || settings.jellyseerrServerUrl || ""
return jellyseerrTempApi.login(user.Name, jellyseerrPassword); );
const testResult = await jellyseerrTempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url");
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
}, },
onSuccess: (user) => { onSuccess: (user) => {
setJellyseerrUser(user); setJellyseerrUser(user);
@@ -57,31 +59,11 @@ export const JellyseerrSettings = () => {
}, },
}); });
const testJellyseerrServerUrlMutation = useMutation({
mutationFn: async () => {
if (!jellyseerrServerUrl || jellyseerrApi) return null;
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
return jellyseerrTempApi.test();
},
onSuccess: (result) => {
if (result && result.isValid) {
if (result.requiresPass) {
setPromptForJellyseerrPass(true);
} else {
updateSettings({ jellyseerrServerUrl });
}
} else {
setPromptForJellyseerrPass(false);
setjellyseerrServerUrl(undefined);
clearAllJellyseerData();
}
},
});
const clearData = () => { const clearData = () => {
clearAllJellyseerData().finally(() => { clearAllJellyseerData().finally(() => {
setJellyseerrUser(undefined);
setJellyseerrPassword(undefined);
setjellyseerrServerUrl(undefined); setjellyseerrServerUrl(undefined);
setPromptForJellyseerrPass(false);
}); });
}; };
@@ -92,34 +74,46 @@ export const JellyseerrSettings = () => {
<> <>
<ListGroup title={"Jellyseerr"}> <ListGroup title={"Jellyseerr"}>
<ListItem <ListItem
title={t("home.settings.plugins.jellyseerr.total_media_requests")} title={t(
"home.settings.plugins.jellyseerr.total_media_requests"
)}
value={jellyseerrUser?.requestCount?.toString()} value={jellyseerrUser?.requestCount?.toString()}
/> />
<ListItem <ListItem
title={t("home.settings.plugins.jellyseerr.movie_quota_limit")} title={t("home.settings.plugins.jellyseerr.movie_quota_limit")}
value={ value={
jellyseerrUser?.movieQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited") jellyseerrUser?.movieQuotaLimit?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
} }
/> />
<ListItem <ListItem
title={t("home.settings.plugins.jellyseerr.movie_quota_days")} title={t("home.settings.plugins.jellyseerr.movie_quota_days")}
value={ value={
jellyseerrUser?.movieQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited") jellyseerrUser?.movieQuotaDays?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
} }
/> />
<ListItem <ListItem
title={t("home.settings.plugins.jellyseerr.tv_quota_limit")} title={t("home.settings.plugins.jellyseerr.tv_quota_limit")}
value={jellyseerrUser?.tvQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")} value={
jellyseerrUser?.tvQuotaLimit?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
/> />
<ListItem <ListItem
title={t("home.settings.plugins.jellyseerr.tv_quota_days")} title={t("home.settings.plugins.jellyseerr.tv_quota_days")}
value={jellyseerrUser?.tvQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")} value={
jellyseerrUser?.tvQuotaDays?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
/> />
</ListGroup> </ListGroup>
<View className="p-4"> <View className="p-4">
<Button color="red" onPress={clearData}> <Button color="red" onPress={clearData}>
{t("home.settings.plugins.jellyseerr.reset_jellyseerr_config_button")} {t(
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button"
)}
</Button> </Button>
</View> </View>
</> </>
@@ -128,15 +122,20 @@ export const JellyseerrSettings = () => {
<Text className="text-xs text-red-600 mb-2"> <Text className="text-xs text-red-600 mb-2">
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")} {t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text> </Text>
<Text className="font-bold mb-1">{t("home.settings.plugins.jellyseerr.server_url")}</Text> <Text className="font-bold mb-1">
{t("home.settings.plugins.jellyseerr.server_url")}
</Text>
<View className="flex flex-col shrink mb-2"> <View className="flex flex-col shrink mb-2">
<Text className="text-xs text-gray-600"> <Text className="text-xs text-gray-600">
{t("home.settings.plugins.jellyseerr.server_url_hint")} {t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text> </Text>
</View> </View>
<Input <Input
placeholder={t("home.settings.plugins.jellyseerr.server_url_placeholder")} className="border border-neutral-800 mb-2"
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl} placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder"
)}
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
defaultValue={ defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
} }
@@ -145,40 +144,20 @@ export const JellyseerrSettings = () => {
autoCapitalize="none" autoCapitalize="none"
textContentType="URL" textContentType="URL"
onChangeText={setjellyseerrServerUrl} onChangeText={setjellyseerrServerUrl}
editable={!testJellyseerrServerUrlMutation.isPending} editable={!loginToJellyseerrMutation.isPending}
/> />
<View>
<Button <Text className="font-bold mb-2">
loading={testJellyseerrServerUrlMutation.isPending} {t("home.settings.plugins.jellyseerr.password")}
disabled={testJellyseerrServerUrlMutation.isPending} </Text>
color={promptForJellyseerrPass ? "red" : "purple"}
className="h-12 mt-2"
onPress={() => {
if (promptForJellyseerrPass) {
clearData();
return;
}
testJellyseerrServerUrlMutation.mutate();
}}
style={{
marginBottom: 8,
}}
>
{promptForJellyseerrPass ? t("home.settings.plugins.jellyseerr.clear_button") : t("home.settings.plugins.jellyseerr.save_button")}
</Button>
<View
pointerEvents={promptForJellyseerrPass ? "auto" : "none"}
style={{
opacity: promptForJellyseerrPass ? 1 : 0.5,
}}
>
<Text className="font-bold mb-2">{t("home.settings.plugins.jellyseerr.password")}</Text>
<Input <Input
className="border border-neutral-800"
autoFocus={true} autoFocus={true}
focusable={true} focusable={true}
placeholder={t("home.settings.plugins.jellyseerr.password_placeholder", {username: user?.Name})} placeholder={t(
"home.settings.plugins.jellyseerr.password_placeholder",
{ username: user?.Name }
)}
value={jellyseerrPassword} value={jellyseerrPassword}
keyboardType="default" keyboardType="default"
secureTextEntry={true} secureTextEntry={true}
@@ -186,10 +165,7 @@ export const JellyseerrSettings = () => {
autoCapitalize="none" autoCapitalize="none"
textContentType="password" textContentType="password"
onChangeText={setJellyseerrPassword} onChangeText={setJellyseerrPassword}
editable={ editable={!loginToJellyseerrMutation.isPending}
!loginToJellyseerrMutation.isPending &&
promptForJellyseerrPass
}
/> />
<Button <Button
loading={loginToJellyseerrMutation.isPending} loading={loginToJellyseerrMutation.isPending}

View File

@@ -1,14 +1,18 @@
import { Platform } from "react-native";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings"; import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { BitrateSelector, BITRATES } from "@/components/BitrateSelector";
import { import {
BACKGROUND_FETCH_TASK, BACKGROUND_FETCH_TASK,
registerBackgroundFetchAsync, registerBackgroundFetchAsync,
unregisterBackgroundFetchAsync, unregisterBackgroundFetchAsync,
} from "@/utils/background-tasks"; } from "@/utils/background-tasks";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import * as BackgroundFetch from "expo-background-fetch"; const BackgroundFetch = !Platform.isTV
? require("expo-background-fetch")
: null;
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import * as TaskManager from "expo-task-manager";
import React, { useEffect, useMemo } from "react"; import React, { useEffect, useMemo } from "react";
import { Linking, Switch, TouchableOpacity } from "react-native"; import { Linking, Switch, TouchableOpacity } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
@@ -29,6 +33,8 @@ export const OtherSettings: React.FC = () => {
* Background task * Background task
*******************/ *******************/
const checkStatusAsync = async () => { const checkStatusAsync = async () => {
if (Platform.isTV) return;
await BackgroundFetch.getStatusAsync(); await BackgroundFetch.getStatusAsync();
return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK); return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
}; };
@@ -158,6 +164,32 @@ export const OtherSettings: React.FC = () => {
title={t("home.settings.other.hide_libraries")} title={t("home.settings.other.hide_libraries")}
showArrow showArrow
/> />
<ListItem
title={t("home.settings.other.default_quality")}
disabled={pluginSettings?.defaultBitrate?.locked}
>
<Dropdown
data={BITRATES}
disabled={pluginSettings?.defaultBitrate?.locked}
keyExtractor={(item) => item.key}
titleExtractor={(item) => item.key}
selected={settings.defaultBitrate}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{settings.defaultBitrate?.key}
</Text>
<Ionicons
name="chevron-expand-sharp"
size={18}
color="#5A5960"
/>
</TouchableOpacity>
}
label={t("home.settings.other.default_quality")}
onSelected={(defaultBitrate) => updateSettings({ defaultBitrate })}
/>
</ListItem>
<ListItem <ListItem
title={t("home.settings.other.disable_haptic_feedback")} title={t("home.settings.other.disable_haptic_feedback")}
disabled={pluginSettings?.disableHapticFeedback?.locked} disabled={pluginSettings?.disableHapticFeedback?.locked}

View File

@@ -49,16 +49,25 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
}); });
if (res.status === 200) { if (res.status === 200) {
successHapticFeedback(); successHapticFeedback();
Alert.alert(t("home.settings.quick_connect.success"), t("home.settings.quick_connect.quick_connect_autorized")); Alert.alert(
t("home.settings.quick_connect.success"),
t("home.settings.quick_connect.quick_connect_autorized")
);
setQuickConnectCode(undefined); setQuickConnectCode(undefined);
bottomSheetModalRef?.current?.close(); bottomSheetModalRef?.current?.close();
} else { } else {
errorHapticFeedback(); errorHapticFeedback();
Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code")); Alert.alert(
t("home.settings.quick_connect.error"),
t("home.settings.quick_connect.invalid_code")
);
} }
} catch (e) { } catch (e) {
errorHapticFeedback(); errorHapticFeedback();
Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code")); Alert.alert(
t("home.settings.quick_connect.error"),
t("home.settings.quick_connect.invalid_code")
);
} }
} }
}, [api, user, quickConnectCode]); }, [api, user, quickConnectCode]);
@@ -96,7 +105,9 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
<BottomSheetTextInput <BottomSheetTextInput
style={{ color: "white" }} style={{ color: "white" }}
clearButtonMode="always" clearButtonMode="always"
placeholder={t("home.settings.quick_connect.enter_the_quick_connect_code")} placeholder={t(
"home.settings.quick_connect.enter_the_quick_connect_code"
)}
placeholderTextColor="#9CA3AF" placeholderTextColor="#9CA3AF"
value={quickConnectCode} value={quickConnectCode}
onChangeText={setQuickConnectCode} onChangeText={setQuickConnectCode}

View File

@@ -48,7 +48,10 @@ export const StorageSettings = () => {
<Text className="">{t("home.settings.storage.storage_title")}</Text> <Text className="">{t("home.settings.storage.storage_title")}</Text>
{size && ( {size && (
<Text className="text-neutral-500"> <Text className="text-neutral-500">
{t("home.settings.storage.size_used", {used: Number(size.total - size.remaining).bytesToReadable(), total: size.total?.bytesToReadable()})} {t("home.settings.storage.size_used", {
used: Number(size.total - size.remaining).bytesToReadable(),
total: size.total?.bytesToReadable(),
})}
</Text> </Text>
)} )}
</View> </View>
@@ -79,13 +82,20 @@ export const StorageSettings = () => {
<View className="flex flex-row items-center"> <View className="flex flex-row items-center">
<View className="w-3 h-3 rounded-full bg-purple-600 mr-1"></View> <View className="w-3 h-3 rounded-full bg-purple-600 mr-1"></View>
<Text className="text-white text-xs"> <Text className="text-white text-xs">
{t("home.settings.storage.app_usage", {usedSpace: calculatePercentage(size.app, size.total)})} {t("home.settings.storage.app_usage", {
usedSpace: calculatePercentage(size.app, size.total),
})}
</Text> </Text>
</View> </View>
<View className="flex flex-row items-center"> <View className="flex flex-row items-center">
<View className="w-3 h-3 rounded-full bg-purple-400 mr-1"></View> <View className="w-3 h-3 rounded-full bg-purple-400 mr-1"></View>
<Text className="text-white text-xs"> <Text className="text-white text-xs">
{t("home.settings.storage.phone_usage", {availableSpace: calculatePercentage(size.total - size.remaining - size.app, size.total)})} {t("home.settings.storage.device_usage", {
availableSpace: calculatePercentage(
size.total - size.remaining - size.app,
size.total
),
})}
</Text> </Text>
</View> </View>
</> </>

View File

@@ -1,5 +1,5 @@
import { TouchableOpacity, View, ViewProps } from "react-native"; import { Platform, TouchableOpacity, View, ViewProps } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { useMedia } from "./MediaContext"; import { useMedia } from "./MediaContext";
import { Switch } from "react-native-gesture-handler"; import { Switch } from "react-native-gesture-handler";
@@ -8,13 +8,14 @@ import { ListItem } from "../list/ListItem";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {useSettings} from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import {Stepper} from "@/components/inputs/Stepper"; import { Stepper } from "@/components/inputs/Stepper";
import Dropdown from "@/components/common/Dropdown"; import Dropdown from "@/components/common/Dropdown";
interface Props extends ViewProps {} interface Props extends ViewProps {}
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => { export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
if (Platform.isTV) return null;
const media = useMedia(); const media = useMedia();
const [_, __, pluginSettings] = useSettings(); const [_, __, pluginSettings] = useSettings();
const { settings, updateSettings } = media; const { settings, updateSettings } = media;
@@ -34,7 +35,8 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
const subtitleModeKeys = { const subtitleModeKeys = {
[SubtitlePlaybackMode.Default]: "home.settings.subtitles.modes.Default", [SubtitlePlaybackMode.Default]: "home.settings.subtitles.modes.Default",
[SubtitlePlaybackMode.Smart]: "home.settings.subtitles.modes.Smart", [SubtitlePlaybackMode.Smart]: "home.settings.subtitles.modes.Smart",
[SubtitlePlaybackMode.OnlyForced]: "home.settings.subtitles.modes.OnlyForced", [SubtitlePlaybackMode.OnlyForced]:
"home.settings.subtitles.modes.OnlyForced",
[SubtitlePlaybackMode.Always]: "home.settings.subtitles.modes.Always", [SubtitlePlaybackMode.Always]: "home.settings.subtitles.modes.Always",
[SubtitlePlaybackMode.None]: "home.settings.subtitles.modes.None", [SubtitlePlaybackMode.None]: "home.settings.subtitles.modes.None",
}; };
@@ -51,13 +53,22 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
> >
<ListItem title={t("home.settings.subtitles.subtitle_language")}> <ListItem title={t("home.settings.subtitles.subtitle_language")}>
<Dropdown <Dropdown
data={[{DisplayName: t("home.settings.subtitles.none"), ThreeLetterISOLanguageName: "none-subs" },...(cultures ?? [])]} data={[
keyExtractor={(item) => item?.ThreeLetterISOLanguageName ?? "unknown"} {
DisplayName: t("home.settings.subtitles.none"),
ThreeLetterISOLanguageName: "none-subs",
},
...(cultures ?? []),
]}
keyExtractor={(item) =>
item?.ThreeLetterISOLanguageName ?? "unknown"
}
titleExtractor={(item) => item?.DisplayName} titleExtractor={(item) => item?.DisplayName}
title={ title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3"> <TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]"> <Text className="mr-1 text-[#8E8D91]">
{settings?.defaultSubtitleLanguage?.DisplayName || t("home.settings.subtitles.none")} {settings?.defaultSubtitleLanguage?.DisplayName ||
t("home.settings.subtitles.none")}
</Text> </Text>
<Ionicons <Ionicons
name="chevron-expand-sharp" name="chevron-expand-sharp"
@@ -69,11 +80,13 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
label={t("home.settings.subtitles.language")} label={t("home.settings.subtitles.language")}
onSelected={(defaultSubtitleLanguage) => onSelected={(defaultSubtitleLanguage) =>
updateSettings({ updateSettings({
defaultSubtitleLanguage: defaultSubtitleLanguage.DisplayName === t("home.settings.subtitles.none") defaultSubtitleLanguage:
? null defaultSubtitleLanguage.DisplayName ===
: defaultSubtitleLanguage t("home.settings.subtitles.none")
? null
: defaultSubtitleLanguage,
}) })
} }
/> />
</ListItem> </ListItem>
@@ -89,7 +102,8 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
title={ title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3"> <TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]"> <Text className="mr-1 text-[#8E8D91]">
{t(subtitleModeKeys[settings?.subtitleMode]) || t("home.settings.subtitles.loading")} {t(subtitleModeKeys[settings?.subtitleMode]) ||
t("home.settings.subtitles.loading")}
</Text> </Text>
<Ionicons <Ionicons
name="chevron-expand-sharp" name="chevron-expand-sharp"
@@ -99,9 +113,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
</TouchableOpacity> </TouchableOpacity>
} }
label={t("home.settings.subtitles.subtitle_mode")} label={t("home.settings.subtitles.subtitle_mode")}
onSelected={(subtitleMode) => onSelected={(subtitleMode) => updateSettings({ subtitleMode })}
updateSettings({subtitleMode})
}
/> />
</ListItem> </ListItem>
@@ -128,7 +140,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
step={5} step={5}
min={0} min={0}
max={120} max={120}
onUpdate={(subtitleSize) => updateSettings({subtitleSize})} onUpdate={(subtitleSize) => updateSettings({ subtitleSize })}
/> />
</ListItem> </ListItem>
</ListGroup> </ListGroup>

View File

@@ -1,8 +1,11 @@
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { View, StyleSheet } from "react-native"; import { View, StyleSheet, Platform } from "react-native";
import { useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
import { Slider } from "react-native-awesome-slider"; import { Slider } from "react-native-awesome-slider";
import { VolumeManager } from "react-native-volume-manager"; // import { VolumeManager } from "react-native-volume-manager";
const VolumeManager = !Platform.isTV
? require("react-native-volume-manager")
: null;
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
interface AudioSliderProps { interface AudioSliderProps {
@@ -10,6 +13,8 @@ interface AudioSliderProps {
} }
const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => { const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
if (Platform.isTV) return;
const volume = useSharedValue<number>(50); // Explicitly type as number const volume = useSharedValue<number>(50); // Explicitly type as number
const min = useSharedValue<number>(0); // Explicitly type as number const min = useSharedValue<number>(0); // Explicitly type as number
const max = useSharedValue<number>(100); // Explicitly type as number const max = useSharedValue<number>(100); // Explicitly type as number

View File

@@ -1,12 +1,15 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { View, StyleSheet } from "react-native"; import { View, StyleSheet, Platform } from "react-native";
import { useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
import { Slider } from "react-native-awesome-slider"; import { Slider } from "react-native-awesome-slider";
import * as Brightness from "expo-brightness"; // import * as Brightness from "expo-brightness";
const Brightness = !Platform.isTV ? require("expo-brightness") : null;
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons"; import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
const BrightnessSlider = () => { const BrightnessSlider = () => {
if (Platform.isTV) return;
const brightness = useSharedValue(50); const brightness = useSharedValue(50);
const min = useSharedValue(0); const min = useSharedValue(0);
const max = useSharedValue(100); const max = useSharedValue(100);

View File

@@ -24,18 +24,23 @@ import {
ticksToMs, ticksToMs,
ticksToSeconds, ticksToSeconds,
} from "@/utils/time"; } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons, MaterialIcons } from "@expo/vector-icons";
import { import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { debounce } from "lodash"; import { debounce } from "lodash";
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { TouchableOpacity, useWindowDimensions, View } from "react-native"; import {
Platform,
TouchableOpacity,
useWindowDimensions,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider"; import { Slider } from "react-native-awesome-slider";
import { import {
runOnJS, runOnJS,
@@ -49,8 +54,7 @@ import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider"; import BrightnessSlider from "./BrightnessSlider";
import { ControlProvider } from "./contexts/ControlContext"; import { ControlProvider } from "./contexts/ControlContext";
import { VideoProvider } from "./contexts/VideoContext"; import { VideoProvider } from "./contexts/VideoContext";
import DropdownViewDirect from "./dropdown/DropdownViewDirect"; import DropdownView from "./dropdown/DropdownView";
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
import { EpisodeList } from "./EpisodeList"; import { EpisodeList } from "./EpisodeList";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton"; import SkipButton from "./SkipButton";
@@ -75,6 +79,7 @@ interface Props {
isVideoLoaded?: boolean; isVideoLoaded?: boolean;
mediaSource?: MediaSourceInfo | null; mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void; seek: (ticks: number) => void;
startPictureInPicture: () => Promise<void>;
play: (() => Promise<void>) | (() => void); play: (() => Promise<void>) | (() => void);
pause: () => void; pause: () => void;
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]); getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
@@ -82,39 +87,38 @@ interface Props {
setSubtitleURL?: (url: string, customName: string) => void; setSubtitleURL?: (url: string, customName: string) => void;
setSubtitleTrack?: (index: number) => void; setSubtitleTrack?: (index: number) => void;
setAudioTrack?: (index: number) => void; setAudioTrack?: (index: number) => void;
stop: (() => Promise<void>) | (() => void);
isVlc?: boolean; isVlc?: boolean;
} }
const CONTROLS_TIMEOUT = 4000; const CONTROLS_TIMEOUT = 4000;
export const Controls: React.FC<Props> = ({ export const Controls: React.FC<Props> = ({
item, item,
seek, seek,
play, startPictureInPicture,
pause, play,
togglePlay, pause,
isPlaying, togglePlay,
isSeeking, isPlaying,
progress, isSeeking,
isBuffering, progress,
cacheProgress, isBuffering,
showControls, cacheProgress,
setShowControls, showControls,
ignoreSafeAreas, setShowControls,
setIgnoreSafeAreas, ignoreSafeAreas,
mediaSource, setIgnoreSafeAreas,
isVideoLoaded, mediaSource,
getAudioTracks, isVideoLoaded,
getSubtitleTracks, getAudioTracks,
setSubtitleURL, getSubtitleTracks,
setSubtitleTrack, setSubtitleURL,
setAudioTrack, setSubtitleTrack,
stop, setAudioTrack,
offline = false, offline = false,
enableTrickplay = true, enableTrickplay = true,
isVlc = false, isVlc = false,
}) => { }) => {
const [settings] = useSettings(); const [settings] = useSettings();
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -183,81 +187,60 @@ export const Controls: React.FC<Props> = ({
isVlc isVlc
); );
const goToPreviousItem = useCallback(() => { const goToItemCommon = useCallback(
if (!previousItem || !settings) return; (item: BaseItemDto) => {
if (!item || !settings) return;
lightHapticFeedback(); lightHapticFeedback();
const previousIndexes: previousIndexes = { const previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined, subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
audioIndex: audioIndex ? parseInt(audioIndex) : undefined, audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
}; };
const { const {
mediaSource: newMediaSource, mediaSource: newMediaSource,
audioIndex: defaultAudioIndex, audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex, subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings( } = getDefaultPlaySettings(
previousItem, item,
settings, settings,
previousIndexes, previousIndexes,
mediaSource ?? undefined mediaSource ?? undefined
); );
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: previousItem.Id ?? "", // Ensure itemId is a string itemId: item.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "", audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "", subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue.toString(), bitrateValue: bitrateValue.toString(),
}).toString(); }).toString();
if (!bitrateValue) {
// @ts-expect-error // @ts-expect-error
router.replace(`player/direct-player?${queryParams}`); router.replace(`player/direct-player?${queryParams}`);
return; },
} [settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router]
// @ts-expect-error );
router.replace(`player/transcoding-player?${queryParams}`);
}, [previousItem, settings, subtitleIndex, audioIndex]); const goToPreviousItem = useCallback(() => {
if (!previousItem) return;
goToItemCommon(previousItem);
}, [previousItem, goToItemCommon]);
const goToNextItem = useCallback(() => { const goToNextItem = useCallback(() => {
if (!nextItem || !settings) return; if (!nextItem) return;
goToItemCommon(nextItem);
}, [nextItem, goToItemCommon]);
lightHapticFeedback(); const goToItem = useCallback(
async (itemId: string) => {
const previousIndexes: previousIndexes = { const gotoItem = await getItemById(api, itemId);
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined, if (!gotoItem) return;
audioIndex: audioIndex ? parseInt(audioIndex) : undefined, goToItemCommon(gotoItem);
}; },
[goToItemCommon, api]
const { );
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
nextItem,
settings,
previousIndexes,
mediaSource ?? undefined
);
const queryParams = new URLSearchParams({
itemId: nextItem.Id ?? "", // Ensure itemId is a string
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue.toString(),
}).toString();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
}, [nextItem, settings, subtitleIndex, audioIndex]);
const updateTimes = useCallback( const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => { (currentProgress: number, maxValue: number) => {
@@ -381,52 +364,6 @@ export const Controls: React.FC<Props> = ({
} }
}, [settings, isPlaying, isVlc]); }, [settings, isPlaying, isVlc]);
const goToItem = useCallback(
async (itemId: string) => {
try {
const gotoItem = await getItemById(api, itemId);
if (!settings || !gotoItem) return;
lightHapticFeedback();
const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
};
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
gotoItem,
settings,
previousIndexes,
mediaSource ?? undefined
);
const queryParams = new URLSearchParams({
itemId: gotoItem.Id ?? "", // Ensure itemId is a string
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue.toString(),
}).toString();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
} catch (error) {
console.error("Error in gotoEpisode:", error);
}
},
[settings, subtitleIndex, audioIndex]
);
const toggleIgnoreSafeAreas = useCallback(() => { const toggleIgnoreSafeAreas = useCallback(() => {
setIgnoreSafeAreas((prev) => !prev); setIgnoreSafeAreas((prev) => !prev);
lightHapticFeedback(); lightHapticFeedback();
@@ -499,6 +436,14 @@ export const Controls: React.FC<Props> = ({
); );
}, [trickPlayUrl, trickplayInfo, time]); }, [trickPlayUrl, trickplayInfo, time]);
const onClose = async () => {
lightHapticFeedback();
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
router.back();
};
return ( return (
<ControlProvider <ControlProvider
item={item} item={item}
@@ -534,23 +479,35 @@ export const Controls: React.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`}
> >
<View className="mr-auto"> {!Platform.isTV && (
<VideoProvider <View className="mr-auto">
getAudioTracks={getAudioTracks} <VideoProvider
getSubtitleTracks={getSubtitleTracks} getAudioTracks={getAudioTracks}
setAudioTrack={setAudioTrack} getSubtitleTracks={getSubtitleTracks}
setSubtitleTrack={setSubtitleTrack} setAudioTrack={setAudioTrack}
setSubtitleURL={setSubtitleURL} setSubtitleTrack={setSubtitleTrack}
> setSubtitleURL={setSubtitleURL}
{!mediaSource?.TranscodingUrl ? ( >
<DropdownViewDirect showControls={showControls} /> <DropdownView />
) : ( </VideoProvider>
<DropdownViewTranscoding showControls={showControls} /> </View>
)} )}
</VideoProvider>
</View>
<View className="flex flex-row items-center space-x-2 "> <View className="flex flex-row items-center space-x-2 ">
{!Platform.isTV && (
<TouchableOpacity
onPress={startPictureInPicture}
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
>
<MaterialIcons
name="picture-in-picture"
size={24}
color="white"
style={{ opacity: showControls ? 1 : 0 }}
/>
</TouchableOpacity>
)}
{item?.Type === "Episode" && !offline && ( {item?.Type === "Episode" && !offline && (
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
@@ -592,13 +549,7 @@ export const Controls: React.FC<Props> = ({
</TouchableOpacity> </TouchableOpacity>
{/* )} */} {/* )} */}
<TouchableOpacity <TouchableOpacity
onPress={async () => { onPress={onClose}
lightHapticFeedback();
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
router.back();
}}
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"
> >
<Ionicons name="close" size={24} color="white" /> <Ionicons name="close" size={24} color="white" />
@@ -778,8 +729,8 @@ export const Controls: React.FC<Props> = ({
!nextItem !nextItem
? false ? false
: isVlc : isVlc
? remainingTime < 10000 ? remainingTime < 10000
: remainingTime < 10 : remainingTime < 10
} }
onFinish={goToNextItem} onFinish={goToNextItem}
onPress={goToNextItem} onPress={goToNextItem}

View File

@@ -60,12 +60,12 @@ const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
} }
}; };
const { t } = useTranslation();
if (!show) { if (!show) {
return null; return null;
} }
const { t } = useTranslation();
return ( return (
<TouchableOpacity <TouchableOpacity
className="w-32 overflow-hidden rounded-md bg-black/60 border border-neutral-900" className="w-32 overflow-hidden rounded-md bg-black/60 border border-neutral-900"

View File

@@ -1,20 +1,12 @@
import { TrackInfo } from "@/modules/vlc-player"; import { TrackInfo } from "@/modules/vlc-player";
import { import React, { createContext, useContext, useState, ReactNode, useEffect, useMemo } from "react";
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import React, {
createContext,
useContext,
useState,
ReactNode,
useEffect,
} from "react";
import { useControlContext } from "./ControlContext"; import { useControlContext } from "./ControlContext";
import { Track } from "../types";
import { router, useLocalSearchParams } from "expo-router";
interface VideoContextProps { interface VideoContextProps {
audioTracks: TrackInfo[] | null; audioTracks: Track[] | null;
subtitleTracks: TrackInfo[] | null; subtitleTracks: Track[] | null;
setAudioTrack: ((index: number) => void) | undefined; setAudioTrack: ((index: number) => void) | undefined;
setSubtitleTrack: ((index: number) => void) | undefined; setSubtitleTrack: ((index: number) => void) | undefined;
setSubtitleURL: ((url: string, customName: string) => void) | undefined; setSubtitleURL: ((url: string, customName: string) => void) | undefined;
@@ -24,14 +16,8 @@ const VideoContext = createContext<VideoContextProps | undefined>(undefined);
interface VideoProviderProps { interface VideoProviderProps {
children: ReactNode; children: ReactNode;
getAudioTracks: getAudioTracks: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]) | undefined;
| (() => Promise<TrackInfo[] | null>) getSubtitleTracks: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]) | undefined;
| (() => TrackInfo[])
| undefined;
getSubtitleTracks:
| (() => Promise<TrackInfo[] | null>)
| (() => TrackInfo[])
| undefined;
setAudioTrack: ((index: number) => void) | undefined; setAudioTrack: ((index: number) => void) | undefined;
setSubtitleTrack: ((index: number) => void) | undefined; setSubtitleTrack: ((index: number) => void) | undefined;
setSubtitleURL: ((url: string, customName: string) => void) | undefined; setSubtitleURL: ((url: string, customName: string) => void) | undefined;
@@ -45,30 +31,135 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
setSubtitleURL, setSubtitleURL,
setAudioTrack, setAudioTrack,
}) => { }) => {
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null); const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>( const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
null
);
const ControlContext = useControlContext(); const ControlContext = useControlContext();
const isVideoLoaded = ControlContext?.isVideoLoaded; const isVideoLoaded = ControlContext?.isVideoLoaded;
const mediaSource = ControlContext?.mediaSource;
const allSubs = mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
const { itemId, audioIndex, bitrateValue, subtitleIndex } = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
const onTextBasedSubtitle = useMemo(
() =>
allSubs.find((s) => s.Index?.toString() === subtitleIndex && s.IsTextSubtitleStream) || subtitleIndex === "-1",
[allSubs, subtitleIndex]
);
const setPlayerParams = ({
chosenAudioIndex = audioIndex,
chosenSubtitleIndex = subtitleIndex,
}: {
chosenAudioIndex?: string;
chosenSubtitleIndex?: string;
}) => {
console.log("chosenSubtitleIndex", chosenSubtitleIndex);
const queryParams = new URLSearchParams({
itemId: itemId ?? "",
audioIndex: chosenAudioIndex,
subtitleIndex: chosenSubtitleIndex,
mediaSourceId: mediaSource?.Id ?? "",
bitrateValue: bitrateValue,
}).toString();
//@ts-ignore
router.replace(`player/direct-player?${queryParams}`);
};
const setTrackParams = (type: "audio" | "subtitle", index: number, serverIndex: number) => {
const setTrack = type === "audio" ? setAudioTrack : setSubtitleTrack;
const paramKey = type === "audio" ? "audioIndex" : "subtitleIndex";
// If we're transcoding and we're going from a image based subtitle
// to a text based subtitle, we need to change the player params.
const shouldChangePlayerParams = type === "subtitle" && mediaSource?.TranscodingUrl && !onTextBasedSubtitle;
console.log("Set player params", index, serverIndex);
if (shouldChangePlayerParams) {
setPlayerParams({
chosenSubtitleIndex: serverIndex.toString(),
});
return;
}
setTrack && setTrack(index);
router.setParams({
[paramKey]: serverIndex.toString(),
});
};
useEffect(() => { useEffect(() => {
const fetchTracks = async () => { const fetchTracks = async () => {
if ( if (getSubtitleTracks) {
getSubtitleTracks && const subtitleData = await getSubtitleTracks();
(subtitleTracks === null || subtitleTracks.length === 0)
) { // Step 1: Move external subs to the end, because VLC puts external subs at the end
const subtitles = await getSubtitleTracks(); const sortedSubs = allSubs.sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal));
console.log("Getting embeded subtitles...", subtitles);
// Step 2: Apply VLC indexing logic
let textSubIndex = 0;
const processedSubs: Track[] = sortedSubs?.map((sub) => {
// Always increment for non-transcoding subtitles
// Only increment for text-based subtitles when transcoding
const shouldIncrement = !mediaSource?.TranscodingUrl || sub.IsTextSubtitleStream;
const vlcIndex = subtitleData?.at(textSubIndex)?.index ?? -1;
const finalIndex = shouldIncrement ? vlcIndex : sub.Index ?? -1;
if (shouldIncrement) textSubIndex++;
return {
name: sub.DisplayTitle || "Undefined Subtitle",
index: sub.Index ?? -1,
setTrack: () =>
shouldIncrement
? setTrackParams("subtitle", finalIndex, sub.Index ?? -1)
: setPlayerParams({
chosenSubtitleIndex: sub.Index?.toString(),
}),
};
});
// Step 3: Restore the original order
const subtitles: Track[] = processedSubs.sort((a, b) => a.index - b.index);
// Add a "Disable Subtitles" option
subtitles.unshift({
name: "Disable",
index: -1,
setTrack: () =>
!mediaSource?.TranscodingUrl || onTextBasedSubtitle
? setTrackParams("subtitle", -1, -1)
: setPlayerParams({ chosenSubtitleIndex: "-1" }),
});
setSubtitleTracks(subtitles); setSubtitleTracks(subtitles);
} }
if ( if (getAudioTracks) {
getAudioTracks && const audioData = await getAudioTracks();
(audioTracks === null || audioTracks.length === 0)
) { const allAudio = mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
const audio = await getAudioTracks(); const audioTracks: Track[] = allAudio?.map((audio, idx) => {
setAudioTracks(audio); if (!mediaSource?.TranscodingUrl) {
const vlcIndex = audioData?.at(idx)?.index ?? -1;
return {
name: audio.DisplayTitle ?? "Undefined Audio",
index: audio.Index ?? -1,
setTrack: () => setTrackParams("audio", vlcIndex, audio.Index ?? -1),
};
}
return {
name: audio.DisplayTitle ?? "Undefined Audio",
index: audio.Index ?? -1,
setTrack: () => setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }),
};
});
setAudioTracks(audioTracks);
} }
}; };
fetchTracks(); fetchTracks();

View File

@@ -0,0 +1,121 @@
import React, { useCallback } from "react";
import { TouchableOpacity, Platform } from "react-native";
import { Ionicons } from "@expo/vector-icons";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useVideoContext } from "../contexts/VideoContext";
import { useLocalSearchParams, useRouter } from "expo-router";
import { BITRATES } from "@/components/BitrateSelector";
import { useControlContext } from "../contexts/ControlContext";
const DropdownView = () => {
const videoContext = useVideoContext();
const { subtitleTracks, audioTracks } = videoContext;
const ControlContext = useControlContext();
const [item, mediaSource] = [ControlContext?.item, ControlContext?.mediaSource];
const router = useRouter();
const { subtitleIndex, audioIndex, bitrateValue } = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
const changeBitrate = useCallback(
(bitrate: string) => {
const queryParams = new URLSearchParams({
itemId: item.Id ?? "",
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "",
bitrateValue: bitrate.toString(),
}).toString();
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
},
[item, mediaSource, subtitleIndex, audioIndex]
);
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="qualitytrigger">Quality</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{BITRATES?.map((bitrate, idx: number) => (
<DropdownMenu.CheckboxItem
key={`quality-item-${idx}`}
value={bitrateValue === (bitrate.value?.toString() ?? "")}
onValueChange={() => changeBitrate(bitrate.value?.toString() ?? "")}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>{bitrate.key}</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="subtitle-trigger">Subtitle</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{subtitleTracks?.map((sub, idx: number) => (
<DropdownMenu.CheckboxItem
key={`subtitle-item-${idx}`}
value={subtitleIndex === sub.index.toString()}
onValueChange={() => sub.setTrack()}
>
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>{sub.name}</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="audio-trigger">Audio</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{audioTracks?.map((track, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={audioIndex === track.index.toString()}
onValueChange={() => track.setTrack()}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>{track.name}</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};
export default DropdownView;

View File

@@ -1,158 +0,0 @@
import React, { useMemo, useState } from "react";
import { View, TouchableOpacity } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import * as DropdownMenu from "zeego/dropdown-menu";
import { useControlContext } from "../contexts/ControlContext";
import { useVideoContext } from "../contexts/VideoContext";
import { EmbeddedSubtitle, ExternalSubtitle } from "../types";
import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { router, useLocalSearchParams } from "expo-router";
interface DropdownViewDirectProps {
showControls: boolean;
offline?: boolean; // used to disable external subs for downloads
}
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
showControls,
offline = false,
}) => {
const api = useAtomValue(apiAtom);
const ControlContext = useControlContext();
const mediaSource = ControlContext?.mediaSource;
const item = ControlContext?.item;
const isVideoLoaded = ControlContext?.isVideoLoaded;
const videoContext = useVideoContext();
const {
subtitleTracks,
audioTracks,
setSubtitleURL,
setSubtitleTrack,
setAudioTrack,
} = videoContext;
const allSubtitleTracksForDirectPlay = useMemo(() => {
if (mediaSource?.TranscodingUrl) return null;
const embeddedSubs =
subtitleTracks
?.map((s) => ({
name: s.name,
index: s.index,
deliveryUrl: undefined,
}))
.filter((sub) => !sub.name.endsWith("[External]")) || [];
const externalSubs =
mediaSource?.MediaStreams?.filter(
(stream) => stream.Type === "Subtitle" && !!stream.DeliveryUrl
).map((s) => ({
name: s.DisplayTitle! + " [External]",
index: s.Index!,
deliveryUrl: s.DeliveryUrl,
})) || [];
// Combine embedded subs with external subs only if not offline
if (!offline) {
return [...embeddedSubs, ...externalSubs] as (
| EmbeddedSubtitle
| ExternalSubtitle
)[];
}
return embeddedSubs as EmbeddedSubtitle[];
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams, offline]);
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="subtitle-trigger">
Subtitle
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
<DropdownMenu.CheckboxItem
key={`subtitle-item-${idx}`}
value={subtitleIndex === sub.index.toString()}
onValueChange={() => {
if ("deliveryUrl" in sub && sub.deliveryUrl) {
setSubtitleURL &&
setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name);
} else {
setSubtitleTrack && setSubtitleTrack(sub.index);
}
router.setParams({
subtitleIndex: sub.index.toString(),
});
}}
>
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="audio-trigger">
Audio
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{audioTracks?.map((track, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={audioIndex === track.index.toString()}
onValueChange={() => {
setAudioTrack && setAudioTrack(track.index);
router.setParams({
audioIndex: track.index.toString(),
});
}}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{track.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};
export default DropdownViewDirect;

View File

@@ -1,228 +0,0 @@
import React, { useCallback, useMemo, useState } from "react";
import { View, TouchableOpacity } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import * as DropdownMenu from "zeego/dropdown-menu";
import { useControlContext } from "../contexts/ControlContext";
import { useVideoContext } from "../contexts/VideoContext";
import { TranscodedSubtitle } from "../types";
import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useLocalSearchParams, useRouter } from "expo-router";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
interface DropdownViewProps {
showControls: boolean;
offline?: boolean; // used to disable external subs for downloads
}
const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
const router = useRouter();
const api = useAtomValue(apiAtom);
const ControlContext = useControlContext();
const mediaSource = ControlContext?.mediaSource;
const item = ControlContext?.item;
const isVideoLoaded = ControlContext?.isVideoLoaded;
const videoContext = useVideoContext();
const { subtitleTracks, setSubtitleTrack } = videoContext;
const { subtitleIndex, audioIndex, bitrateValue } = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
// Either its on a text subtitle or its on not on any subtitle therefore it should show all the embedded HLS subtitles.
const isOnTextSubtitle = useMemo(() => {
const res = Boolean(
mediaSource?.MediaStreams?.find(
(x) => x.Index === parseInt(subtitleIndex) && x.IsTextSubtitleStream
) || subtitleIndex === "-1"
);
return res;
}, []);
const allSubs =
mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [];
const subtitleHelper = new SubtitleHelper(mediaSource?.MediaStreams ?? []);
const allSubtitleTracksForTranscodingStream = useMemo(() => {
const disableSubtitle = {
name: "Disable",
index: -1,
IsTextSubtitleStream: true,
} as TranscodedSubtitle;
if (isOnTextSubtitle) {
const textSubtitles =
subtitleTracks?.map((s) => ({
name: s.name,
index: s.index,
IsTextSubtitleStream: true,
})) || [];
const sortedSubtitles = subtitleHelper.getSortedSubtitles(textSubtitles);
return [disableSubtitle, ...sortedSubtitles];
}
const transcodedSubtitle: TranscodedSubtitle[] = allSubs.map((x) => ({
name: x.DisplayTitle!,
index: x.Index!,
IsTextSubtitleStream: x.IsTextSubtitleStream!,
}));
return [disableSubtitle, ...transcodedSubtitle];
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
const changeToImageBasedSub = useCallback(
(subtitleIndex: number) => {
const queryParams = new URLSearchParams({
itemId: item.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex?.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue,
}).toString();
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
},
[mediaSource]
);
// Audio tracks for transcoding streams.
const allAudio =
mediaSource?.MediaStreams?.filter((x) => x.Type === "Audio").map((x) => ({
name: x.DisplayTitle!,
index: x.Index!,
})) || [];
const ChangeTranscodingAudio = useCallback(
(audioIndex: number) => {
const queryParams = new URLSearchParams({
itemId: item.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex?.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue,
}).toString();
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
},
[mediaSource, subtitleIndex, audioIndex]
);
return (
<View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="subtitle-trigger">
Subtitle
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{allSubtitleTracksForTranscodingStream?.map(
(sub, idx: number) => (
<DropdownMenu.CheckboxItem
value={
subtitleIndex ===
(isOnTextSubtitle && sub.IsTextSubtitleStream
? subtitleHelper
.getSourceSubtitleIndex(sub.index)
.toString()
: sub?.index.toString())
}
key={`subtitle-item-${idx}`}
onValueChange={() => {
if (
subtitleIndex ===
(isOnTextSubtitle && sub.IsTextSubtitleStream
? subtitleHelper
.getSourceSubtitleIndex(sub.index)
.toString()
: sub?.index.toString())
)
return;
router.setParams({
subtitleIndex: subtitleHelper
.getSourceSubtitleIndex(sub.index)
.toString(),
});
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
setSubtitleTrack && setSubtitleTrack(sub.index);
return;
}
changeToImageBasedSub(sub.index);
}}
>
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
)
)}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="audio-trigger">
Audio
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{allAudio?.map((track, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={audioIndex === track.index.toString()}
onValueChange={() => {
if (audioIndex === track.index.toString()) return;
router.setParams({
audioIndex: track.index.toString(),
});
ChangeTranscodingAudio(track.index);
}}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{track.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};
export default DropdownView;

View File

@@ -13,7 +13,14 @@ type ExternalSubtitle = {
type TranscodedSubtitle = { type TranscodedSubtitle = {
name: string; name: string;
index: number; index: number;
deliveryUrl: string;
IsTextSubtitleStream: boolean; IsTextSubtitleStream: boolean;
}; };
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle }; type Track = {
name: string;
index: number;
setTrack: () => void;
};
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };

View File

@@ -11,6 +11,16 @@
"buildType": "apk" "buildType": "apk"
} }
}, },
"development_tv": {
"developmentClient": true,
"distribution": "internal",
"android": {
"buildType": "apk"
},
"env": {
"EXPO_TV": "1"
}
},
"preview": { "preview": {
"distribution": "internal" "distribution": "internal"
}, },
@@ -22,17 +32,27 @@
} }
}, },
"production": { "production": {
"channel": "0.25.0", "channel": "0.27.0",
"android": { "android": {
"image": "latest" "image": "latest"
} }
}, },
"production-apk": { "production-apk": {
"channel": "0.25.0", "channel": "0.27.0",
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "image": "latest"
} }
},
"production-apk-tv": {
"channel": "0.27.0",
"android": {
"buildType": "apk",
"image": "latest"
},
"env": {
"EXPO_TV": "1"
}
} }
}, },
"submit": { "submit": {

View File

@@ -1,15 +0,0 @@
--- expo.js.original 2024-11-10 09:08:19
+++ node_modules/react-native-edge-to-edge/dist/commonjs/expo.js 2024-11-10 09:08:23
@@ -19,10 +19,8 @@
const {
barStyle
} = androidStatusBar;
+ const android = props?.android || {};
const {
- android = {}
- } = props;
- const {
parentTheme = "Default"
} = android;
config.modResults.resources.style = config.modResults.resources.style?.map(style => {
\ No newline at end of file

View File

@@ -28,8 +28,8 @@ const useDefaultPlaySettings = (
(x) => x.Type === "Audio" (x) => x.Type === "Audio"
)?.Index; )?.Index;
// 4. Get default bitrate // 4. Get default bitrate from settings or fallback to max
const bitrate = BITRATES[0]; const bitrate = settings?.defaultBitrate ?? BITRATES[0];
return { return {
defaultAudioIndex: defaultAudioIndex:

109
hooks/useFavorite.ts Normal file
View File

@@ -0,0 +1,109 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useEffect, useState, useMemo } from "react";
export const useFavorite = (item: BaseItemDto) => {
const queryClient = useQueryClient();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const type = "item";
const [isFavorite, setIsFavorite] = useState(item.UserData?.IsFavorite);
useEffect(() => {
setIsFavorite(item.UserData?.IsFavorite);
}, [item.UserData?.IsFavorite]);
const updateItemInQueries = (newData: Partial<BaseItemDto>) => {
queryClient.setQueryData<BaseItemDto | undefined>(
[type, item.Id],
(old) => {
if (!old) return old;
return {
...old,
...newData,
UserData: { ...old.UserData, ...newData.UserData },
};
}
);
};
const markFavoriteMutation = useMutation({
mutationFn: async () => {
if (api && user) {
await getUserLibraryApi(api).markFavoriteItem({
userId: user.Id,
itemId: item.Id!,
});
}
},
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
const previousItem = queryClient.getQueryData<BaseItemDto>([
type,
item.Id,
]);
updateItemInQueries({ UserData: { IsFavorite: true } });
return { previousItem };
},
onError: (err, variables, context) => {
if (context?.previousItem) {
queryClient.setQueryData([type, item.Id], context.previousItem);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
setIsFavorite(true);
},
});
const unmarkFavoriteMutation = useMutation({
mutationFn: async () => {
if (api && user) {
await getUserLibraryApi(api).unmarkFavoriteItem({
userId: user.Id,
itemId: item.Id!,
});
}
},
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
const previousItem = queryClient.getQueryData<BaseItemDto>([
type,
item.Id,
]);
updateItemInQueries({ UserData: { IsFavorite: false } });
return { previousItem };
},
onError: (err, variables, context) => {
if (context?.previousItem) {
queryClient.setQueryData([type, item.Id], context.previousItem);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
setIsFavorite(false);
},
});
const toggleFavorite = () => {
if (isFavorite) {
unmarkFavoriteMutation.mutate();
} else {
markFavoriteMutation.mutate();
}
};
return {
isFavorite,
toggleFavorite,
markFavoriteMutation,
unmarkFavoriteMutation,
};
};

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import * as Haptics from "expo-haptics";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
const Haptics = !Platform.isTV ? require("expo-haptics") : null;
export type HapticFeedbackType = export type HapticFeedbackType =
| "light" | "light"
@@ -15,15 +15,21 @@ export type HapticFeedbackType =
export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => { export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
const [settings] = useSettings(); const [settings] = useSettings();
if (Platform.isTV) {
return () => {};
}
const createHapticHandler = useCallback( const createHapticHandler = useCallback(
(type: Haptics.ImpactFeedbackStyle) => { (type: typeof Haptics.ImpactFeedbackStyle) => {
return Platform.OS === "web" ? () => {} : () => Haptics.impactAsync(type); return Platform.OS === "web" || Platform.isTV
? () => {}
: () => Haptics.impactAsync(type);
}, },
[] []
); );
const createNotificationFeedback = useCallback( const createNotificationFeedback = useCallback(
(type: Haptics.NotificationFeedbackType) => { (type: typeof Haptics.NotificationFeedbackType) => {
return Platform.OS === "web" return Platform.OS === "web" || Platform.isTV
? () => {} ? () => {}
: () => Haptics.notificationAsync(type); : () => Haptics.notificationAsync(type);
}, },
@@ -35,7 +41,10 @@ export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
light: createHapticHandler(Haptics.ImpactFeedbackStyle.Light), light: createHapticHandler(Haptics.ImpactFeedbackStyle.Light),
medium: createHapticHandler(Haptics.ImpactFeedbackStyle.Medium), medium: createHapticHandler(Haptics.ImpactFeedbackStyle.Medium),
heavy: createHapticHandler(Haptics.ImpactFeedbackStyle.Heavy), heavy: createHapticHandler(Haptics.ImpactFeedbackStyle.Heavy),
selection: Platform.OS === "web" ? () => {} : Haptics.selectionAsync, selection:
Platform.OS === "web" || Platform.isTV
? () => {}
: Haptics.selectionAsync,
success: createNotificationFeedback( success: createNotificationFeedback(
Haptics.NotificationFeedbackType.Success Haptics.NotificationFeedbackType.Success
), ),

View File

@@ -10,7 +10,9 @@ import { storage } from "@/utils/mmkv";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { getColors } from "react-native-image-colors"; import { Platform } from "react-native";
// import { getColors } from "react-native-image-colors";
const Colors = !Platform.isTV ? require("react-native-image-colors") : null;
/** /**
* Custom hook to extract and manage image colors for a given item. * Custom hook to extract and manage image colors for a given item.
@@ -28,6 +30,8 @@ export const useImageColors = ({
url?: string | null; url?: string | null;
disabled?: boolean; disabled?: boolean;
}) => { }) => {
if (Platform.isTV) return;
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const [, setPrimaryColor] = useAtom(itemThemeColorAtom); const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
@@ -62,11 +66,11 @@ export const useImageColors = ({
} }
// Extract colors from the image // Extract colors from the image
getColors(source.uri, { Colors.getColors(source.uri, {
fallback: "#fff", fallback: "#fff",
cache: false, cache: false,
}) })
.then((colors) => { .then((colors: { platform: string; dominant: string; vibrant: string; detail: string; primary: string; }) => {
let primary: string = "#fff"; let primary: string = "#fff";
let text: string = "#000"; let text: string = "#000";
let backup: string = "#fff"; let backup: string = "#fff";
@@ -100,7 +104,7 @@ export const useImageColors = ({
storage.set(`${source.uri}-text`, text); storage.set(`${source.uri}-text`, text);
} }
}) })
.catch((error) => { .catch((error: any) => {
console.error("Error getting colors", error); console.error("Error getting colors", error);
}); });
} }

View File

@@ -449,12 +449,23 @@ export const useJellyseerr = () => {
); );
}; };
const jellyseerrRegion = useMemo(
() => jellyseerrUser?.settings?.region || "US",
[jellyseerrUser]
);
const jellyseerrLocale = useMemo(() => {
return jellyseerrUser?.settings?.locale || "en";
}, [jellyseerrUser]);
return { return {
jellyseerrApi, jellyseerrApi,
jellyseerrUser, jellyseerrUser,
setJellyseerrUser, setJellyseerrUser,
clearAllJellyseerData, clearAllJellyseerData,
isJellyseerrResult, isJellyseerrResult,
jellyseerrRegion,
jellyseerrLocale,
requestMedia, requestMedia,
}; };
}; };

View File

@@ -6,7 +6,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useHaptic } from "./useHaptic"; import { useHaptic } from "./useHaptic";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
export const useMarkAsPlayed = (item: BaseItemDto) => { export const useMarkAsPlayed = (items: BaseItemDto[]) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -14,7 +14,6 @@ export const useMarkAsPlayed = (item: BaseItemDto) => {
const invalidateQueries = () => { const invalidateQueries = () => {
const queriesToInvalidate = [ const queriesToInvalidate = [
["item", item.Id],
["resumeItems"], ["resumeItems"],
["continueWatching"], ["continueWatching"],
["nextUp-all"], ["nextUp-all"],
@@ -24,6 +23,11 @@ export const useMarkAsPlayed = (item: BaseItemDto) => {
["home"], ["home"],
]; ];
items.forEach((item) => {
if(!item.Id) return;
queriesToInvalidate.push(["item", item.Id]);
});
queriesToInvalidate.forEach((queryKey) => { queriesToInvalidate.forEach((queryKey) => {
queryClient.invalidateQueries({ queryKey }); queryClient.invalidateQueries({ queryKey });
}); });
@@ -32,40 +36,8 @@ export const useMarkAsPlayed = (item: BaseItemDto) => {
const markAsPlayedStatus = async (played: boolean) => { const markAsPlayedStatus = async (played: boolean) => {
lightHapticFeedback(); lightHapticFeedback();
// Optimistic update items.forEach((item) => {
queryClient.setQueryData( // Optimistic update
["item", item.Id],
(oldData: BaseItemDto | undefined) => {
if (oldData) {
return {
...oldData,
UserData: {
...oldData.UserData,
Played: !played,
},
};
}
return oldData;
}
);
try {
if (played) {
await markAsNotPlayed({
api: api,
itemId: item?.Id,
userId: user?.Id,
});
} else {
await markAsPlayed({
api: api,
item: item,
userId: user?.Id,
});
}
invalidateQueries();
} catch (error) {
// Revert optimistic update on error
queryClient.setQueryData( queryClient.setQueryData(
["item", item.Id], ["item", item.Id],
(oldData: BaseItemDto | undefined) => { (oldData: BaseItemDto | undefined) => {
@@ -81,8 +53,45 @@ export const useMarkAsPlayed = (item: BaseItemDto) => {
return oldData; 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); console.error("Error updating played status:", error);
} }
invalidateQueries();
}; };
return markAsPlayedStatus; return markAsPlayedStatus;

View File

@@ -1,12 +1,17 @@
import orientationToOrientationLock from "@/utils/OrientationLockConverter"; import orientationToOrientationLock from "@/utils/OrientationLockConverter";
import * as ScreenOrientation from "expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Platform } from "react-native";
export const useOrientation = () => { export const useOrientation = () => {
const [orientation, setOrientation] = useState( const [orientation, setOrientation] = useState(
ScreenOrientation.OrientationLock.UNKNOWN Platform.isTV
? ScreenOrientation.OrientationLock.LANDSCAPE
: ScreenOrientation.OrientationLock.UNKNOWN
); );
if (Platform.isTV) return { orientation, setOrientation };
useEffect(() => { useEffect(() => {
const orientationSubscription = const orientationSubscription =
ScreenOrientation.addOrientationChangeListener((event) => { ScreenOrientation.addOrientationChangeListener((event) => {

View File

@@ -1,8 +1,11 @@
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import * as ScreenOrientation from "expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useEffect } from "react"; import { useEffect } from "react";
import { Platform } from "react-native";
export const useOrientationSettings = () => { export const useOrientationSettings = () => {
if (Platform.isTV) return;
const [settings] = useSettings(); const [settings] = useSettings();
useEffect(() => { useEffect(() => {

View File

@@ -9,7 +9,11 @@ import {
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
// import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
const FFMPEGKitReactNative = !Platform.isTV
? require("ffmpeg-kit-react-native")
: null;
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback } from "react"; import { useCallback } from "react";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
@@ -18,8 +22,14 @@ import useDownloadHelper from "@/utils/download";
import { Api } from "@jellyfin/sdk"; import { Api } from "@jellyfin/sdk";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { JobStatus } from "@/utils/optimize-server"; import { JobStatus } from "@/utils/optimize-server";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type FFmpegSession = typeof FFMPEGKitReactNative.FFmpegSession;
type Statistics = typeof FFMPEGKitReactNative.Statistics;
const FFmpegKit = Platform.isTV
? null
: (FFMPEGKitReactNative.FFmpegKit as typeof FFMPEGKitReactNative.FFmpegKit);
const createFFmpegCommand = (url: string, output: string) => [ const createFFmpegCommand = (url: string, output: string) => [
"-y", // overwrite output files without asking "-y", // overwrite output files without asking
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options "-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
@@ -55,7 +65,12 @@ export const useRemuxHlsToMp4 = () => {
const [settings] = useSettings(); const [settings] = useSettings();
const { saveImage } = useImageStorage(); const { saveImage } = useImageStorage();
const { saveSeriesPrimaryImage } = useDownloadHelper(); const { saveSeriesPrimaryImage } = useDownloadHelper();
const { saveDownloadedItemInfo, setProcesses, processes, APP_CACHE_DOWNLOAD_DIRECTORY } = useDownload(); const {
saveDownloadedItemInfo,
setProcesses,
processes,
APP_CACHE_DOWNLOAD_DIRECTORY,
} = useDownload();
const onSaveAssets = async (api: Api, item: BaseItemDto) => { const onSaveAssets = async (api: Api, item: BaseItemDto) => {
await saveSeriesPrimaryImage(item); await saveSeriesPrimaryImage(item);
@@ -79,9 +94,9 @@ export const useRemuxHlsToMp4 = () => {
if (returnCode.isValueSuccess()) { if (returnCode.isValueSuccess()) {
const stat = await session.getLastReceivedStatistics(); const stat = await session.getLastReceivedStatistics();
await FileSystem.moveAsync({ await FileSystem.moveAsync({
from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`, from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`,
to: `${FileSystem.documentDirectory}${item.Id}.mp4` to: `${FileSystem.documentDirectory}${item.Id}.mp4`,
}) });
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: ["downloadedItems"], queryKey: ["downloadedItems"],
}); });
@@ -89,8 +104,11 @@ export const useRemuxHlsToMp4 = () => {
toast.success(t("home.downloads.toasts.download_completed")); toast.success(t("home.downloads.toasts.download_completed"));
} }
setProcesses((prev) => { setProcesses((prev: any[]) => {
return prev.filter((process) => process.itemId !== item.Id); return prev.filter(
(process: { itemId: string | undefined }) =>
process.itemId !== item.Id
);
}); });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -114,8 +132,8 @@ export const useRemuxHlsToMp4 = () => {
totalFrames > 0 ? Math.floor((processedFrames / totalFrames) * 100) : 0; totalFrames > 0 ? Math.floor((processedFrames / totalFrames) * 100) : 0;
if (!item.Id) throw new Error("Item is undefined"); if (!item.Id) throw new Error("Item is undefined");
setProcesses((prev) => { setProcesses((prev: any[]) => {
return prev.map((process) => { return prev.map((process: { itemId: string | undefined }) => {
if (process.itemId === item.Id) { if (process.itemId === item.Id) {
return { return {
...process, ...process,
@@ -133,12 +151,16 @@ export const useRemuxHlsToMp4 = () => {
const startRemuxing = useCallback( const startRemuxing = useCallback(
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => { async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
const cacheDir = await FileSystem.getInfoAsync(APP_CACHE_DOWNLOAD_DIRECTORY); const cacheDir = await FileSystem.getInfoAsync(
APP_CACHE_DOWNLOAD_DIRECTORY
);
if (!cacheDir.exists) { if (!cacheDir.exists) {
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {intermediates: true}) await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {
intermediates: true,
});
} }
const output = APP_CACHE_DOWNLOAD_DIRECTORY + `${item.Id}.mp4` const output = APP_CACHE_DOWNLOAD_DIRECTORY + `${item.Id}.mp4`;
if (!api) throw new Error("API is not defined"); if (!api) throw new Error("API is not defined");
if (!item.Id) throw new Error("Item must have an Id"); if (!item.Id) throw new Error("Item must have an Id");
@@ -146,15 +168,18 @@ export const useRemuxHlsToMp4 = () => {
// First lets save any important assets we want to present to the user offline // First lets save any important assets we want to present to the user offline
await onSaveAssets(api, item); await onSaveAssets(api, item);
toast.success(t("home.downloads.toasts.download_started_for", {item: item.Name}), { toast.success(
action: { t("home.downloads.toasts.download_started_for", { item: item.Name }),
label: "Go to download", {
onClick: () => { action: {
router.push("/downloads"); label: "Go to download",
toast.dismiss(); onClick: () => {
router.push("/downloads");
toast.dismiss();
},
}, },
}, }
}); );
try { try {
const job: JobStatus = { const job: JobStatus = {
@@ -170,13 +195,13 @@ export const useRemuxHlsToMp4 = () => {
}; };
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`); writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
setProcesses((prev) => [...prev, job]); setProcesses((prev: any) => [...prev, job]);
await FFmpegKit.executeAsync( await FFmpegKit.executeAsync(
createFFmpegCommand(url, output).join(" "), createFFmpegCommand(url, output).join(" "),
(session) => completeCallback(session, item), (session: any) => completeCallback(session, item),
undefined, undefined,
(s) => statisticsCallback(s, item) (s: any) => statisticsCallback(s, item)
); );
} catch (e) { } catch (e) {
const error = e as Error; const error = e as Error;
@@ -185,8 +210,11 @@ export const useRemuxHlsToMp4 = () => {
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}, `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
Error: ${error.message}, Stack: ${error.stack}` Error: ${error.message}, Stack: ${error.stack}`
); );
setProcesses((prev) => { setProcesses((prev: any[]) => {
return prev.filter((process) => process.itemId !== item.Id); return prev.filter(
(process: { itemId: string | undefined }) =>
process.itemId !== item.Id
);
}); });
throw error; // Re-throw the error to propagate it to the caller throw error; // Re-throw the error to propagate it to the caller
} }

36
hooks/useSessions.ts Normal file
View File

@@ -0,0 +1,36 @@
import { useQuery } from "@tanstack/react-query";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useAtom } from "jotai";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { userAtom } from "@/providers/JellyfinProvider";
export interface useSessionsProps {
refetchInterval: number;
activeWithinSeconds: number;
}
export const useSessions = ({
refetchInterval = 5 * 1000,
activeWithinSeconds = 360,
}: useSessionsProps) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data, isLoading, error } = useQuery({
queryKey: ["sessions"],
queryFn: async () => {
if (!api || !user || !user.Policy?.IsAdministrator) {
return [];
}
const response = await getSessionApi(api).getSessions({
activeWithinSeconds: activeWithinSeconds,
});
return response.data.filter((s) => s.NowPlayingItem);
},
refetchInterval: refetchInterval,
//enabled: !!user || !!user.Policy?.IsAdministrator,
//cacheTime: 0
});
return { sessions: data, isLoading };
};

21
i18n.ts
View File

@@ -1,23 +1,44 @@
import i18n from "i18next"; import i18n from "i18next";
import { initReactI18next } from "react-i18next"; import { initReactI18next } from "react-i18next";
import de from "./translations/de.json";
import en from "./translations/en.json"; import en from "./translations/en.json";
import es from "./translations/es.json";
import fr from "./translations/fr.json"; import fr from "./translations/fr.json";
import it from "./translations/it.json";
import ja from "./translations/ja.json";
import nl from "./translations/nl.json";
import sv from "./translations/sv.json"; import sv from "./translations/sv.json";
import zhCN from './translations/zh-CN.json';
import zhTW from './translations/zh-TW.json';
import { getLocales } from "expo-localization"; import { getLocales } from "expo-localization";
export const APP_LANGUAGES = [ export const APP_LANGUAGES = [
{ label: "Deutsch", value: "de" },
{ label: "English", value: "en" }, { label: "English", value: "en" },
{ label: "Español", value: "es" },
{ label: "Français", value: "fr" }, { label: "Français", value: "fr" },
{ label: "Italiano", value: "it" },
{ label: "日本語", value: "ja" },
{ label: "Nederlands", value: "nl" },
{ label: "Svenska", value: "sv" }, { label: "Svenska", value: "sv" },
{ label: "简体中文", value: "zh-CN" },
{ label: "繁體中文", value: "zh-TW" },
]; ];
i18n.use(initReactI18next).init({ i18n.use(initReactI18next).init({
compatibilityJSON: "v4", compatibilityJSON: "v4",
resources: { resources: {
de: { translation: de },
en: { translation: en }, en: { translation: en },
es: { translation: es },
fr: { translation: fr }, fr: { translation: fr },
it: { translation: it },
ja: { translation: ja },
nl: { translation: nl },
sv: { translation: sv }, sv: { translation: sv },
"zh-CN": { translation: zhCN },
"zh-TW": { translation: zhTW },
}, },
lng: getLocales()[0].languageCode || "en", lng: getLocales()[0].languageCode || "en",

6
login.yaml Normal file
View File

@@ -0,0 +1,6 @@
# login.yaml
appId: your.app.id
---
- launchApp
- tapOn: "Text on the screen"

14
metro.config.js Normal file
View File

@@ -0,0 +1,14 @@
const { getDefaultConfig } = require("expo/metro-config");
const config = getDefaultConfig(__dirname);
if (process.env?.EXPO_TV === "1") {
const originalSourceExts = config.resolver.sourceExts;
const tvSourceExts = [
...originalSourceExts.map((e) => `tv.${e}`),
...originalSourceExts,
];
config.resolver.sourceExts = tvSourceExts;
}
module.exports = config;

View File

@@ -1,12 +1,17 @@
apply plugin: 'com.android.library' plugins {
apply plugin: 'kotlin-android' id 'com.android.library'
apply plugin: 'kotlin-kapt' id 'kotlin-android'
id 'kotlin-kapt'
}
group = 'expo.modules.vlcplayer' group = 'expo.modules.vlcplayer'
version = '0.6.0' version = '0.6.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
apply from: expoModulesCorePlugin apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin() applyKotlinExpoModulesCorePlugin()
useCoreDependencies() useCoreDependencies()
useExpoPublishing() useExpoPublishing()
@@ -37,8 +42,8 @@ if (useManagedAndroidSdkVersions) {
} }
dependencies { dependencies {
implementation 'org.videolan.android:libvlc-all:3.6.0-eap12' implementation 'org.videolan.android:libvlc-all:3.6.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.5.31" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
} }
android { android {

View File

@@ -0,0 +1,38 @@
package expo.modules.vlcplayer
import expo.modules.core.interfaces.ReactActivityLifecycleListener
// TODO: Creating a separate package class and adding this as a lifecycle listener did not work...
// https://docs.expo.dev/modules/android-lifecycle-listeners/
object VLCManager: ReactActivityLifecycleListener {
val listeners: MutableList<ReactActivityLifecycleListener> = mutableListOf()
// override fun onCreate(activity: Activity?, savedInstanceState: Bundle?) {
// listeners.forEach {
// it.onCreate(activity, savedInstanceState)
// }
// }
//
// override fun onResume(activity: Activity?) {
// listeners.forEach {
// it.onResume(activity)
// }
// }
//
// override fun onPause(activity: Activity?) {
// listeners.forEach {
// it.onPause(activity)
// }
// }
//
// override fun onUserLeaveHint(activity: Activity?) {
// listeners.forEach {
// it.onUserLeaveHint(activity)
// }
// }
//
// override fun onDestroy(activity: Activity?) {
// listeners.forEach {
// it.onDestroy(activity)
// }
// }
}

View File

@@ -1,5 +1,6 @@
package expo.modules.vlcplayer package expo.modules.vlcplayer
import androidx.core.os.bundleOf
import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.modules.ModuleDefinition
@@ -7,6 +8,18 @@ class VlcPlayerModule : Module() {
override fun definition() = ModuleDefinition { override fun definition() = ModuleDefinition {
Name("VlcPlayer") Name("VlcPlayer")
OnActivityEntersForeground {
VLCManager.listeners.forEach {
it.onResume(appContext.currentActivity)
}
}
OnActivityEntersBackground {
VLCManager.listeners.forEach {
it.onPause(appContext.currentActivity)
}
}
View(VlcPlayerView::class) { View(VlcPlayerView::class) {
Prop("source") { view: VlcPlayerView, source: Map<String, Any> -> Prop("source") { view: VlcPlayerView, source: Map<String, Any> ->
view.setSource(source) view.setSource(source)
@@ -26,9 +39,14 @@ class VlcPlayerModule : Module() {
"onVideoLoadStart", "onVideoLoadStart",
"onVideoLoadEnd", "onVideoLoadEnd",
"onVideoProgress", "onVideoProgress",
"onVideoError" "onVideoError",
"onPipStarted"
) )
AsyncFunction("startPictureInPicture") { view: VlcPlayerView ->
view.startPictureInPicture()
}
AsyncFunction("play") { view: VlcPlayerView -> AsyncFunction("play") { view: VlcPlayerView ->
view.play() view.play()
} }

Some files were not shown because too many files have changed in this diff Show More