Compare commits

..

133 Commits

Author SHA1 Message Date
Fredrik Burmester
ef851ae3f5 chore: debugging counter 2025-02-21 13:29:40 +01:00
Fredrik Burmester
349899d840 Merge branch 'develop' into feat/recently-added-notifications 2025-02-21 12:06:00 +01:00
Fredrik Burmester
e1561dbd82 fix: return NewData for initial run 2025-02-21 11:26:32 +01:00
tkymmm
b0c5255bd7 feat: add japanese translations (#552) 2025-02-21 11:09:36 +01:00
Fredrik Burmester
e217fcff36 fix: refactor settings location for notifications 2025-02-21 10:49:26 +01:00
Fredrik Burmester
b402cf7f10 fix: add return type 2025-02-21 10:49:14 +01:00
Fredrik Burmester
f3b77b8547 fix: return correct type for notifications to work 2025-02-21 10:49:02 +01:00
Fredrik Burmester
c0643f564d fix: include count 2025-02-20 21:42:34 +01:00
Fredrik Burmester
893cedcf36 Merge branch 'develop' into feat/recently-added-notifications 2025-02-20 20:34:40 +01:00
Fredrik Burmester
69db54a66c fix: include series, increase to 30 items 2025-02-20 20:34:20 +01:00
Fredrik Burmester
268e93effb fix: time 2025-02-20 20:34:08 +01:00
Fredrik Burmester
8fe0089131 fix: task not registered properly 2025-02-20 20:34:02 +01:00
Fredrik Burmester
82ced0f101 fix: don't rerender function 2025-02-20 17:25:47 +01:00
Fredrik Burmester
5447c36bcd chore 2025-02-20 17:24:03 +01:00
Fredrik Burmester
988eede36f fix: change to 10 min as thie is the default and min value 2025-02-20 17:23:55 +01:00
Fredrik Burmester
a472d48b3b fix: 3 min not seconds interval 2025-02-20 17:21:33 +01:00
Fredrik Burmester
1fd4598ba8 chore 2025-02-20 16:31:32 +01:00
Fredrik Burmester
73dd171987 chore: version bump 2025-02-20 16:30:36 +01:00
Fredrik Burmester
63ea7d128f feat: recently added notifications 2025-02-20 15:08:14 +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
herrrta
3975473da9 fix: Requesting some seasons not working [Jellyseerr] 2025-01-25 15:05:48 -05:00
lostb1t
d34b86297a Update ScrollingCollectionList.tsx 2025-01-24 09:06:30 +01:00
sarendsen
c4a83e283f feat: hide sections when empty by default 2025-01-24 08:59:41 +01:00
sarendsen
dac471f0a6 feat: hide sections when empty by default 2025-01-24 08:55:02 +01: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
131 changed files with 11102 additions and 3788 deletions

View File

@@ -43,6 +43,9 @@ body:
label: Version
description: What version of Streamyfin are you running?
options:
- 0.27.0
- 0.26.1
- 0.26.0
- 0.25.0
- 0.24.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"

7
.gitignore vendored
View File

@@ -26,6 +26,10 @@ package-lock.json
/ios
/android
/iostv
/iosmobile
/androidmobile
/androidtv
modules/player/android
@@ -37,4 +41,5 @@ credentials.json
.vscode/
.idea/
.ruby-lsp
.ruby-lsp
modules/hls-downloader/android/build

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.
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
- 🤖 **Jellyseerr integration**: Request media directly in the app.
## 🧪 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:
- 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
- Customize homescreen
- 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.
**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
@@ -84,8 +85,14 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload`
3. Make sure you have xcode and/or android studio installed.
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.
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. 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
@@ -116,7 +123,85 @@ Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmest
## ✨ 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.
- [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": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.25.0",
"version": "0.27.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
"userInterfaceStyle": "dark",
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#2E2E2E"
},
"jsEngine": "hermes",
"assetBundlePatterns": ["**/*"],
"ios": {
@@ -36,7 +31,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 50,
"versionCode": 53,
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png"
},
@@ -48,15 +43,10 @@
]
},
"plugins": [
"@react-native-tvos/config-tv",
"expo-router",
"expo-font",
"@config-plugins/ffmpeg-kit-react-native",
[
"react-native-google-cast",
{
"useDefaultExpandedMediaControls": true
}
],
[
"react-native-video",
{
@@ -78,18 +68,19 @@
"useFrameworks": "static"
},
"android": {
"android": {
"compileSdkVersion": 34,
"targetSdkVersion": 34,
"buildToolsVersion": "34.0.0"
},
"compileSdkVersion": 35,
"targetSdkVersion": 35,
"buildToolsVersion": "35.0.0",
"kotlinVersion": "2.0.21",
"minSdkVersion": 24,
"usesCleartextTraffic": true,
"packagingOptions": {
"jniLibs": {
"useLegacyPackaging": true
}
}
},
"useAndroidX": true,
"enableJetifier": true
}
}
],
@@ -109,12 +100,25 @@
"expo-asset",
[
"react-native-edge-to-edge",
{ "android": { "parentTheme": "Material3" } }
{
"android": {
"parentTheme": "Material3"
}
}
],
["react-native-bottom-tabs"],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withGoogleCastActivity.js"],
["./plugins/withTrustLocalCerts.js"]
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
[
"expo-splash-screen",
{
"backgroundColor": "#2e2e2e",
"image": "./assets/images/StreamyFinFinal.png",
"imageWidth": 100
}
]
],
"experiments": {
"typedRoutes": true
@@ -133,6 +137,7 @@
},
"updates": {
"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 { useSafeAreaInsets } from "react-native-safe-area-context";
import React, { useCallback, useEffect, useState } from "react";
import { useAtom } from "jotai/index";
import { apiAtom } from "@/providers/JellyfinProvider";
import { ListItem } from "@/components/list/ListItem";
import * as WebBrowser from "expo-web-browser";
import Ionicons from "@expo/vector-icons/Ionicons";
import { Text } from "@/components/common/Text";
import { useTranslation } from "react-i18next";
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
export interface MenuLink {
name: string;
url: string;
@@ -52,7 +54,13 @@ export default function menuLinks() {
}}
data={menuLinks}
renderItem={({ item }) => (
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url)}>
<TouchableOpacity
onPress={() => {
if (!Platform.isTV) {
WebBrowser.openBrowserAsync(item.url);
}
}}
>
<ListItem
title={item.name}
iconAfter={<Ionicons name="link" size={24} color="white" />}

View File

@@ -1,10 +1,9 @@
import { Chromecast } from "@/components/Chromecast";
import { Text } from "@/components/common/Text";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native";
import { useTranslation } from "react-i18next";
const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null;
export default function IndexLayout() {
const router = useRouter();
@@ -25,14 +24,18 @@ export default function IndexLayout() {
headerShadowVisible: false,
headerRight: () => (
<View className="flex flex-row items-center space-x-2">
<Chromecast />
<TouchableOpacity
onPress={() => {
router.push("/(auth)/settings");
}}
>
<Feather name="settings" color={"white"} size={22} />
</TouchableOpacity>
{!Platform.isTV && (
<>
<Chromecast.Chromecast />
<TouchableOpacity
onPress={() => {
router.push("/(auth)/settings");
}}
>
<Feather name="settings" color={"white"} size={22} />
</TouchableOpacity>
</>
)}
</View>
),
}}

View File

@@ -1,475 +1,5 @@
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 { 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";
import { HomeIndex } from "@/components/settings/HomeIndex";
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 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}
/>
);
} 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;
export default function page() {
return <HomeIndex />;
}

View File

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

View File

@@ -1,8 +1,9 @@
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
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 { MediaToggles } from "@/components/settings/MediaToggles";
import { OtherSettings } from "@/components/settings/OtherSettings";
@@ -10,17 +11,26 @@ import { PluginSettings } from "@/components/settings/PluginSettings";
import { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { UserInfo } from "@/components/settings/UserInfo";
import { useHaptic } from "@/hooks/useHaptic";
import { useJellyfin } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
import { useHaptic } from "@/hooks/useHaptic";
import { storage } from "@/utils/mmkv";
import { RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY } from "@/utils/recently-added-notifications";
import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import React, { useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import React, { useCallback, useEffect, useMemo } from "react";
import {
ScrollView,
StyleSheet,
Switch,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { storage } from "@/utils/mmkv";
import * as TaskManager from "expo-task-manager";
import { BACKGROUND_FETCH_TASK_RECENTLY_ADDED } from "@/utils/background-tasks";
import { RecentlyAddedNotificationsSettings } from "@/components/settings/RecentlyAddedNotifications";
export default function settings() {
const router = useRouter();
@@ -42,7 +52,9 @@ export default function settings() {
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>
),
});
@@ -66,11 +78,12 @@ export default function settings() {
</MediaProvider>
<OtherSettings />
<DownloadSettings />
<PluginSettings />
<AppLanguageSelector/>
<AppLanguageSelector />
<ListGroup title={"Intro"}>
<ListItem
@@ -88,7 +101,7 @@ export default function settings() {
/>
</ListGroup>
<View className="mb-4">
<View className="">
<ListGroup title={t("home.settings.logs.logs_title")}>
<ListItem
onPress={() => router.push("/settings/logs/page")}
@@ -103,7 +116,21 @@ export default function settings() {
</ListGroup>
</View>
<StorageSettings />
<RecentlyAddedNotificationsSettings />
<View
style={{
height: StyleSheet.hairlineWidth,
backgroundColor: "white",
overflow: "hidden",
marginVertical: 16,
opacity: 0.3,
}}
></View>
<View className="">
<StorageSettings />
</View>
</View>
</ScrollView>
);

View File

@@ -29,7 +29,7 @@ import {
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
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 React, { useCallback, useEffect, useMemo, useState } from "react";
import { FlatList, View } from "react-native";

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
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 React, { useCallback, useEffect, useMemo } from "react";
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 { Stack } from "expo-router";
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";
export default function IndexLayout() {
@@ -27,166 +27,171 @@ export default function IndexLayout() {
},
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerRight: () => (
headerRight: () =>
!pluginSettings?.libraryOptions?.locked &&
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Ionicons
name="ellipsis-horizontal-outline"
size={24}
color="white"
/>
</DropdownMenu.Trigger>
<DropdownMenu.Content
align={"end"}
alignOffset={-10}
avoidCollisions={false}
collisionPadding={0}
loop={false}
side={"bottom"}
sideOffset={10}
>
<DropdownMenu.Label>{t("library.options.display")}</DropdownMenu.Label>
<DropdownMenu.Group key="display-group">
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="image-style-trigger">
{t("library.options.display")}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
!Platform.isTV && (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Ionicons
name="ellipsis-horizontal-outline"
size={24}
color="white"
/>
</DropdownMenu.Trigger>
<DropdownMenu.Content
align={"end"}
alignOffset={-10}
avoidCollisions={false}
collisionPadding={0}
loop={false}
side={"bottom"}
sideOffset={10}
>
<DropdownMenu.Label>
{t("library.options.display")}
</DropdownMenu.Label>
<DropdownMenu.Group key="display-group">
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="image-style-trigger">
{t("library.options.display")}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-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
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.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: string) => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showStats: newValue === "on" ? true : false,
},
});
}}
>
<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) => {
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.ItemIndicator />
<DropdownMenu.ItemTitle key="show-stats-title">
{t("library.options.show_stats")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.Group>
<DropdownMenu.Separator />
</DropdownMenu.Content>
</DropdownMenu.Root>
),
<DropdownMenu.Separator />
</DropdownMenu.Content>
</DropdownMenu.Root>
),
}}
/>
<Stack.Screen

View File

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

View File

@@ -50,7 +50,7 @@ export default function search() {
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 [search, setSearch] = useState<string>("");
@@ -122,18 +122,17 @@ export default function search() {
const navigation = useNavigation();
useLayoutEffect(() => {
if (Platform.OS === "ios")
navigation.setOptions({
headerSearchBarOptions: {
placeholder: t("search.search"),
onChangeText: (e: any) => {
router.setParams({ q: "" });
setSearch(e.nativeEvent.text);
},
hideWhenScrolling: false,
autoFocus: true,
navigation.setOptions({
headerSearchBarOptions: {
placeholder: t("search.search"),
onChangeText: (e: any) => {
router.setParams({ q: "" });
setSearch(e.nativeEvent.text);
},
});
hideWhenScrolling: false,
autoFocus: true,
},
});
}, [navigation]);
const { data: movies, isFetching: l1 } = useQuery({
@@ -210,19 +209,12 @@ export default function search() {
paddingRight: insets.right,
}}
>
<View className="flex flex-col">
{Platform.OS === "android" && (
<View className="mb-4 px-4">
<Input
autoCorrect={false}
returnKeyType="done"
keyboardType="web-search"
placeholder={t("search.search_here")}
value={search}
onChangeText={(text) => setSearch(text)}
/>
</View>
)}
<View
className="flex flex-col"
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}}
>
{jellyseerrApi && (
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
<TouchableOpacity onPress={() => setSearchType("Library")}>

View File

@@ -55,7 +55,9 @@ export default function TabLayout() {
<NativeTabs
sidebarAdaptable={false}
ignoresTopSafeArea
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
tabBarStyle={{
backgroundColor: "#121212",
}}
tabBarActiveTintColor={Colors.primary}
scrollEdgeAppearance="default"
>

View File

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

View File

@@ -3,32 +3,32 @@ import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules/vlc-player";
import {
PipStartedPayload,
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types";
import { useDownload } from "@/providers/DownloadProvider";
// import { useDownload } from "@/providers/DownloadProvider";
const downloadProvider = !Platform.isTV
? require("@/providers/DownloadProvider")
: null;
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log";
import native from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useHaptic } from "@/hooks/useHaptic";
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
import { useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
useCallback,
@@ -37,25 +37,20 @@ import React, {
useState,
useEffect,
} from "react";
import {
Alert,
BackHandler,
View,
AppState,
AppStateStatus,
Platform,
} from "react-native";
import { Alert, View, AppState, AppStateStatus, Platform } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import settings from "../(tabs)/(home)/settings";
import { useSettings } from "@/utils/atoms/settings";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client";
export default function page() {
console.log("Direct Player");
const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const { t } = useTranslation();
const navigation = useNavigation();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
@@ -63,12 +58,16 @@ export default function page() {
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [isPipStarted, setIsPipStarted] = useState(false);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
let getDownloadedItem = null;
if (!Platform.isTV) {
getDownloadedItem = downloadProvider.useDownload();
}
const { getDownloadedItem } = useDownload();
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light");
@@ -109,8 +108,8 @@ export default function page() {
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
if (offline) {
const item = await getDownloadedItem(itemId);
if (offline && !Platform.isTV) {
const item = await getDownloadedItem.getDownloadedItem(itemId);
if (item) return item.item;
}
@@ -125,57 +124,80 @@ export default function page() {
staleTime: 0,
});
const {
data: stream,
isLoading: isLoadingStreamUrl,
isError: isErrorStreamUrl,
} = useQuery({
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
queryFn: async () => {
if (offline) {
const data = await getDownloadedItem(itemId);
if (!data?.mediaSource) return null;
const [stream, setStream] = useState<{
mediaSource: MediaSourceInfo;
url: string;
sessionId: string | undefined;
} | null>(null);
const [isLoadingStream, setIsLoadingStream] = useState(true);
const [isErrorStream, setIsErrorStream] = useState(false);
const url = await getDownloadedFileUrl(data.item.Id!);
useEffect(() => {
const fetchStream = async () => {
setIsLoadingStream(true);
setIsErrorStream(false);
if (item)
return {
mediaSource: data.mediaSource,
url,
sessionId: undefined,
};
try {
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (!data?.mediaSource) {
setStream(null);
return;
}
const url = await getDownloadedFileUrl(data.item.Id!);
if (item) {
setStream({
mediaSource: data.mediaSource as MediaSourceInfo,
url,
sessionId: undefined,
});
return;
}
}
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) {
setStream(null);
return;
}
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
setStream(null);
return;
}
setStream({
mediaSource,
sessionId,
url,
});
} catch (error) {
console.error("Error fetching stream:", error);
setIsErrorStream(true);
setStream(null);
} finally {
setIsLoadingStream(false);
}
};
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 null;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
return null;
}
return {
mediaSource,
sessionId,
url,
};
},
enabled: !!itemId && !!item,
staleTime: 0,
});
fetchStream();
}, [itemId, mediaSourceId]);
const togglePlay = useCallback(async () => {
if (!api) return;
@@ -183,37 +205,21 @@ export default function page() {
lightHapticFeedback();
if (isPlaying) {
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 {
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,
});
}
}
if (!offline && stream) {
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,
});
}
}, [
isPlaying,
@@ -225,13 +231,13 @@ export default function page() {
subtitleIndex,
mediaSourceId,
offline,
progress.value,
progress,
]);
const reportPlaybackStopped = useCallback(async () => {
if (offline) return;
const currentTimeInTicks = msToTicks(progress.value);
const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!,
@@ -249,25 +255,9 @@ export default function page() {
videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]);
// TODO: unused should remove.
const reportPlaybackStart = useCallback(async () => {
if (offline) return;
if (!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,
});
}, [api, item, mediaSourceId, stream]);
const onProgress = useCallback(
async (data: ProgressUpdatePayload) => {
if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return;
if (isSeeking.get() || isPlaybackStopped) return;
const { currentTime } = data.nativeEvent;
@@ -275,7 +265,7 @@ export default function page() {
setIsBuffering(false);
}
progress.value = currentTime;
progress.set(currentTime);
if (offline) return;
@@ -294,12 +284,9 @@ export default function page() {
playSessionId: stream.sessionId,
});
},
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
[item?.Id, isSeeking, api, isPlaybackStopped, audioIndex, subtitleIndex]
);
useOrientation();
useOrientationSettings();
useWebSocket({
isPlaying: isPlaying,
togglePlay: togglePlay,
@@ -307,16 +294,23 @@ export default function page() {
offline,
});
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
const onPipStarted = useCallback((e: PipStartedPayload) => {
const { pipStarted } = e.nativeEvent;
setIsPipStarted(pipStarted);
}, []);
const onPlaybackStateChanged = useCallback(async (e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") {
setIsPlaying(true);
if (!Platform.isTV) await activateKeepAwakeAsync()
return;
}
if (state === "Paused") {
setIsPlaying(false);
if (!Platform.isTV) await deactivateKeepAwake();
return;
}
@@ -336,96 +330,71 @@ export default function page() {
: 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
const subscription = AppState.addEventListener(
"change",
handleAppStateChange
);
return () => {
// Cleanup the event listener when the component is unmounted
subscription.remove();
};
}, [appState]);
// Preselection of audio and subtitle tracks.
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(
(audio) => audio.Type === "Audio"
) || [];
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle"
) || [];
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex
);
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
// Direct playback CASE
if (!bitrateValue) {
// If Subtitle is embedded we can use the position to select it straight away.
if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) {
initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`);
} 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 notTranscoding = !stream?.mediaSource.TranscodingUrl;
if (
chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) {
const finalIndex = notTranscoding
? allSubs.indexOf(chosenSubtitleTrack)
: textSubs.indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`);
}
const insets = useSafeAreaInsets();
if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
}));
const [isMounted, setIsMounted] = useState(false);
// Add useEffect to handle mounting
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []);
const insets = useSafeAreaInsets();
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => {
beforeRemoveListener();
};
}, [navigation]);
if (!item || isLoadingItem || !stream)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
if (isErrorItem || isErrorStreamUrl)
if (isErrorItem || isErrorStream)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">{t("player.error")}</Text>
@@ -449,18 +418,18 @@ export default function page() {
<VlcPlayerView
ref={videoRef}
source={{
uri: stream.url,
uri: stream?.url || "",
autoplay: true,
isNetwork: true,
startPosition,
externalTrack,
externalSubtitles,
initOptions,
}}
style={{ width: "100%", height: "100%" }}
onVideoProgress={onProgress}
progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged}
onVideoLoadStart={() => {}}
onPipStarted={onPipStarted}
onVideoLoadEnd={() => {
setIsVideoLoaded(true);
}}
@@ -474,7 +443,7 @@ export default function page() {
}}
/>
</View>
{videoRef.current && (
{videoRef.current && !isPipStarted && isMounted === true ? (
<Controls
mediaSource={stream?.mediaSource}
item={item}
@@ -490,6 +459,7 @@ export default function page() {
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef?.current?.startPictureInPicture}
play={videoRef.current?.play}
pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo}
@@ -503,26 +473,7 @@ export default function page() {
stop={stop}
isVlc
/>
)}
) : null}
</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,81 +1,99 @@
import "@/augmentations";
import { Text } from "@/components/common/Text";
import { Platform } from "react-native";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
getOrSetDeviceId,
getServerUrlFromStorage,
getTokenFromStorage,
getUserFromStorage,
JellyfinProvider,
} from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { orientationAtom } from "@/utils/atoms/orientation";
import { Settings, useSettings } from "@/utils/atoms/settings";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
import {
BACKGROUND_FETCH_TASK,
BACKGROUND_FETCH_TASK_RECENTLY_ADDED,
registerBackgroundFetchAsyncRecentlyAdded,
unregisterBackgroundFetchAsyncRecentlyAdded,
} from "@/utils/background-tasks";
import { LogProvider, writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import {
checkForExistingDownloads,
completeHandler,
download,
} from "@kesha-antonov/react-native-background-downloader";
const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
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 { useFonts } from "expo-font";
import { useKeepAwake } from "expo-keep-awake";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import { router, Stack } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import * as SplashScreen from "expo-splash-screen";
import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
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 { 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 { 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 { Toaster } from "sonner-native";
import { Jellyfin } from "@jellyfin/sdk";
import { fetchAndStoreRecentlyAdded } from "@/utils/recently-added-notifications";
if (!Platform.isTV) {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
}
// Keep the splash screen visible while we fetch resources
SplashScreen.preventAutoHideAsync();
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
// Set the animation options. This is optional.
SplashScreen.setOptions({
duration: 500,
fade: true,
});
function useNotificationObserver() {
if (Platform.isTV) return;
useEffect(() => {
let isMounted = true;
function redirect(notification: Notifications.Notification) {
function redirect(notification: typeof Notifications.Notification) {
const url = notification.request.content.data?.url;
if (url) {
router.push(url);
}
}
Notifications.getLastNotificationResponseAsync().then((response) => {
if (!isMounted || !response?.notification) {
return;
Notifications.getLastNotificationResponseAsync().then(
(response: { notification: any }) => {
if (!isMounted || !response?.notification) {
return;
}
redirect(response?.notification);
}
redirect(response?.notification);
});
);
const subscription = Notifications.addNotificationResponseReceivedListener(
(response) => {
(response: { notification: any }) => {
redirect(response.notification);
}
);
@@ -87,99 +105,125 @@ function useNotificationObserver() {
}, []);
}
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger");
if (!Platform.isTV) {
TaskManager.defineTask(BACKGROUND_FETCH_TASK_RECENTLY_ADDED, async () => {
const token = getTokenFromStorage();
const url = getServerUrlFromStorage();
const user = getUserFromStorage();
const now = Date.now();
const c = storage.getNumber("notification_send_for_item_ids.count");
storage.set("notification_send_for_item_ids.count", (c || 0) + 1);
const settingsData = storage.getString("settings");
console.log(
"TaskManager ~ trigger ~ recently added notifications:",
token,
url,
user?.Id
);
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
if (!token || !url || !user?.Id) return;
const settings: Partial<Settings> = JSON.parse(settingsData);
const url = settings?.optimizedVersionsServerUrl;
const result = await fetchAndStoreRecentlyAdded(user.Id, url, token);
if (!settings?.autoDownload || !url)
return BackgroundFetch.BackgroundFetchResult.NoData;
if (!result) return BackgroundFetch.BackgroundFetchResult.NoData;
const token = getTokenFromStorage();
const deviceId = getOrSetDeviceId();
const baseDirectory = FileSystem.documentDirectory;
if (!token || !deviceId || !baseDirectory)
return BackgroundFetch.BackgroundFetchResult.NoData;
const jobs = await getAllJobsByDeviceId({
deviceId,
authHeader: token,
url,
return BackgroundFetch.BackgroundFetchResult.NewData;
});
console.log("TaskManager ~ Active jobs: ", jobs.length);
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger");
for (let job of jobs) {
if (job.status === "completed") {
const downloadUrl = url + "download/" + job.id;
const tasks = await checkForExistingDownloads();
const now = Date.now();
if (tasks.find((task) => task.id === job.id)) {
console.log("TaskManager ~ Download already in progress: ", job.id);
continue;
const settingsData = storage.getString("settings");
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
const settings: Partial<Settings> = JSON.parse(settingsData);
const url = settings?.optimizedVersionsServerUrl;
if (!settings?.autoDownload || !url)
return BackgroundFetch.BackgroundFetchResult.NoData;
const token = getTokenFromStorage();
const deviceId = getOrSetDeviceId();
const baseDirectory = FileSystem.documentDirectory;
if (!token || !deviceId || !baseDirectory)
return BackgroundFetch.BackgroundFetchResult.NoData;
const jobs = await getAllJobsByDeviceId({
deviceId,
authHeader: token,
url,
});
console.log("TaskManager ~ Active jobs: ", jobs.length);
for (let job of jobs) {
if (job.status === "completed") {
const downloadUrl = url + "download/" + job.id;
const tasks = await BackGroundDownloader.checkForExistingDownloads();
if (tasks.find((task: { id: string }) => task.id === job.id)) {
console.log("TaskManager ~ Download already in progress: ", job.id);
continue;
}
BackGroundDownloader.download({
id: job.id,
url: downloadUrl,
destination: `${baseDirectory}${job.item.Id}.mp4`,
headers: {
Authorization: token,
},
})
.begin(() => {
console.log("TaskManager ~ Download started: ", job.id);
})
.done(() => {
console.log("TaskManager ~ Download completed: ", job.id);
saveDownloadedItemInfo(job.item);
BackGroundDownloader.completeHandler(job.id);
cancelJobById({
authHeader: token,
id: job.id,
url: url,
});
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download completed",
data: {
url: `/downloads`,
},
},
trigger: null,
});
})
.error((error: any) => {
console.log("TaskManager ~ Download error: ", job.id, error);
BackGroundDownloader.completeHandler(job.id);
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download failed",
data: {
url: `/downloads`,
},
},
trigger: null,
});
});
}
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!
return BackgroundFetch.BackgroundFetchResult.NewData;
});
// Be sure to return the successful result type!
return BackgroundFetch.BackgroundFetchResult.NewData;
});
}
const checkAndRequestPermissions = async () => {
try {
@@ -213,28 +257,18 @@ const checkAndRequestPermissions = async () => {
};
export default function RootLayout() {
const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
Appearance.setColorScheme("dark");
if (!loaded) {
return null;
}
return (
<JotaiProvider>
<I18nextProvider i18n={i18n}>
<Layout />
</I18nextProvider>
</JotaiProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<JotaiProvider>
<ActionSheetProvider>
<I18nextProvider i18n={i18n}>
<Layout />
</I18nextProvider>
</ActionSheetProvider>
</JotaiProvider>
</GestureHandlerRootView>
);
}
@@ -251,26 +285,8 @@ const queryClient = new QueryClient({
});
function Layout() {
const [settings, updateSettings] = useSettings();
const [orientation, setOrientation] = useAtom(orientationAtom);
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]);
const [settings] = useSettings();
const appState = useRef(AppState.currentState);
useEffect(() => {
i18n.changeLanguage(
@@ -278,112 +294,133 @@ function Layout() {
);
}, [settings?.preferedLanguage, i18n]);
const appState = useRef(AppState.currentState);
if (!Platform.isTV) {
useNotificationObserver();
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
checkForExistingDownloads();
}
});
useEffect(() => {
checkAndRequestPermissions();
}, []);
checkForExistingDownloads();
const checkStatusAsync = async () => {
if (Platform.isTV) return;
return () => {
subscription.remove();
await BackgroundFetch.getStatusAsync();
return await TaskManager.isTaskRegisteredAsync(
BACKGROUND_FETCH_TASK_RECENTLY_ADDED
);
};
}, []);
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
setOrientation(event.orientationInfo.orientation);
useEffect(() => {
(async () => {
const isRegistered = await checkStatusAsync();
if (settings.recentlyAddedNotifications === false && isRegistered) {
console.log("unregisterBackgroundFetchAsyncRecentlyAdded");
unregisterBackgroundFetchAsyncRecentlyAdded();
} else if (
settings.recentlyAddedNotifications === true &&
!isRegistered
) {
console.log("registerBackgroundFetchAsyncRecentlyAdded");
registerBackgroundFetchAsyncRecentlyAdded();
}
})();
}, [settings.recentlyAddedNotifications]);
useEffect(() => {
// If the user has auto rotate enabled, unlock the orientation
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]);
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
setOrientation(initialOrientation);
});
useEffect(() => {
const subscription = AppState.addEventListener(
"change",
(nextAppState) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
BackGroundDownloader.checkForExistingDownloads();
}
}
);
return () => {
ScreenOrientation.removeOrientationChangeListener(subscription);
};
}, []);
BackGroundDownloader.checkForExistingDownloads();
const url = Linking.useURL();
if (url) {
const { hostname, path, queryParams } = Linking.parse(url);
return () => {
subscription.remove();
};
}, []);
}
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClient}>
<ActionSheetProvider>
<JobQueueProvider>
<JellyfinProvider>
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="(auth)/player"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="login"
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</JobQueueProvider>
</ActionSheetProvider>
</QueryClientProvider>
</GestureHandlerRootView>
<QueryClientProvider client={queryClient}>
<JobQueueProvider>
<JellyfinProvider>
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="(auth)/(tabs)">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="(auth)/player"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="login"
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</JobQueueProvider>
</QueryClientProvider>
);
}

View File

@@ -9,7 +9,7 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import React, { useCallback, useEffect, useState } from "react";
import {
Alert,
@@ -19,17 +19,20 @@ import {
TouchableOpacity,
View,
} from "react-native";
import { Keyboard } from "react-native";
import { z } from "zod";
import { t } from 'i18next';
import { t } from "i18next";
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 } =
useJellyfin();
const [api] = useAtom(apiAtom);
const params = useLocalSearchParams();
const {
apiUrl: _apiUrl,
@@ -37,6 +40,8 @@ const CredentialsSchema = z.object({
password: _password,
} = 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 [serverName, setServerName] = useState<string>("");
const [credentials, setCredentials] = useState<{
@@ -47,10 +52,11 @@ const CredentialsSchema = z.object({
password: _password,
});
/**
* A way to auto login based on a link
*/
useEffect(() => {
(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) {
setServer({
address: _apiUrl,
@@ -66,7 +72,6 @@ const CredentialsSchema = z.object({
})();
}, [_apiUrl, _username, _password]);
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerTitle: serverName,
@@ -79,15 +84,17 @@ const CredentialsSchema = z.object({
className="flex flex-row items-center"
>
<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>
) : null,
});
}, [serverName, navigation, api?.basePath]);
const [loading, setLoading] = useState<boolean>(false);
const handleLogin = async () => {
Keyboard.dismiss();
setLoading(true);
try {
const result = CredentialsSchema.safeParse(credentials);
@@ -98,15 +105,16 @@ const CredentialsSchema = z.object({
if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message);
} 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 {
setLoading(false);
}
};
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
/**
* Checks the availability and validity of a Jellyfin server URL.
*
@@ -180,14 +188,21 @@ const CredentialsSchema = z.object({
try {
const code = await initiateQuickConnect();
if (code) {
Alert.alert(t("login.quick_connect"), t("login.enter_code_to_login", {code: code}), [
{
text: t("login.got_it"),
},
]);
Alert.alert(
t("login.quick_connect"),
t("login.enter_code_to_login", { code: code }),
[
{
text: t("login.got_it"),
},
]
);
}
} 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="px-4 -mt-20 w-full">
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold -mb-2">
<>
{serverName ? (
<>
{t("login.login_to_title") + " "}
<Text className="text-purple-600">{serverName}</Text>
</>
) : t("login.login_title")}
</>
</Text>
<Text className="text-2xl font-bold -mb-2">
<>
{serverName ? (
<>
{t("login.login_to_title") + " "}
<Text className="text-purple-600">{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</>
</Text>
<Text className="text-xs text-neutral-400">
{api.basePath}
</Text>
@@ -220,7 +237,6 @@ const CredentialsSchema = z.object({
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
autoFocus
secureTextEntry={false}
keyboardType="default"
returnKeyType="done"
@@ -300,7 +316,9 @@ const CredentialsSchema = z.object({
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)}
onPress={async () => {
await handleConnect(serverURL);
}}
className="w-full grow"
>
{t("server.connect_button")}

2927
bun.lock Normal file

File diff suppressed because it is too large Load Diff

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,7 +1,7 @@
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text";
import { useTranslation } from "react-i18next";
@@ -17,6 +17,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
selected,
...props
}) => {
if (Platform.isTV) return null;
const audioStreams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
[source]
@@ -39,7 +40,9 @@ export const AudioTrackSelector: React.FC<Props> = ({
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<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">
<Text className="" numberOfLines={1}>
{selectedAudioSteam?.DisplayTitle}

View File

@@ -1,5 +1,5 @@
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
@@ -54,6 +54,7 @@ export const BitrateSelector: React.FC<Props> = ({
inverted,
...props
}) => {
if (Platform.isTV) return null;
const sorted = useMemo(() => {
if (inverted)
return BITRATES.sort(
@@ -77,7 +78,9 @@ export const BitrateSelector: React.FC<Props> = ({
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<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">
<Text style={{}} className="" numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key}

View File

@@ -1,6 +1,6 @@
import { useHaptic } from "@/hooks/useHaptic";
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";
export interface ButtonProps

View File

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

View File

View File

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

View File

View File

@@ -66,7 +66,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(settings?.defaultBitrate ?? {
key: "Max",
value: undefined,
});
@@ -194,10 +194,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
for (const item of items) {
if (itemsNotDownloaded.length > 1) {
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
item,
settings!
));
const defaults = getDefaultPlaySettings(item, settings!);
mediaSource = defaults.mediaSource;
audioIndex = defaults.audioIndex;
subtitleIndex = defaults.subtitleIndex;
// Keep using the selected bitrate for consistency across all downloads
}
const res = await getStreamUrl({

View File

@@ -3,6 +3,7 @@ import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
import { PlayButton } from "@/components/PlayButton";
import { PlayedStatus } from "@/components/PlayedStatus";
import { SimilarItems } from "@/components/SimilarItems";
@@ -15,7 +16,6 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom } from "@/providers/JellyfinProvider";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import {
@@ -24,12 +24,12 @@ import {
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai";
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 { Chromecast } from "./Chromecast";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSelector } from "./MediaSourceSelector";
@@ -81,23 +81,29 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
defaultMediaSource,
]);
useEffect(() => {
navigation.setOptions({
headerRight: () =>
item && (
<View className="flex flex-row items-center space-x-2">
<Chromecast background="blur" width={22} height={22} />
{item.Type !== "Program" && (
<View className="flex flex-row items-center space-x-2">
<DownloadSingleItem item={item} size="large" />
<PlayedStatus item={item} />
<AddToFavorites item={item} type="item" />
</View>
)}
</View>
),
});
}, [item]);
if (!Platform.isTV) {
useEffect(() => {
navigation.setOptions({
headerRight: () =>
item && (
<View className="flex flex-row items-center space-x-2">
<Chromecast.Chromecast
background="blur"
width={22}
height={22}
/>
{item.Type !== "Program" && (
<View className="flex flex-row items-center space-x-2">
<DownloadSingleItem item={item} size="large" />
<PlayedStatus items={[item]} size="large" />
<AddToFavorites item={item} type="item" />
</View>
)}
</View>
),
});
}, [item]);
}
useEffect(() => {
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
@@ -111,37 +117,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [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;
return (
@@ -189,9 +164,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
}
>
<View className="flex flex-col bg-transparent shrink">
{/* {!Platform.isTV && ( */}
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
<ItemHeader item={item} className="mb-4" />
{item.Type !== "Program" && (
{item.Type !== "Program" && !Platform.isTV && (
<View className="flex flex-row items-center justify-start w-full h-16">
<BitrateSelector
className="mr-1"
@@ -231,7 +207,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
selected={selectedOptions.audioIndex}
/>
<SubtitleTrackSelector
isTranscoding={isTranscoding}
source={selectedOptions.mediaSource}
onChange={(val) =>
setSelectedOptions(
@@ -247,11 +222,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View>
)}
{/* {!Platform.isTV && ( */}
<PlayButton
className="grow"
selectedOptions={selectedOptions}
item={item}
/>
{/* )} */}
</View>
{item.Type === "Episode" && (

View File

@@ -3,8 +3,8 @@ import {
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text";
import { useTranslation } from "react-i18next";
@@ -20,6 +20,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
selected,
...props
}) => {
if (Platform.isTV) return null;
const selectedName = useMemo(
() =>
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
@@ -61,7 +62,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<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">
<Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity>

View File

@@ -1,3 +1,4 @@
import { Platform } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
@@ -31,7 +32,9 @@ import Animated, {
} from "react-native-reanimated";
import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent";
import { chromecastProfile } from "@/utils/profiles/chromecast";
const chromecastProfile = !Platform.isTV
? require("@/utils/profiles/chromecast")
: null;
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
@@ -70,11 +73,7 @@ export const PlayButton: React.FC<Props> = ({
const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => {
if (!bitrateValue) {
router.push(`/player/direct-player?${q}`);
return;
}
router.push(`/player/transcoding-player?${q}`);
router.push(`/player/direct-player?${q}`);
},
[router]
);
@@ -114,99 +113,105 @@ export const PlayButton: React.FC<Props> = ({
switch (selectedIndex) {
case 0:
await CastContext.getPlayServicesState().then(async (state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
// Get a new URL with the Chromecast device profile:
const data = await getStreamUrl({
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 (!Platform.isTV) {
await CastContext.getPlayServicesState().then(async (state) => {
if (state && state !== PlayServicesState.SUCCESS) {
CastContext.showPlayServicesErrorDialog(state);
} else {
// Get a new URL with the Chromecast device profile:
try {
const data = await getStreamUrl({
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) {
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) {
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;
}
CastContext.showExpandedControls();
});
}
});
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;
case 1:
goToPlayer(queryString, selectedOptions.bitrate?.value);

View File

@@ -0,0 +1,247 @@
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, bitrateValue: number | undefined) => {
router.push(`/player/direct-player?${q}`);
},
[router]
);
const onPress = useCallback(async () => {
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, selectedOptions.bitrate?.value);
return;
}, [
item,
settings,
api,
user,
router,
showActionSheetWithOptions,
selectedOptions,
]);
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 (
<View>
<TouchableOpacity
disabled={!item}
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 className="mt-2 flex flex-row items-center">
<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

@@ -6,16 +6,19 @@ import { View, ViewProps } from "react-native";
import { RoundButton } from "./RoundButton";
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 invalidateQueries = () => {
queryClient.invalidateQueries({
queryKey: ["item", item.Id],
});
items.forEach((item) => {
queryClient.invalidateQueries({
queryKey: ["item", item.Id],
});
})
queryClient.invalidateQueries({
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 (
<View {...props}>
<RoundButton
fillColor={item.UserData?.Played ? "primary" : undefined}
icon={item.UserData?.Played ? "checkmark" : "checkmark"}
onPress={() => markAsPlayedStatus(item.UserData?.Played || false)}
size="large"
fillColor={allPlayed ? "primary" : undefined}
icon={allPlayed ? "checkmark" : "checkmark"}
onPress={async () => {
console.log(allPlayed);
await markAsPlayedStatus(!allPlayed)
}}
size={props.size}
/>
</View>
);

View File

@@ -2,41 +2,33 @@ import { tc } from "@/utils/textTools";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
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 { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
onChange: (value: number) => void;
selected?: number | undefined;
isTranscoding?: boolean;
}
export const SubtitleTrackSelector: React.FC<Props> = ({
source,
onChange,
selected,
isTranscoding,
...props
}) => {
if (Platform.isTV) return null;
const subtitleStreams = useMemo(() => {
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
if (isTranscoding && Platform.OS === "ios") {
return subtitleHelper.getUniqueSubtitles();
}
return subtitleHelper.getSubtitles();
}, [source, isTranscoding]);
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
}, [source]);
const selectedSubtitleSteam = useMemo(
() => subtitleStreams.find((x) => x.Index === selected),
() => subtitleStreams?.find((x) => x.Index === selected),
[subtitleStreams, selected]
);
if (subtitleStreams.length === 0) return null;
if (subtitleStreams?.length === 0) return null;
const { t } = useTranslation();
@@ -51,7 +43,9 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col " {...props}>
<Text className="opacity-50 mb-1 text-xs">{t("item_card.subtitles")}</Text>
<Text 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">
<Text className=" ">
{selectedSubtitleSteam

View File

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

View File

@@ -1,10 +1,24 @@
import React from "react";
import { TextInput, TextInputProps } from "react-native";
import {Platform, TextInput, TextInputProps, TouchableOpacity} from "react-native";
export function Input(props: TextInputProps) {
const { style, ...otherProps } = props;
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
ref={inputRef}
className="p-4 rounded-xl bg-neutral-900"
@@ -14,5 +28,5 @@ export function Input(props: TextInputProps) {
clearButtonMode="while-editing"
{...otherProps}
/>
);
)
}

View File

@@ -1,11 +1,14 @@
import {useRouter, useSegments} from "expo-router";
import React, {PropsWithChildren, useCallback, useMemo} from "react";
import {TouchableOpacity, TouchableOpacityProps} from "react-native";
import * as ContextMenu from "zeego/context-menu";
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {useJellyseerr} from "@/hooks/useJellyseerr";
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
import { useRouter, useSegments } from "expo-router";
import React, { PropsWithChildren, useCallback, useMemo } from "react";
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
import * as ContextMenu from "@/components/ContextMenu";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import {
hasPermission,
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
interface Props extends TouchableOpacityProps {
result: MovieResult | TvResult;
@@ -26,26 +29,27 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
}) => {
const router = useRouter();
const segments = useSegments();
const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr()
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const from = segments[2];
const autoApprove = useMemo(() => {
return jellyseerrUser && hasPermission(
Permission.AUTO_APPROVE,
jellyseerrUser.permissions,
{type: 'or'}
)
}, [jellyseerrApi, jellyseerrUser])
return (
jellyseerrUser &&
hasPermission(Permission.AUTO_APPROVE, jellyseerrUser.permissions, {
type: "or",
})
);
}, [jellyseerrApi, jellyseerrUser]);
const request = useCallback(() =>
const request = useCallback(
() =>
requestMedia(mediaTitle, {
mediaId: result.id,
mediaType: result.mediaType
}
),
mediaType: result.mediaType,
}),
[jellyseerrApi, result]
)
);
if (from === "(home)" || from === "(search)" || from === "(libraries)")
return (
@@ -55,7 +59,16 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity
onPress={() => {
// @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}
>
@@ -71,31 +84,33 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
>
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
{canRequest && result.mediaType === MediaType.MOVIE && (
<ContextMenu.Item
key="item-1"
onSelect={() => {
if (autoApprove) {
request()
}
<ContextMenu.Item
key="item-1"
onSelect={() => {
if (autoApprove) {
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
>
<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",
},
}}
androidIconName="download"
/>
</ContextMenu.Item>
)}
androidIconName="download"
/>
</ContextMenu.Item>
)}
</ContextMenu.Content>
</ContextMenu.Root>
</>

View File

@@ -6,9 +6,8 @@ import {
import { useRouter, useSegments } from "expo-router";
import { PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
import * as ContextMenu from "zeego/context-menu";
import { useActionSheet } from "@expo/react-native-action-sheet";
import * as Haptics from "expo-haptics";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends TouchableOpacityProps {
item: BaseItemDto;
@@ -57,7 +56,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
const router = useRouter();
const segments = useSegments();
const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed(item);
const markAsPlayedStatus = useMarkAsPlayed([item]);
const from = segments[2];
@@ -75,10 +74,10 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
async (selectedIndex) => {
if (selectedIndex === 0) {
await markAsPlayedStatus(true);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} else if (selectedIndex === 1) {
await markAsPlayedStatus(false);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
}
);

View File

@@ -4,12 +4,16 @@ import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { JobStatus } from "@/utils/optimize-server";
import { formatTimeString } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader";
const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { FFmpegKit } from "ffmpeg-kit-react-native";
const FFmpegKitProvider = !Platform.isTV ? require("ffmpeg-kit-react-native") : null;
import { useAtom } from "jotai";
import {
ActivityIndicator,
Platform,
TouchableOpacity,
TouchableOpacityProps,
View,
@@ -38,7 +42,7 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold mb-2">{t("home.downloads.active_downloads")}</Text>
<View className="space-y-2">
{processes?.map((p) => (
{processes?.map((p: JobStatus) => (
<DownloadCard key={p.item.Id} process={p} />
))}
</View>
@@ -63,7 +67,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
if (settings?.downloadMethod === DownloadMethod.Optimized) {
try {
const tasks = await checkForExistingDownloads();
const tasks = await BackGroundDownloader.checkForExistingDownloads();
for (const task of tasks) {
if (task.id === id) {
task.stop();
@@ -76,8 +80,8 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
await queryClient.refetchQueries({ queryKey: ["jobs"] });
}
} else {
FFmpegKit.cancel(Number(id));
setProcesses((prev) => prev.filter((p) => p.id !== id));
FFmpegKitProvider.FFmpegKit.cancel(Number(id));
setProcesses((prev: any[]) => prev.filter((p: { id: string; }) => p.id !== id));
}
},
onSuccess: () => {

View File

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

View File

@@ -122,7 +122,7 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
},
onRequested
)
}, [requestOverrides, defaultProfile, defaultFolder, defaultTags]);
}, [modalRequestProps, requestOverrides, defaultProfile, defaultFolder, defaultTags]);
const pathTitleExtractor = (item: RootFolder) => `${item.path} (${item.freeSpace.bytesToReadable()})`;

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"
id={item.id.toString()}
title={item.name}
colors={[]}
colors={['transparent', 'transparent']}
contentFit={"cover"}
url={jellyseerrApi?.imageProxy(
item.backdrops?.[0],

View File

@@ -23,6 +23,8 @@ import { Loader } from "../Loader";
import { t } from "i18next";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
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<{
details: TvDetails;
@@ -52,26 +54,51 @@ const JellyseerrSeasonEpisodes: React.FC<{
};
const RenderItem = ({ item, index }: any) => {
const { jellyseerrApi } = useJellyseerr();
const { jellyseerrApi, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
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 (
<View className="flex flex-col w-44 mt-2">
<View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
{!imageError ? (
<Image
key={item.id}
id={item.id}
source={{
uri: jellyseerrApi?.imageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
onError={(e) => {
setImageError(true);
}}
/>
<>
<Image
key={item.id}
id={item.id}
source={{
uri: jellyseerrApi?.imageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
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">
<Ionicons

View File

@@ -1,7 +1,7 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "../common/Text";
import { t } from "i18next";
@@ -30,6 +30,8 @@ export const SeasonDropdown: React.FC<Props> = ({
state,
onSelect,
}) => {
if (Platform.isTV) return null;
const keys = useMemo<SeasonKeys>(
() =>
item.Type === "Episode"
@@ -92,7 +94,9 @@ export const SeasonDropdown: React.FC<Props> = ({
<DropdownMenu.Trigger>
<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">
<Text>{t("item_card.season")} {seasonIndex}</Text>
<Text>
{t("item_card.season")} {seasonIndex}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>

View File

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

View File

@@ -1,5 +1,5 @@
import * as DropdownMenu from "zeego/dropdown-menu";
import { TouchableOpacity, View, ViewProps } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Platform, TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "../common/Text";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
@@ -10,6 +10,7 @@ import { APP_LANGUAGES } from "@/i18n";
interface Props extends ViewProps {}
export const AppLanguageSelector: React.FC<Props> = ({ ...props }) => {
if (Platform.isTV) return null;
const [settings, updateSettings] = useSettings();
const { t } = useTranslation();
@@ -17,60 +18,58 @@ export const AppLanguageSelector: React.FC<Props> = ({ ...props }) => {
return (
<View>
<ListGroup
title={t("home.settings.languages.title")}
>
<ListGroup title={t("home.settings.languages.title")}>
<ListItem title={t("home.settings.languages.app_language")}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{APP_LANGUAGES.find(
(l) => l.value === settings?.preferedLanguage
)?.label || t("home.settings.languages.system")}
</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>
{t("home.settings.languages.title")}
</DropdownMenu.Label>
<DropdownMenu.Item
key={"unknown"}
onSelect={() => {
updateSettings({
preferedLanguage: undefined,
});
}}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{APP_LANGUAGES.find(
(l) => l.value === settings?.preferedLanguage
)?.label || t("home.settings.languages.system")}
</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.ItemTitle>
{t("home.settings.languages.system")}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{APP_LANGUAGES?.map((l) => (
<DropdownMenu.Label>
{t("home.settings.languages.title")}
</DropdownMenu.Label>
<DropdownMenu.Item
key={l?.value ?? "unknown"}
key={"unknown"}
onSelect={() => {
updateSettings({
preferedLanguage: l.value,
preferedLanguage: undefined,
});
}}
>
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
<DropdownMenu.ItemTitle>
{t("home.settings.languages.system")}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem>
</ListGroup>
</View>
{APP_LANGUAGES?.map((l) => (
<DropdownMenu.Item
key={l?.value ?? "unknown"}
onSelect={() => {
updateSettings({
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 * as DropdownMenu from "zeego/dropdown-menu";
import { Platform, TouchableOpacity, View, ViewProps } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "../common/Text";
import { useMedia } from "./MediaContext";
import { Switch } from "react-native-gesture-handler";
@@ -7,11 +7,12 @@ import { useTranslation } from "react-i18next";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { Ionicons } from "@expo/vector-icons";
import {useSettings} from "@/utils/atoms/settings";
import { useSettings } from "@/utils/atoms/settings";
interface Props extends ViewProps {}
export const AudioToggles: React.FC<Props> = ({ ...props }) => {
if (Platform.isTV) return null;
const media = useMedia();
const [_, __, pluginSettings] = useSettings();
const { settings, updateSettings } = media;
@@ -47,7 +48,8 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
<DropdownMenu.Trigger>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3 ">
<Text className="mr-1 text-[#8E8D91]">
{settings?.defaultAudioLanguage?.DisplayName || t("home.settings.audio.none")}
{settings?.defaultAudioLanguage?.DisplayName ||
t("home.settings.audio.none")}
</Text>
<Ionicons
name="chevron-expand-sharp"
@@ -65,7 +67,9 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>{t("home.settings.audio.language")}</DropdownMenu.Label>
<DropdownMenu.Label>
{t("home.settings.audio.language")}
</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-audio"}
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>
{cultures?.map((l) => (
<DropdownMenu.Item

View File

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

View File

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

View File

@@ -0,0 +1,485 @@
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 { 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 = {
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 { 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 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 [settings, updateSettings, pluginSettings] = useSettings();
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
useState<boolean>(false);
const [jellyseerrPassword, setJellyseerrPassword] = useState<
string | undefined
>(undefined);
@@ -39,11 +36,16 @@ export const JellyseerrSettings = () => {
const loginToJellyseerrMutation = useMutation({
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");
}
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
return jellyseerrTempApi.login(user.Name, jellyseerrPassword);
const jellyseerrTempApi = new JellyseerrApi(
jellyseerrServerUrl || settings.jellyseerrServerUrl || ""
);
const testResult = await jellyseerrTempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url");
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
},
onSuccess: (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 = () => {
clearAllJellyseerData().finally(() => {
setJellyseerrUser(undefined);
setJellyseerrPassword(undefined);
setjellyseerrServerUrl(undefined);
setPromptForJellyseerrPass(false);
});
};
@@ -92,34 +74,46 @@ export const JellyseerrSettings = () => {
<>
<ListGroup title={"Jellyseerr"}>
<ListItem
title={t("home.settings.plugins.jellyseerr.total_media_requests")}
title={t(
"home.settings.plugins.jellyseerr.total_media_requests"
)}
value={jellyseerrUser?.requestCount?.toString()}
/>
<ListItem
title={t("home.settings.plugins.jellyseerr.movie_quota_limit")}
value={
jellyseerrUser?.movieQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")
jellyseerrUser?.movieQuotaLimit?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
/>
<ListItem
title={t("home.settings.plugins.jellyseerr.movie_quota_days")}
value={
jellyseerrUser?.movieQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")
jellyseerrUser?.movieQuotaDays?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
/>
<ListItem
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
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>
<View className="p-4">
<Button color="red" onPress={clearData}>
{t("home.settings.plugins.jellyseerr.reset_jellyseerr_config_button")}
{t(
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button"
)}
</Button>
</View>
</>
@@ -128,15 +122,20 @@ export const JellyseerrSettings = () => {
<Text className="text-xs text-red-600 mb-2">
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</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">
<Text className="text-xs text-gray-600">
{t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text>
</View>
<Input
placeholder={t("home.settings.plugins.jellyseerr.server_url_placeholder")}
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl}
className="border border-neutral-800 mb-2"
placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder"
)}
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
}
@@ -145,40 +144,20 @@ export const JellyseerrSettings = () => {
autoCapitalize="none"
textContentType="URL"
onChangeText={setjellyseerrServerUrl}
editable={!testJellyseerrServerUrlMutation.isPending}
editable={!loginToJellyseerrMutation.isPending}
/>
<Button
loading={testJellyseerrServerUrlMutation.isPending}
disabled={testJellyseerrServerUrlMutation.isPending}
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>
<View>
<Text className="font-bold mb-2">
{t("home.settings.plugins.jellyseerr.password")}
</Text>
<Input
className="border border-neutral-800"
autoFocus={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}
keyboardType="default"
secureTextEntry={true}
@@ -186,10 +165,7 @@ export const JellyseerrSettings = () => {
autoCapitalize="none"
textContentType="password"
onChangeText={setJellyseerrPassword}
editable={
!loginToJellyseerrMutation.isPending &&
promptForJellyseerrPass
}
editable={!loginToJellyseerrMutation.isPending}
/>
<Button
loading={loginToJellyseerrMutation.isPending}

View File

@@ -1,14 +1,18 @@
import { Platform } from "react-native";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { BitrateSelector, BITRATES } from "@/components/BitrateSelector";
import {
BACKGROUND_FETCH_TASK,
registerBackgroundFetchAsync,
unregisterBackgroundFetchAsync,
} from "@/utils/background-tasks";
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 * as ScreenOrientation from "expo-screen-orientation";
import * as TaskManager from "expo-task-manager";
import React, { useEffect, useMemo } from "react";
import { Linking, Switch, TouchableOpacity } from "react-native";
import { toast } from "sonner-native";
@@ -18,6 +22,7 @@ import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
import Dropdown from "@/components/common/Dropdown";
import { RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY } from "@/utils/recently-added-notifications";
export const OtherSettings: React.FC = () => {
const router = useRouter();
@@ -29,6 +34,8 @@ export const OtherSettings: React.FC = () => {
* Background task
*******************/
const checkStatusAsync = async () => {
if (Platform.isTV) return;
await BackgroundFetch.getStatusAsync();
return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
};
@@ -158,6 +165,32 @@ export const OtherSettings: React.FC = () => {
title={t("home.settings.other.hide_libraries")}
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}
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
title={t("home.settings.other.disable_haptic_feedback")}
disabled={pluginSettings?.disableHapticFeedback?.locked}

View File

@@ -49,16 +49,25 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
});
if (res.status === 200) {
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);
bottomSheetModalRef?.current?.close();
} else {
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) {
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]);
@@ -96,7 +105,9 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
<BottomSheetTextInput
style={{ color: "white" }}
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"
value={quickConnectCode}
onChangeText={setQuickConnectCode}

View File

@@ -0,0 +1,56 @@
import settings from "@/app/(auth)/(tabs)/(home)/settings";
import { Switch, View } from "react-native";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { useSettings } from "@/utils/atoms/settings";
import React, { useCallback, useEffect, useMemo } from "react";
import { BACKGROUND_FETCH_TASK_RECENTLY_ADDED } from "@/utils/background-tasks";
import { storage } from "@/utils/mmkv";
import { RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY } from "@/utils/recently-added-notifications";
import * as TaskManager from "expo-task-manager";
import * as BackgroundFetch from "expo-background-fetch";
import { useMMKVNumber } from "react-native-mmkv";
export const RecentlyAddedNotificationsSettings: React.FC = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
const clearRecentlyAddedNotifications = useCallback(() => {
storage.delete(RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY);
}, []);
const recentlyAddedNotificationsItemIds = useMemo(() => {
const s = storage.getString(RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY);
if (!s) return [] as string[];
try {
const t: string[] = JSON.parse(s);
return t;
} catch (e) {
throw new Error("Failed to parse recently added notifications item ids");
}
}, []);
const [triggerCount, setTriggerCount] = useMMKVNumber(
"notification_send_for_item_ids.count"
);
return (
<View className="mb-4" {...props}>
<ListGroup title={"Recently Added Notifications"}>
<ListItem title={"Recently added notifications"}>
<Switch
value={settings.recentlyAddedNotifications}
onValueChange={(recentlyAddedNotifications) =>
updateSettings({ recentlyAddedNotifications })
}
/>
</ListItem>
<ListItem title={`Trigger count (${triggerCount || 0})`} />
<ListItem
textColor="red"
onPress={clearRecentlyAddedNotifications}
title={`Reset recently added notifications (${recentlyAddedNotificationsItemIds.length})`}
/>
</ListGroup>
</View>
);
};

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 SettingsIndex = () => {
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

@@ -8,6 +8,9 @@ import { toast } from "sonner-native";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
import { storage } from "@/utils/mmkv";
import { RECENTLY_ADDED_SENT_NOTIFICATIONS_ITEM_IDS_KEY } from "@/utils/recently-added-notifications";
import { useCallback, useMemo } from "react";
export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
@@ -41,6 +44,7 @@ export const StorageSettings = () => {
return ((value / total) * 100).toFixed(2);
};
return (
<View>
<View className="flex flex-col gap-y-1">
@@ -48,7 +52,10 @@ export const StorageSettings = () => {
<Text className="">{t("home.settings.storage.storage_title")}</Text>
{size && (
<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>
)}
</View>
@@ -79,13 +86,20 @@ export const StorageSettings = () => {
<View className="flex flex-row items-center">
<View className="w-3 h-3 rounded-full bg-purple-600 mr-1"></View>
<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>
</View>
<View className="flex flex-row items-center">
<View className="w-3 h-3 rounded-full bg-purple-400 mr-1"></View>
<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>
</View>
</>
@@ -99,6 +113,7 @@ export const StorageSettings = () => {
title={t("home.settings.storage.delete_all_downloaded_files")}
/>
</ListGroup>
</View>
);
};

View File

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

View File

@@ -1,8 +1,11 @@
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 { 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";
interface AudioSliderProps {
@@ -10,6 +13,8 @@ interface AudioSliderProps {
}
const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
if (Platform.isTV) return;
const volume = useSharedValue<number>(50); // Explicitly type as number
const min = useSharedValue<number>(0); // 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 { View, StyleSheet } from "react-native";
import { View, StyleSheet, Platform } from "react-native";
import { useSharedValue } from "react-native-reanimated";
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 MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
const BrightnessSlider = () => {
if (Platform.isTV) return;
const brightness = useSharedValue(50);
const min = useSharedValue(0);
const max = useSharedValue(100);

View File

@@ -24,18 +24,23 @@ import {
ticksToMs,
ticksToSeconds,
} from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
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 { debounce } from "lodash";
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 {
runOnJS,
@@ -49,8 +54,7 @@ import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider";
import { ControlProvider } from "./contexts/ControlContext";
import { VideoProvider } from "./contexts/VideoContext";
import DropdownViewDirect from "./dropdown/DropdownViewDirect";
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
import DropdownView from "./dropdown/DropdownView";
import { EpisodeList } from "./EpisodeList";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
@@ -75,6 +79,7 @@ interface Props {
isVideoLoaded?: boolean;
mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void;
startPictureInPicture: () => Promise<void>;
play: (() => Promise<void>) | (() => void);
pause: () => void;
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
@@ -91,6 +96,7 @@ const CONTROLS_TIMEOUT = 4000;
export const Controls: React.FC<Props> = ({
item,
seek,
startPictureInPicture,
play,
pause,
togglePlay,
@@ -212,13 +218,10 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(),
}).toString();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
stop();
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
router.replace(`player/direct-player?${queryParams}`);
}, [previousItem, settings, subtitleIndex, audioIndex]);
const goToNextItem = useCallback(() => {
@@ -250,13 +253,10 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(),
}).toString();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
stop();
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
router.replace(`player/direct-player?${queryParams}`);
}, [nextItem, settings, subtitleIndex, audioIndex]);
const updateTimes = useCallback(
@@ -413,13 +413,10 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrateValue.toString(),
}).toString();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
stop();
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
router.replace(`player/direct-player?${queryParams}`);
} catch (error) {
console.error("Error in gotoEpisode:", error);
}
@@ -499,6 +496,15 @@ export const Controls: React.FC<Props> = ({
);
}, [trickPlayUrl, trickplayInfo, time]);
const onClose = async () => {
stop();
lightHapticFeedback();
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
router.back();
};
return (
<ControlProvider
item={item}
@@ -542,15 +548,25 @@ export const Controls: React.FC<Props> = ({
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
>
{!mediaSource?.TranscodingUrl ? (
<DropdownViewDirect showControls={showControls} />
) : (
<DropdownViewTranscoding showControls={showControls} />
)}
<DropdownView showControls={showControls} />
</VideoProvider>
</View>
<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 && (
<TouchableOpacity
onPress={() => {
@@ -592,13 +608,7 @@ export const Controls: React.FC<Props> = ({
</TouchableOpacity>
{/* )} */}
<TouchableOpacity
onPress={async () => {
lightHapticFeedback();
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
router.back();
}}
onPress={onClose}
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />

View File

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

View File

@@ -9,12 +9,15 @@ import React, {
useState,
ReactNode,
useEffect,
useMemo,
} from "react";
import { useControlContext } from "./ControlContext";
import { Track } from "../types";
import { router, useLocalSearchParams } from "expo-router";
interface VideoContextProps {
audioTracks: TrackInfo[] | null;
subtitleTracks: TrackInfo[] | null;
audioTracks: Track[] | null;
subtitleTracks: Track[] | null;
setAudioTrack: ((index: number) => void) | undefined;
setSubtitleTrack: ((index: number) => void) | undefined;
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
@@ -45,30 +48,155 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
setSubtitleURL,
setAudioTrack,
}) => {
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
null
);
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
const ControlContext = useControlContext();
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(() => {
const fetchTracks = async () => {
if (
getSubtitleTracks &&
(subtitleTracks === null || subtitleTracks.length === 0)
) {
const subtitles = await getSubtitleTracks();
console.log("Getting embeded subtitles...", subtitles);
if (getSubtitleTracks) {
const subtitleData = await getSubtitleTracks();
let textSubIndex = 0;
const subtitles: Track[] = allSubs?.map((sub) => {
// Always increment for non-transcoding subtitles
// Only increment for text-based subtitles when transcoding
const shouldIncrement =
!mediaSource?.TranscodingUrl || sub.IsTextSubtitleStream;
const displayTitle = sub.DisplayTitle || "Undefined Subtitle";
const vlcIndex = subtitleData?.at(textSubIndex)?.index ?? -1;
const finalIndex = shouldIncrement ? vlcIndex : sub.Index ?? -1;
if (shouldIncrement) textSubIndex++;
return {
name: displayTitle,
index: sub.Index ?? -1,
originalIndex: finalIndex,
setTrack: () =>
shouldIncrement
? setTrackParams("subtitle", finalIndex, sub.Index ?? -1)
: setPlayerParams({
chosenSubtitleIndex: sub.Index?.toString(),
}),
};
});
// Add a "Disable Subtitles" option
subtitles.unshift({
name: "Disable",
index: -1,
setTrack: () =>
!mediaSource?.TranscodingUrl || onTextBasedSubtitle
? setTrackParams("subtitle", -1, -1)
: setPlayerParams({ chosenSubtitleIndex: "-1" }),
});
setSubtitleTracks(subtitles);
}
if (
getAudioTracks &&
(audioTracks === null || audioTracks.length === 0)
) {
const audio = await getAudioTracks();
setAudioTracks(audio);
const audioData = await getAudioTracks();
if (!audioData) return;
console.log("audioData", audioData);
const allAudio =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
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();

View File

@@ -0,0 +1,97 @@
import React 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 } from "expo-router";
interface DropdownViewProps {
showControls: boolean;
offline?: boolean; // used to disable external subs for downloads
}
const DropdownView: React.FC<DropdownViewProps> = ({
showControls,
offline = false,
}) => {
const videoContext = useVideoContext();
const { subtitleTracks, audioTracks } = videoContext;
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}
>
{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 = {
name: string;
index: number;
deliveryUrl: string;
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"
}
},
"development_tv": {
"developmentClient": true,
"distribution": "internal",
"android": {
"buildType": "apk"
},
"env": {
"EXPO_TV": "1"
}
},
"preview": {
"distribution": "internal"
},
@@ -22,17 +32,27 @@
}
},
"production": {
"channel": "0.25.0",
"channel": "0.27.0",
"android": {
"image": "latest"
}
},
"production-apk": {
"channel": "0.25.0",
"channel": "0.27.0",
"android": {
"buildType": "apk",
"image": "latest"
}
},
"production-apk-tv": {
"channel": "0.27.0",
"android": {
"buildType": "apk",
"image": "latest"
},
"env": {
"EXPO_TV": "1"
}
}
},
"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"
)?.Index;
// 4. Get default bitrate
const bitrate = BITRATES[0];
// 4. Get default bitrate from settings or fallback to max
const bitrate = settings?.defaultBitrate ?? BITRATES[0];
return {
defaultAudioIndex:

View File

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

View File

@@ -10,7 +10,9 @@ import { storage } from "@/utils/mmkv";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom, useAtomValue } from "jotai";
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.
@@ -28,6 +30,8 @@ export const useImageColors = ({
url?: string | null;
disabled?: boolean;
}) => {
if (Platform.isTV) return;
const api = useAtomValue(apiAtom);
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
@@ -62,11 +66,11 @@ export const useImageColors = ({
}
// Extract colors from the image
getColors(source.uri, {
Colors.getColors(source.uri, {
fallback: "#fff",
cache: false,
})
.then((colors) => {
.then((colors: { platform: string; dominant: string; vibrant: string; detail: string; primary: string; }) => {
let primary: string = "#fff";
let text: string = "#000";
let backup: string = "#fff";
@@ -100,7 +104,7 @@ export const useImageColors = ({
storage.set(`${source.uri}-text`, text);
}
})
.catch((error) => {
.catch((error: any) => {
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 {
jellyseerrApi,
jellyseerrUser,
setJellyseerrUser,
clearAllJellyseerData,
isJellyseerrResult,
jellyseerrRegion,
jellyseerrLocale,
requestMedia,
};
};

View File

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

View File

@@ -1,12 +1,17 @@
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 { Platform } from "react-native";
export const useOrientation = () => {
const [orientation, setOrientation] = useState(
ScreenOrientation.OrientationLock.UNKNOWN
Platform.isTV
? ScreenOrientation.OrientationLock.LANDSCAPE
: ScreenOrientation.OrientationLock.UNKNOWN
);
if (Platform.isTV) return { orientation, setOrientation };
useEffect(() => {
const orientationSubscription =
ScreenOrientation.addOrientationChangeListener((event) => {

View File

@@ -1,8 +1,11 @@
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 { Platform } from "react-native";
export const useOrientationSettings = () => {
if (Platform.isTV) return;
const [settings] = useSettings();
useEffect(() => {

View File

@@ -9,7 +9,9 @@ import {
import { useQueryClient } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router";
import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
// import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
const FFMPEGKitReactNative = !Platform.isTV ? require("ffmpeg-kit-react-native") : null;
import { useAtomValue } from "jotai";
import { useCallback } from "react";
import { toast } from "sonner-native";
@@ -18,8 +20,12 @@ import useDownloadHelper from "@/utils/download";
import { Api } from "@jellyfin/sdk";
import { useSettings } from "@/utils/atoms/settings";
import { JobStatus } from "@/utils/optimize-server";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
type FFmpegSession = typeof FFMPEGKitReactNative.FFmpegSession;
type Statistics = typeof FFMPEGKitReactNative.Statistics
const FFmpegKit = FFMPEGKitReactNative.FFmpegKit;
const createFFmpegCommand = (url: string, output: string) => [
"-y", // overwrite output files without asking
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
@@ -55,7 +61,12 @@ export const useRemuxHlsToMp4 = () => {
const [settings] = useSettings();
const { saveImage } = useImageStorage();
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) => {
await saveSeriesPrimaryImage(item);
@@ -79,9 +90,9 @@ export const useRemuxHlsToMp4 = () => {
if (returnCode.isValueSuccess()) {
const stat = await session.getLastReceivedStatistics();
await FileSystem.moveAsync({
from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`,
to: `${FileSystem.documentDirectory}${item.Id}.mp4`
})
from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`,
to: `${FileSystem.documentDirectory}${item.Id}.mp4`,
});
await queryClient.invalidateQueries({
queryKey: ["downloadedItems"],
});
@@ -89,8 +100,8 @@ export const useRemuxHlsToMp4 = () => {
toast.success(t("home.downloads.toasts.download_completed"));
}
setProcesses((prev) => {
return prev.filter((process) => process.itemId !== item.Id);
setProcesses((prev: any[]) => {
return prev.filter((process: { itemId: string | undefined; }) => process.itemId !== item.Id);
});
} catch (e) {
console.error(e);
@@ -114,8 +125,8 @@ export const useRemuxHlsToMp4 = () => {
totalFrames > 0 ? Math.floor((processedFrames / totalFrames) * 100) : 0;
if (!item.Id) throw new Error("Item is undefined");
setProcesses((prev) => {
return prev.map((process) => {
setProcesses((prev: any[]) => {
return prev.map((process: { itemId: string | undefined; }) => {
if (process.itemId === item.Id) {
return {
...process,
@@ -133,12 +144,16 @@ export const useRemuxHlsToMp4 = () => {
const startRemuxing = useCallback(
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) {
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 (!item.Id) throw new Error("Item must have an Id");
@@ -170,13 +185,13 @@ export const useRemuxHlsToMp4 = () => {
};
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
setProcesses((prev) => [...prev, job]);
setProcesses((prev: any) => [...prev, job]);
await FFmpegKit.executeAsync(
createFFmpegCommand(url, output).join(" "),
(session) => completeCallback(session, item),
(session: any) => completeCallback(session, item),
undefined,
(s) => statisticsCallback(s, item)
(s: any) => statisticsCallback(s, item)
);
} catch (e) {
const error = e as Error;
@@ -185,8 +200,8 @@ export const useRemuxHlsToMp4 = () => {
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
Error: ${error.message}, Stack: ${error.stack}`
);
setProcesses((prev) => {
return prev.filter((process) => process.itemId !== item.Id);
setProcesses((prev: any[]) => {
return prev.filter((process: { itemId: string | undefined; }) => process.itemId !== item.Id);
});
throw error; // Re-throw the error to propagate it to the caller
}

18
i18n.ts
View File

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

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'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
plugins {
id 'com.android.library'
id 'kotlin-android'
id 'kotlin-kapt'
}
group = 'expo.modules.vlcplayer'
version = '0.6.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()
@@ -37,8 +42,8 @@ if (useManagedAndroidSdkVersions) {
}
dependencies {
implementation 'org.videolan.android:libvlc-all:3.6.0-eap12'
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.5.31"
implementation 'org.videolan.android:libvlc-all:3.6.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
}
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
import androidx.core.os.bundleOf
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
@@ -7,6 +8,18 @@ class VlcPlayerModule : Module() {
override fun definition() = ModuleDefinition {
Name("VlcPlayer")
OnActivityEntersForeground {
VLCManager.listeners.forEach {
it.onResume(appContext.currentActivity)
}
}
OnActivityEntersBackground {
VLCManager.listeners.forEach {
it.onPause(appContext.currentActivity)
}
}
View(VlcPlayerView::class) {
Prop("source") { view: VlcPlayerView, source: Map<String, Any> ->
view.setSource(source)
@@ -26,9 +39,14 @@ class VlcPlayerModule : Module() {
"onVideoLoadStart",
"onVideoLoadEnd",
"onVideoProgress",
"onVideoError"
"onVideoError",
"onPipStarted"
)
AsyncFunction("startPictureInPicture") { view: VlcPlayerView ->
view.startPictureInPicture()
}
AsyncFunction("play") { view: VlcPlayerView ->
view.play()
}

View File

@@ -1,23 +1,49 @@
package expo.modules.vlcplayer
import android.R
import android.app.Activity
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.IntentFilter
import android.graphics.drawable.Icon
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.ViewGroup
import android.widget.FrameLayout
import android.view.View
import androidx.annotation.RequiresApi
import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import android.net.Uri
import androidx.lifecycle.OnLifecycleEvent
import expo.modules.core.interfaces.ReactActivityLifecycleListener
import expo.modules.core.logging.LogHandlers
import expo.modules.core.logging.Logger
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.views.ExpoView
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
import org.videolan.libvlc.LibVLC
import org.videolan.libvlc.Media
import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.util.VLCVideoLayout
class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener {
class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener, ReactActivityLifecycleListener {
private val log = Logger(listOf(LogHandlers.createOSLogHandler(this::class.simpleName!!)))
private val PIP_PLAY_PAUSE_ACTION = "PIP_PLAY_PAUSE_ACTION"
private val PIP_REWIND_ACTION = "PIP_REWIND_ACTION"
private val PIP_FORWARD_ACTION = "PIP_FORWARD_ACTION"
private var libVLC: LibVLC? = null
private var mediaPlayer: MediaPlayer? = null
@@ -26,10 +52,12 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var lastReportedState: Int? = null
private var lastReportedIsPlaying: Boolean? = null
private var media : Media? = null
private var timeLeft: Long? = null
private val onVideoProgress by EventDispatcher()
private val onVideoStateChange by EventDispatcher()
private val onVideoLoadEnd by EventDispatcher()
private val onPipStarted by EventDispatcher()
private var startPosition: Int? = 0
private var isMediaReady: Boolean = false
@@ -44,23 +72,146 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
handler.postDelayed(this, updateInterval)
}
}
private val currentActivity get() = context.findActivity()
private val actions: MutableList<RemoteAction> = mutableListOf()
private val remoteActionFilter = IntentFilter()
private val playPauseIntent: Intent = Intent(PIP_PLAY_PAUSE_ACTION).setPackage(context.packageName)
private val forwardIntent: Intent = Intent(PIP_FORWARD_ACTION).setPackage(context.packageName)
private val rewindIntent: Intent = Intent(PIP_REWIND_ACTION).setPackage(context.packageName)
private var actionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
PIP_PLAY_PAUSE_ACTION -> {
if (isPaused) play() else pause()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setupPipActions()
currentActivity.setPictureInPictureParams(getPipParams()!!)
}
}
PIP_FORWARD_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) + 15_000)
PIP_REWIND_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) - 15_000)
}
}
}
private var pipChangeListener: (PictureInPictureModeChangedInfo) -> Unit = { info ->
if (!info.isInPictureInPictureMode && mediaPlayer?.isPlaying == true) {
log.debug("Exiting PiP")
timeLeft = mediaPlayer?.time
pause()
// Setting the media after reattaching the view allows for a fast video view render
if (mediaPlayer?.vlcVout?.areViewsAttached() == false) {
mediaPlayer?.attachViews(videoLayout, null, false, false)
mediaPlayer?.media = media
mediaPlayer?.play()
timeLeft?.let { mediaPlayer?.time = it }
mediaPlayer?.pause()
}
}
onPipStarted(mapOf(
"pipStarted" to info.isInPictureInPictureMode
))
}
init {
VLCManager.listeners.add(this)
setupView()
setupPiP()
}
private fun setupView() {
Log.d("VlcPlayerView", "Setting up view")
log.debug("Setting up view")
setBackgroundColor(android.graphics.Color.WHITE)
videoLayout = VLCVideoLayout(context).apply {
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
}
videoLayout.keepScreenOn = true
addView(videoLayout)
Log.d("VlcPlayerView", "View setup complete")
log.debug("View setup complete")
}
private fun setupPiP() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
remoteActionFilter.addAction(PIP_PLAY_PAUSE_ACTION)
remoteActionFilter.addAction(PIP_FORWARD_ACTION)
remoteActionFilter.addAction(PIP_REWIND_ACTION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
currentActivity.registerReceiver(
actionReceiver,
remoteActionFilter,
Context.RECEIVER_NOT_EXPORTED
)
}
setupPipActions()
currentActivity.apply {
setPictureInPictureParams(getPipParams()!!)
addOnPictureInPictureModeChangedListener(pipChangeListener)
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun setupPipActions() {
actions.clear()
actions.addAll(
listOf(
RemoteAction(
Icon.createWithResource(context, R.drawable.ic_media_rew),
"Rewind",
"Rewind Video",
PendingIntent.getBroadcast(
context,
0,
rewindIntent,
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
)
),
RemoteAction(
if (isPaused) Icon.createWithResource(context, R.drawable.ic_media_play)
else Icon.createWithResource(context, R.drawable.ic_media_pause),
"Play",
"Play Video",
PendingIntent.getBroadcast(
context,
if (isPaused) 0 else 1,
playPauseIntent,
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
)
),
RemoteAction(
Icon.createWithResource(context, R.drawable.ic_media_ff),
"Skip",
"Skip Forward",
PendingIntent.getBroadcast(
context,
0,
forwardIntent,
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
)
)
)
)
}
private fun getPipParams(): PictureInPictureParams? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
var builder = PictureInPictureParams.Builder()
.setActions(actions)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder = builder.setAutoEnterEnabled(true)
}
return builder.build()
}
return null
}
fun setSource(source: Map<String, Any>) {
log.debug("setting source $source")
if (hasSource) {
log.debug("Source already set. Resuming")
mediaPlayer?.attachViews(videoLayout, null, false, false)
play()
return
@@ -85,12 +236,12 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
mediaPlayer?.attachViews(videoLayout, null, false, false)
mediaPlayer?.setEventListener(this)
Log.d("VlcPlayerView", "Loading network file: $uri")
log.debug("Loading network file: $uri")
media = Media(libVLC, Uri.parse(uri))
mediaPlayer?.media = media
Log.d("VlcPlayerView", "Debug: Media options: $mediaOptions")
log.debug("Debug: Media options: $mediaOptions")
// media.addOptions(mediaOptions)
// Apply subtitle options
@@ -107,11 +258,17 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
hasSource = true
if (autoplay) {
Log.d("VlcPlayerView", "Playing...")
log.debug("Playing...")
play()
}
}
fun startPictureInPicture() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
currentActivity.enterPictureInPictureMode(getPipParams()!!)
}
}
fun play() {
mediaPlayer?.play()
isPaused = false
@@ -151,9 +308,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
fun getAudioTracks(): List<Map<String, Any>>? {
println("getAudioTracks")
println(mediaPlayer?.getAudioTracks())
log.debug("getAudioTracks ${mediaPlayer?.audioTracks}")
val trackDescriptions = mediaPlayer?.audioTracks ?: return null
return trackDescriptions.map { trackDescription ->
@@ -177,19 +332,32 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
// Debug statement to print the result
Log.d("VlcPlayerView", "Subtitle Tracks: $subtitleTracks")
log.debug("Subtitle Tracks: $subtitleTracks")
return subtitleTracks
}
fun setSubtitleURL(subtitleURL: String, name: String) {
println("Setting subtitle URL: $subtitleURL, name: $name")
log.debug("Setting subtitle URL: $subtitleURL, name: $name")
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
}
override fun onDetachedFromWindow() {
println("onDetachedFromWindow")
log.debug("onDetachedFromWindow")
super.onDetachedFromWindow()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
currentActivity.setPictureInPictureParams(
PictureInPictureParams.Builder()
.setAutoEnterEnabled(false)
.build()
)
}
currentActivity.unregisterReceiver(actionReceiver)
currentActivity.removeOnPictureInPictureModeChangedListener(pipChangeListener)
VLCManager.listeners.clear()
mediaPlayer?.stop()
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
@@ -202,6 +370,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
override fun onEvent(event: MediaPlayer.Event) {
keepScreenOn = event.type == MediaPlayer.Event.Playing || event.type == MediaPlayer.Event.Buffering
when (event.type) {
MediaPlayer.Event.Playing,
MediaPlayer.Event.Paused,
@@ -223,35 +392,27 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
"target" to "null", // Replace with actual target if needed
"currentTime" to player.time.toInt(),
"duration" to (player.media?.duration?.toInt() ?: 0),
"error" to false
"error" to false,
"isPlaying" to (currentState == MediaPlayer.Event.Playing),
"isBuffering" to (!player.isPlaying && currentState == MediaPlayer.Event.Buffering)
)
// Todo: make enum - string to prevent this when statement from becoming exhaustive
when (currentState) {
MediaPlayer.Event.Playing -> {
stateInfo["isPlaying"] = true
stateInfo["isBuffering"] = false
MediaPlayer.Event.Playing ->
stateInfo["state"] = "Playing"
}
MediaPlayer.Event.Paused -> {
stateInfo["isPlaying"] = false
MediaPlayer.Event.Paused ->
stateInfo["state"] = "Paused"
}
MediaPlayer.Event.Buffering -> {
stateInfo["isBuffering"] = true
MediaPlayer.Event.Buffering ->
stateInfo["state"] = "Buffering"
}
MediaPlayer.Event.EncounteredError -> {
Log.e("VlcPlayerView", "player.state ~ error")
stateInfo["state"] = "Error"
onVideoLoadEnd(stateInfo);
}
MediaPlayer.Event.Opening -> {
Log.d("VlcPlayerView", "player.state ~ opening")
MediaPlayer.Event.Opening ->
stateInfo["state"] = "Opening"
}
}
if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) {
lastReportedState = currentState
lastReportedIsPlaying = player.isPlaying
@@ -283,4 +444,23 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
));
}
}
override fun onPause(activity: Activity?) {
log.debug("Pausing activity...")
}
override fun onResume(activity: Activity?) {
log.debug("Resuming activity...")
if (isPaused) play()
}
}
internal fun Context.findActivity(): androidx.activity.ComponentActivity {
var context = this
while (context is ContextWrapper) {
if (context is androidx.activity.ComponentActivity) return context
context = context.baseContext
}
throw IllegalStateException("Failed to find ComponentActivity")
}

View File

@@ -1,7 +1,8 @@
{
"platforms": ["ios", "tvos", "android", "web"],
"ios": {
"modules": ["VlcPlayerModule"]
"modules": ["VlcPlayerModule"],
"appDelegateSubscribers": ["AppLifecycleDelegate"]
},
"android": {
"modules": ["expo.modules.vlcplayer.VlcPlayerModule"]

View File

@@ -1,7 +1,6 @@
import {
NativeModulesProxy,
EventEmitter,
Subscription,
EventSubscription,
} from "expo-modules-core";
import VlcPlayerModule from "./src/VlcPlayerModule";
@@ -19,13 +18,11 @@ import {
VlcPlayerViewRef,
} from "./src/VlcPlayer.types";
const emitter = new EventEmitter(
VlcPlayerModule ?? NativeModulesProxy.VlcPlayer
);
const emitter = new EventEmitter(VlcPlayerModule);
export function addPlaybackStateListener(
listener: (event: PlaybackStatePayload) => void
): Subscription {
): EventSubscription {
return emitter.addListener<PlaybackStatePayload>(
"onPlaybackStateChanged",
listener
@@ -34,7 +31,7 @@ export function addPlaybackStateListener(
export function addVideoLoadStartListener(
listener: (event: VideoLoadStartPayload) => void
): Subscription {
): EventSubscription {
return emitter.addListener<VideoLoadStartPayload>(
"onVideoLoadStart",
listener
@@ -43,7 +40,7 @@ export function addVideoLoadStartListener(
export function addVideoStateChangeListener(
listener: (event: VideoStateChangePayload) => void
): Subscription {
): EventSubscription {
return emitter.addListener<VideoStateChangePayload>(
"onVideoStateChange",
listener
@@ -52,7 +49,7 @@ export function addVideoStateChangeListener(
export function addVideoProgressListener(
listener: (event: VideoProgressPayload) => void
): Subscription {
): EventSubscription {
return emitter.addListener<VideoProgressPayload>("onVideoProgress", listener);
}

View File

@@ -0,0 +1,32 @@
import ExpoModulesCore
protocol SimpleAppLifecycleListener {
func applicationDidEnterBackground() -> Void
func applicationDidEnterForeground() -> Void
}
public class AppLifecycleDelegate: ExpoAppDelegateSubscriber {
public func applicationDidBecomeActive(_ application: UIApplication) {
// The app has become active.
}
public func applicationWillResignActive(_ application: UIApplication) {
// The app is about to become inactive.
}
public func applicationDidEnterBackground(_ application: UIApplication) {
VLCManager.shared.listeners.forEach { listener in
listener.applicationDidEnterBackground()
}
}
public func applicationWillEnterForeground(_ application: UIApplication) {
VLCManager.shared.listeners.forEach { listener in
listener.applicationDidEnterForeground()
}
}
public func applicationWillTerminate(_ application: UIApplication) {
// The app is about to terminate.
}
}

View File

@@ -0,0 +1,4 @@
class VLCManager {
static let shared = VLCManager()
var listeners: [SimpleAppLifecycleListener] = []
}

View File

@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'VlcPlayer'
s.version = '1.0.0'
s.version = '4.0.0a10'
s.summary = 'A sample project summary'
s.description = 'A sample project description'
s.author = ''
@@ -10,7 +10,8 @@ Pod::Spec.new do |s|
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.dependency 'MobileVLCKit', '~> 3.6.1b1'
s.ios.dependency 'VLCKit', s.version
s.tvos.dependency 'VLCKit', s.version
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {

View File

@@ -16,27 +16,20 @@ public class VlcPlayerModule: Module {
}
}
// Prop("muted") { (view: VlcPlayerView, muted: Bool) in
// view.setMuted(muted)
// }
// Prop("volume") { (view: VlcPlayerView, volume: Int) in
// view.setVolume(volume)
// }
// Prop("videoAspectRatio") { (view: VlcPlayerView, ratio: String) in
// view.setVideoAspectRatio(ratio)
// }
Events(
"onPlaybackStateChanged",
"onVideoStateChange",
"onVideoLoadStart",
"onVideoLoadEnd",
"onVideoProgress",
"onVideoError"
"onVideoError",
"onPipStarted"
)
AsyncFunction("startPictureInPicture") { (view: VlcPlayerView) in
view.startPictureInPicture()
}
AsyncFunction("play") { (view: VlcPlayerView) in
view.play()
}
@@ -69,14 +62,6 @@ public class VlcPlayerModule: Module {
return view.getSubtitleTracks()
}
// AsyncFunction("setVideoCropGeometry") { (view: VlcPlayerView, geometry: String?) in
// view.setVideoCropGeometry(geometry)
// }
// AsyncFunction("getVideoCropGeometry") { (view: VlcPlayerView) -> String? in
// return view.getVideoCropGeometry()
// }
AsyncFunction("setSubtitleURL") {
(view: VlcPlayerView, url: String, name: String) in
view.setSubtitleURL(url, name: name)

View File

@@ -1,50 +1,176 @@
import ExpoModulesCore
import MobileVLCKit
import UIKit
import VLCKit
import os
public class VLCPlayerView: UIView {
func setupView(parent: UIView) {
self.backgroundColor = .black
self.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
self.leadingAnchor.constraint(equalTo: parent.leadingAnchor),
self.trailingAnchor.constraint(equalTo: parent.trailingAnchor),
self.topAnchor.constraint(equalTo: parent.topAnchor),
self.bottomAnchor.constraint(equalTo: parent.bottomAnchor),
])
}
public override func layoutSubviews() {
super.layoutSubviews()
for subview in subviews {
subview.frame = bounds
}
}
}
class VLCPlayerWrapper: NSObject {
private var lastProgressCall = Date().timeIntervalSince1970
public var player: VLCMediaPlayer = VLCMediaPlayer()
private var updatePlayerState: (() -> Void)?
private var updateVideoProgress: (() -> Void)?
private var playerView: VLCPlayerView = VLCPlayerView()
public weak var pipController: VLCPictureInPictureWindowControlling?
override public init() {
super.init()
player.delegate = self
player.drawable = self
player.scaleFactor = 0
}
public func setup(
parent: UIView,
updatePlayerState: (() -> Void)?,
updateVideoProgress: (() -> Void)?
) {
self.updatePlayerState = updatePlayerState
self.updateVideoProgress = updateVideoProgress
player.delegate = self
parent.addSubview(playerView)
playerView.setupView(parent: parent)
}
public func getPlayerView() -> UIView {
return playerView
}
}
// MARK: - VLCPictureInPictureDrawable
extension VLCPlayerWrapper: VLCPictureInPictureDrawable {
public func mediaController() -> (any VLCPictureInPictureMediaControlling)! {
return self
}
public func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)!
{
return { [weak self] controller in
self?.pipController = controller
}
}
}
// MARK: - VLCPictureInPictureMediaControlling
extension VLCPlayerWrapper: VLCPictureInPictureMediaControlling {
func mediaTime() -> Int64 {
return player.time.value?.int64Value ?? 0
}
func mediaLength() -> Int64 {
return player.media?.length.value?.int64Value ?? 0
}
func play() {
player.play()
}
func pause() {
player.pause()
}
func seek(by offset: Int64, completion: @escaping () -> Void) {
player.jump(withOffset: Int32(offset), completion: completion)
}
func isMediaSeekable() -> Bool {
return player.isSeekable
}
func isMediaPlaying() -> Bool {
return player.isPlaying
}
}
// MARK: - VLCDrawable
extension VLCPlayerWrapper: VLCDrawable {
public func addSubview(_ view: UIView) {
playerView.addSubview(view)
}
public func bounds() -> CGRect {
return playerView.bounds
}
}
// MARK: - VLCMediaPlayerDelegate
extension VLCPlayerWrapper: VLCMediaPlayerDelegate {
func mediaPlayerTimeChanged(_ aNotification: Notification) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
let timeNow = Date().timeIntervalSince1970
if timeNow - self.lastProgressCall >= 1 {
self.lastProgressCall = timeNow
self.updateVideoProgress?()
}
}
}
func mediaPlayerStateChanged(_ state: VLCMediaPlayerState) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.updatePlayerState?()
guard let pipController = self.pipController else { return }
pipController.invalidatePlaybackState()
}
}
}
// MARK: - VLCMediaDelegate
extension VLCPlayerWrapper: VLCMediaDelegate {
// Implement VLCMediaDelegate methods if needed
}
class VlcPlayerView: ExpoView {
private var mediaPlayer: VLCMediaPlayer?
private var videoView: UIView?
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayerView")
private var vlc: VLCPlayerWrapper = VLCPlayerWrapper()
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
private var isPaused: Bool = false
private var currentGeometryCString: [CChar]?
private var lastReportedState: VLCMediaPlayerState?
private var lastReportedIsPlaying: Bool?
private var customSubtitles: [(internalName: String, originalName: String)] = []
private var startPosition: Int32 = 0
private var isMediaReady: Bool = false
private var externalTrack: [String: String]?
private var progressTimer: DispatchSourceTimer?
private var isStopping: Bool = false // Define isStopping here
private var lastProgressCall = Date().timeIntervalSince1970
private var externalSubtitles: [[String: String]]?
var hasSource = false
// MARK: - Initialization
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
setupView()
setupVLC()
setupNotifications()
VLCManager.shared.listeners.append(self)
}
// MARK: - Setup
private func setupView() {
DispatchQueue.main.async {
self.backgroundColor = .black
self.videoView = UIView()
self.videoView?.translatesAutoresizingMaskIntoConstraints = false
if let videoView = self.videoView {
self.addSubview(videoView)
NSLayoutConstraint.activate([
videoView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
videoView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
videoView.topAnchor.constraint(equalTo: self.topAnchor),
videoView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
])
}
}
private func setupVLC() {
vlc.setup(
parent: self,
updatePlayerState: updatePlayerState,
updateVideoProgress: updateVideoProgress
)
}
private func setupNotifications() {
@@ -57,57 +183,71 @@ class VlcPlayerView: ExpoView {
}
// MARK: - Public Methods
func startPictureInPicture() {
self.vlc.pipController?.stateChangeEventHandler = { (isStarted: Bool) in
self.onPipStarted?(["pipStarted": isStarted])
}
self.vlc.pipController?.startPictureInPicture()
}
@objc func play() {
self.mediaPlayer?.play()
self.vlc.player.play()
self.isPaused = false
print("Play")
logger.debug("Play")
}
@objc func pause() {
self.mediaPlayer?.pause()
self.vlc.player.pause()
self.isPaused = true
}
@objc func seekTo(_ time: Int32) {
guard let player = self.mediaPlayer else { return }
let wasPlaying = player.isPlaying
let wasPlaying = vlc.player.isPlaying
if wasPlaying {
self.pause()
}
if let duration = player.media?.length.intValue {
print("Seeking to time: \(time) Video Duration \(duration)")
if let duration = vlc.player.media?.length.intValue {
logger.debug("Seeking to time: \(time) Video Duration \(duration)")
// If the specified time is greater than the duration, seek to the end
let seekTime = time > duration ? duration - 1000 : time
player.time = VLCTime(int: seekTime)
if wasPlaying {
self.play()
}
vlc.player.time = VLCTime(int: seekTime)
self.updatePlayerState()
// Let mediaPlayerStateChanged handle play state change
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if wasPlaying {
self.play()
}
}
} else {
print("Error: Unable to retrieve video duration")
logger.error("Unable to retrieve video duration")
}
}
@objc func setSource(_ source: [String: Any]) {
logger.debug("Setting source...")
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if self.hasSource {
return
}
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
var mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
self.externalTrack = source["externalTrack"] as? [String: String]
var initOptions = source["initOptions"] as? [Any] ?? []
let initOptions: [String] = source["initOptions"] as? [String] ?? []
self.startPosition = source["startPosition"] as? Int32 ?? 0
initOptions.append("--start-time=\(self.startPosition)")
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
for item in initOptions {
let option = item.components(separatedBy: "=")
mediaOptions.updateValue(
option[1], forKey: option[0].replacingOccurrences(of: "--", with: ""))
}
guard let uri = source["uri"] as? String, !uri.isEmpty else {
print("Error: Invalid or empty URI")
logger.error("Invalid or empty URI")
self.onVideoError?(["error": "Invalid or empty URI"])
return
}
@@ -116,17 +256,13 @@ class VlcPlayerView: ExpoView {
let isNetwork = source["isNetwork"] as? Bool ?? false
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
self.mediaPlayer = VLCMediaPlayer(options: initOptions)
self.mediaPlayer?.delegate = self
self.mediaPlayer?.drawable = self.videoView
self.mediaPlayer?.scaleFactor = 0
let media: VLCMedia
let media: VLCMedia!
if isNetwork {
print("Loading network file: \(uri)")
logger.debug("Loading network file: \(uri)")
media = VLCMedia(url: URL(string: uri)!)
} else {
print("Loading local file: \(uri)")
logger.debug("Loading local file: \(uri)")
if uri.starts(with: "file://"), let url = URL(string: uri) {
media = VLCMedia(url: url)
} else {
@@ -134,109 +270,84 @@ class VlcPlayerView: ExpoView {
}
}
print("Debug: Media options: \(mediaOptions)")
logger.debug("Media options: \(mediaOptions)")
media.addOptions(mediaOptions)
self.mediaPlayer?.media = media
self.vlc.player.media = media
self.setInitialExternalSubtitles()
self.hasSource = true
if autoplay {
print("Playing...")
logger.info("Playing...")
self.play()
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
}
}
}
@objc func setAudioTrack(_ trackIndex: Int) {
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
print("Setting audio track: \(trackIndex)")
let track = self.vlc.player.audioTracks[trackIndex]
track.isSelectedExclusively = true
}
@objc func getAudioTracks() -> [[String: Any]]? {
guard let trackNames = mediaPlayer?.audioTrackNames,
let trackIndexes = mediaPlayer?.audioTrackIndexes
else {
return nil
}
return zip(trackNames, trackIndexes).map { name, index in
return ["name": name, "index": index]
return vlc.player.audioTracks.enumerated().map {
return ["name": $1.trackName, "index": $0]
}
}
@objc func setSubtitleTrack(_ trackIndex: Int) {
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
print(
"Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)"
)
logger.debug("Attempting to set subtitle track to index: \(trackIndex)")
if trackIndex == -1 {
logger.debug("Disabling all subtitles")
for track in self.vlc.player.textTracks {
track.isSelected = false
}
return
}
let track = self.vlc.player.textTracks[trackIndex]
track.isSelectedExclusively = true;
logger.debug("Current subtitle track index after setting: \(track.trackName)")
}
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
guard let url = URL(string: subtitleURL) else {
print("Error: Invalid subtitle URL")
logger.error("Invalid subtitle URL")
return
}
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
if let result = result {
let internalName = "Track \(self.customSubtitles.count + 1)"
print("Subtitle added with result: \(result) \(internalName)")
let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: false)
if result == 0 {
let internalName = "Track \(self.customSubtitles.count)"
self.customSubtitles.append((internalName: internalName, originalName: name))
logger.debug("Subtitle added with result: \(result) \(internalName)")
} else {
print("Failed to add subtitle")
logger.debug("Failed to add subtitle")
}
}
@objc func getSubtitleTracks() -> [[String: Any]]? {
guard let mediaPlayer = self.mediaPlayer else {
if self.vlc.player.textTracks.count == 0 {
return nil
}
let count = mediaPlayer.numberOfSubtitlesTracks
print("Debug: Number of subtitle tracks: \(count)")
logger.debug("Number of subtitle tracks: \(self.vlc.player.textTracks.count)")
guard count > 0 else {
return nil
}
var tracks: [[String: Any]] = []
if let names = mediaPlayer.videoSubTitlesNames as? [String],
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
{
for (index, name) in zip(indexes, names) {
if let customSubtitle = customSubtitles.first(where: { $0.internalName == name }) {
tracks.append(["name": customSubtitle.originalName, "index": index.intValue])
} else {
tracks.append(["name": name, "index": index.intValue])
}
let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in
if let customSubtitle = customSubtitles.first(where: {
$0.internalName == track.trackName
}) {
return ["name": customSubtitle.originalName, "index": index]
} else {
return ["name": track.trackName, "index": index]
}
}
print("Debug: Subtitle tracks: \(tracks)")
return tracks
}
private func setSubtitleTrackByName(_ trackName: String) {
guard let mediaPlayer = self.mediaPlayer else { return }
// Get the subtitle tracks and their indexes
if let names = mediaPlayer.videoSubTitlesNames as? [String],
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
{
for (index, name) in zip(indexes, names) {
if name.starts(with: trackName) {
let trackIndex = index.intValue
print("Track Index setting to: \(trackIndex)")
setSubtitleTrack(trackIndex)
return
}
}
}
print("Track not found for name: \(trackName)")
logger.debug("Subtitle tracks: \(tracks)")
return tracks
}
@objc func stop(completion: (() -> Void)? = nil) {
logger.debug("Stopping media...")
guard !isStopping else {
completion?()
return
@@ -263,125 +374,98 @@ class VlcPlayerView: ExpoView {
}
private func setInitialExternalSubtitles() {
if let externalSubtitles = self.externalSubtitles {
for subtitle in externalSubtitles {
if let subtitleName = subtitle["name"],
let subtitleURL = subtitle["DeliveryUrl"]
{
print("Setting external subtitle: \(subtitleName) \(subtitleURL)")
self.setSubtitleURL(subtitleURL, name: subtitleName)
}
}
}
}
private func performStop(completion: (() -> Void)? = nil) {
// Stop the media player
mediaPlayer?.stop()
vlc.player.stop()
// Remove observer
NotificationCenter.default.removeObserver(self)
// Clear the video view
videoView?.removeFromSuperview()
videoView = nil
// Release the media player
mediaPlayer?.delegate = nil
mediaPlayer = nil
vlc.getPlayerView().removeFromSuperview()
isStopping = false
completion?()
}
private func updateVideoProgress() {
guard let player = self.mediaPlayer else { return }
guard let media = self.vlc.player.media else { return }
let currentTimeMs = player.time.intValue
let durationMs = player.media?.length.intValue ?? 0
let currentTimeMs = self.vlc.player.time.intValue
let durationMs = self.vlc.player.media?.length.intValue ?? 0
print("Debug: Current time: \(currentTimeMs)")
if currentTimeMs >= 0 && currentTimeMs < durationMs {
if player.isPlaying && !self.isMediaReady {
self.isMediaReady = true
// Set external track subtitle when starting.
if let externalTrack = self.externalTrack {
if let name = externalTrack["name"], !name.isEmpty {
let deliveryUrl = externalTrack["DeliveryUrl"] ?? ""
self.setSubtitleURL(deliveryUrl, name: name)
}
}
}
self.onVideoProgress?([
"currentTime": currentTimeMs,
"duration": durationMs,
])
}
logger.debug("Current time: \(currentTimeMs)")
self.onVideoProgress?([
"currentTime": currentTimeMs,
"duration": durationMs,
])
}
private func updatePlayerState() {
let player = self.vlc.player
self.onVideoStateChange?([
"target": self.reactTag ?? NSNull(),
"currentTime": player.time.intValue,
"duration": player.media?.length.intValue ?? 0,
"error": false,
"isPlaying": player.isPlaying,
"isBuffering": !player.isPlaying && player.state == VLCMediaPlayerState.buffering,
"state": player.state.description,
])
}
// MARK: - Expo Events
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
@objc var onVideoLoadStart: RCTDirectEventBlock?
@objc var onVideoStateChange: RCTDirectEventBlock?
@objc var onVideoProgress: RCTDirectEventBlock?
@objc var onVideoLoadEnd: RCTDirectEventBlock?
@objc var onVideoError: RCTDirectEventBlock?
@objc var onPipStarted: RCTDirectEventBlock?
// MARK: - Deinitialization
deinit {
logger.debug("Deinitialization")
performStop()
VLCManager.shared.listeners.removeAll()
}
}
extension VlcPlayerView: VLCMediaPlayerDelegate {
func mediaPlayerTimeChanged(_ aNotification: Notification) {
// self?.updateVideoProgress()
let timeNow = Date().timeIntervalSince1970
if timeNow - lastProgressCall >= 1 {
lastProgressCall = timeNow
updateVideoProgress()
}
// MARK: - SimpleAppLifecycleListener
extension VlcPlayerView: SimpleAppLifecycleListener {
func applicationDidEnterBackground() {
logger.debug("Entering background")
}
func mediaPlayerStateChanged(_ aNotification: Notification) {
self.updatePlayerState()
func applicationDidEnterForeground() {
logger.debug("Entering foreground, is player visible? \(self.vlc.getPlayerView().superview != nil)")
if !self.vlc.getPlayerView().isDescendant(of: self) {
logger.debug("Player view is missing. Adding back as subview")
self.addSubview(self.vlc.getPlayerView())
}
// Current solution to fixing black screen when re-entering application
if let videoTrack = self.vlc.player.videoTracks.first { $0.isSelected == true }, !self.vlc.isMediaPlaying() {
videoTrack.isSelected = false
videoTrack.isSelectedExclusively = true
self.vlc.player.play()
self.vlc.player.pause()
}
}
private func updatePlayerState() {
guard let player = self.mediaPlayer else { return }
let currentState = player.state
var stateInfo: [String: Any] = [
"target": self.reactTag ?? NSNull(),
"currentTime": player.time.intValue,
"duration": player.media?.length.intValue ?? 0,
"error": false,
]
if player.isPlaying {
stateInfo["isPlaying"] = true
stateInfo["isBuffering"] = false
stateInfo["state"] = "Playing"
} else {
stateInfo["isPlaying"] = false
stateInfo["state"] = "Paused"
}
if player.state == VLCMediaPlayerState.buffering {
stateInfo["isBuffering"] = true
stateInfo["state"] = "Buffering"
} else if player.state == VLCMediaPlayerState.error {
print("player.state ~ error")
stateInfo["state"] = "Error"
self.onVideoLoadEnd?(stateInfo)
} else if player.state == VLCMediaPlayerState.opening {
print("player.state ~ opening")
stateInfo["state"] = "Opening"
}
if self.lastReportedState != currentState
|| self.lastReportedIsPlaying != player.isPlaying
{
self.lastReportedState = currentState
self.lastReportedIsPlaying = player.isPlaying
self.onVideoStateChange?(stateInfo)
}
}
}
extension VlcPlayerView: VLCMediaDelegate {
// Implement VLCMediaDelegate methods if needed
}
extension VLCMediaPlayerState {
@@ -392,9 +476,7 @@ extension VLCMediaPlayerState {
case .playing: return "Playing"
case .paused: return "Paused"
case .stopped: return "Stopped"
case .ended: return "Ended"
case .error: return "Error"
case .esAdded: return "ESAdded"
@unknown default: return "Unknown"
}
}

View File

@@ -24,6 +24,12 @@ export type VideoLoadStartPayload = {
};
};
export type PipStartedPayload = {
nativeEvent: {
pipStarted: boolean;
};
};
export type VideoStateChangePayload = PlaybackStatePayload;
export type VideoProgressPayload = ProgressUpdatePayload;
@@ -33,7 +39,7 @@ export type VlcPlayerSource = {
type?: string;
isNetwork?: boolean;
autoplay?: boolean;
externalTrack?: { name: string, DeliveryUrl: string };
externalSubtitles: { name: string; DeliveryUrl: string }[];
initOptions?: any[];
mediaOptions?: { [key: string]: any };
startPosition?: number;
@@ -64,9 +70,11 @@ export type VlcPlayerViewProps = {
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
onVideoLoadEnd?: (event: VideoLoadStartPayload) => void;
onVideoError?: (event: PlaybackStatePayload) => void;
onPipStarted?: (event: PipStartedPayload) => void;
};
export interface VlcPlayerViewRef {
startPictureInPicture: () => Promise<void>;
play: () => Promise<void>;
pause: () => Promise<void>;
stop: () => Promise<void>;

View File

@@ -23,6 +23,9 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
const nativeRef = React.useRef<NativeViewRef>(null);
React.useImperativeHandle(ref, () => ({
startPictureInPicture: async () => {
await nativeRef.current?.startPictureInPicture()
},
play: async () => {
await nativeRef.current?.play();
},
@@ -96,6 +99,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
onVideoProgress,
onVideoLoadEnd,
onVideoError,
onPipStarted,
...otherProps
} = props;
@@ -122,6 +126,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
onVideoStateChange={onVideoStateChange}
onVideoProgress={onVideoProgress}
onVideoError={onVideoError}
onPipStarted={onPipStarted}
/>
);
}

View File

@@ -6,123 +6,131 @@
"submodule-reload": "git submodule update --init --remote --recursive",
"clean": "echo y | expo prebuild --clean",
"start": "bun run submodule-reload && expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "bun run submodule-reload && expo run:android",
"ios": "bun run submodule-reload && expo run:ios",
"web": "bun run submodule-reload && expo start --web",
"ios": "EXPO_TV=0 expo run:ios",
"ios:tv": "EXPO_TV=1 expo run:ios",
"android": "EXPO_TV=0 expo run:android",
"android:tv": "EXPO_TV=1 expo run:android",
"prebuild": "EXPO_TV=0 bun run clean",
"prebuild:tv": "EXPO_TV=1 bun run clean",
"prebuild:tv-new": "EXPO_TV=1 node ./scripts/symlink-native-dirs.js; bun run prebuild:tv",
"test": "jest --watchAll",
"lint": "expo lint",
"postinstall": "patch-package"
},
"jest": {
"preset": "jest-expo"
},
"dependencies": {
"@bottom-tabs/react-navigation": "0.8.0",
"react-native-bottom-tabs": "0.8.0",
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
"@bottom-tabs/react-navigation": "0.8.6",
"@config-plugins/ffmpeg-kit-react-native": "^9.0.0",
"@expo/config-plugins": "~9.0.15",
"@expo/react-native-action-sheet": "^4.1.0",
"@expo/vector-icons": "^14.0.4",
"@futurejj/react-native-visibility-sensor": "^1.3.5",
"@gorhom/bottom-sheet": "^4.6.4",
"@futurejj/react-native-visibility-sensor": "^1.3.10",
"@gorhom/bottom-sheet": "^5.1.0",
"@jellyfin/sdk": "^0.11.0",
"@kesha-antonov/react-native-background-downloader": "3.2.6",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/netinfo": "11.3.1",
"@react-native-menu/menu": "^1.1.6",
"@react-navigation/material-top-tabs": "^6.6.14",
"@react-navigation/native": "^6.1.18",
"@shopify/flash-list": "1.6.4",
"@tanstack/react-query": "^5.59.20",
"@types/lodash": "^4.17.13",
"@types/react-native-vector-icons": "^6.4.18",
"@types/uuid": "^10.0.0",
"@react-native-community/netinfo": "11.4.1",
"@react-native-menu/menu": "^1.2.2",
"@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/material-top-tabs": "^7.1.0",
"@react-navigation/native": "^7.0.14",
"@shopify/flash-list": "1.7.3",
"@tanstack/react-query": "^5.66.0",
"add": "^2.0.6",
"axios": "^1.7.7",
"expo": "~51.0.39",
"expo-asset": "~10.0.10",
"expo-background-fetch": "~12.0.1",
"expo-blur": "~13.0.2",
"expo-brightness": "~12.0.1",
"expo-build-properties": "~0.12.5",
"expo-constants": "~16.0.2",
"expo-dev-client": "~4.0.29",
"expo-device": "~6.0.2",
"expo-font": "~12.0.10",
"expo-haptics": "~13.0.1",
"expo-image": "~1.13.0",
"expo-keep-awake": "~13.0.2",
"expo-linear-gradient": "~13.0.2",
"expo-linking": "~6.3.1",
"expo-localization": "~16.0.0",
"expo-network": "~6.0.1",
"expo-notifications": "~0.28.19",
"expo-router": "~3.5.24",
"expo-screen-orientation": "~7.0.5",
"expo-sensors": "~13.0.9",
"expo-splash-screen": "~0.27.7",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "^3.0.7",
"expo-task-manager": "~11.8.2",
"expo-updates": "~0.25.27",
"expo-web-browser": "~13.0.3",
"axios": "^1.7.9",
"expo": "^52.0.31",
"expo-asset": "~11.0.3",
"expo-background-fetch": "~13.0.5",
"expo-blur": "~14.0.3",
"expo-brightness": "~13.0.3",
"expo-build-properties": "~0.13.2",
"expo-constants": "~17.0.5",
"expo-crypto": "~14.0.2",
"expo-dev-client": "~5.0.11",
"expo-device": "~7.0.2",
"expo-font": "~13.0.3",
"expo-haptics": "~14.0.1",
"expo-image": "~2.0.4",
"expo-keep-awake": "~14.0.2",
"expo-linear-gradient": "~14.0.2",
"expo-linking": "~7.0.5",
"expo-localization": "~16.0.1",
"expo-network": "~7.0.5",
"expo-notifications": "~0.29.13",
"expo-router": "~4.0.17",
"expo-screen-orientation": "~8.0.4",
"expo-sensors": "~14.0.2",
"expo-splash-screen": "~0.29.22",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.8",
"expo-task-manager": "~12.0.5",
"expo-updates": "~0.26.17",
"expo-web-browser": "~14.0.2",
"ffmpeg-kit-react-native": "^6.0.2",
"install": "^0.13.0",
"i18next": "^24.2.0",
"jotai": "^2.10.1",
"i18next": "^24.2.2",
"jotai": "^2.11.3",
"lodash": "^4.17.21",
"nativewind": "^2.0.11",
"react": "18.2.0",
"react-dom": "18.2.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-i18next": "^15.4.0",
"react-native": "0.74.5",
"react-native-awesome-slider": "^2.5.6",
"react-native": "npm:react-native-tvos@~0.77.0-0",
"react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "0.8.7",
"react-native-circular-progress": "^1.4.1",
"react-native-compressor": "^1.9.0",
"react-native-compressor": "^1.10.3",
"react-native-country-flag": "^2.0.2",
"react-native-device-info": "^14.0.1",
"react-native-edge-to-edge": "^1.1.3",
"react-native-gesture-handler": "~2.16.1",
"react-native-device-info": "^14.0.4",
"react-native-edge-to-edge": "^1.4.3",
"react-native-gesture-handler": "2.22.0",
"react-native-get-random-values": "^1.11.0",
"react-native-google-cast": "^4.8.3",
"react-native-image-colors": "^2.4.0",
"react-native-ios-context-menu": "^2.5.2",
"react-native-ios-utilities": "4.5.3",
"react-native-ios-context-menu": "^3.1.0",
"react-native-ios-utilities": "5.1.1",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.3.0",
"react-native-pager-view": "6.5.1",
"react-native-progress": "^5.0.1",
"react-native-reanimated": "~3.10.1",
"react-native-reanimated-carousel": "4.0.0-canary.22",
"react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1",
"react-native-svg": "15.2.0",
"react-native-tab-view": "^3.5.2",
"react-native-reanimated": "~3.16.7",
"react-native-reanimated-carousel": "3.5.1",
"react-native-safe-area-context": "5.1.0",
"react-native-screens": "~4.5.0",
"react-native-svg": "15.11.1",
"react-native-tab-view": "^4.0.5",
"react-native-udp": "^4.1.7",
"react-native-uitextview": "^1.4.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.2",
"react-native-video": "^6.7.0",
"react-native-volume-manager": "^1.10.0",
"react-native-uuid": "^2.0.3",
"react-native-video": "6.10.0",
"react-native-volume-manager": "^2.0.8",
"react-native-web": "~0.19.13",
"react-native-webview": "13.8.6",
"sonner-native": "^0.14.2",
"react-native-webview": "13.13.2",
"sonner-native": "^0.17.0",
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.4",
"uuid": "^10.0.0",
"zeego": "^1.10.0",
"zod": "^3.23.8"
"uuid": "^11.0.5",
"zeego": "^2.0.4",
"zod": "^3.24.1"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@babel/core": "^7.26.8",
"@react-native-community/cli": "15.1.3",
"@react-native-tvos/config-tv": "^0.1.1",
"@types/jest": "^29.5.14",
"@types/react": "~18.2.79",
"@types/react-test-renderer": "^18.0.7",
"jest": "^29.2.1",
"jest-expo": "~51.0.4",
"@types/react": "~18.3.12",
"@types/react-test-renderer": "^19.0.0",
"patch-package": "^8.0.0",
"postinstall-postinstall": "^2.1.0",
"react-test-renderer": "18.2.0",
"typescript": "~5.3.3"
"react-test-renderer": "19.0.0",
"typescript": "~5.7.3",
"@types/lodash": "^4.17.15",
"@types/react-native-vector-icons": "^6.4.18",
"@types/uuid": "^10.0.0"
},
"private": true
"private": true,
"expo": {
"install": {
"exclude": [
"react-native"
]
}
}
}

View File

@@ -0,0 +1 @@
export * from "expo-screen-orientation";

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