Compare commits

...

164 Commits

Author SHA1 Message Date
Alex Kim
de95b2dd18 Add more 2025-05-29 18:30:30 +10:00
Alex Kim
3a8fa09881 update 2025-04-21 22:30:29 +10:00
Alex Kim
b0c8aefda6 Updated 2025-04-21 19:54:49 +10:00
Alex Kim
f477e86718 Update Seeking behaviour 2025-04-21 15:50:07 +10:00
Alex Kim
5ce4eb1be1 Working prototype 2025-04-21 14:55:27 +10:00
Alex Kim
dd25feea25 Update 2025-04-21 05:01:21 +10:00
Alex Kim
d8f8224d0c update name 2025-04-21 04:46:44 +10:00
Alex Kim
6631cc5d65 added mpv 2025-04-21 04:46:00 +10:00
Alex Kim
f1f2777119 added mpv 2025-04-21 04:45:22 +10:00
Alex Kim
b6198b21bd MPV Player init 2025-04-21 04:38:03 +10:00
Gap
2ce04b3fd3 feat(lang): Added Russian localization (#613) 2025-04-11 20:08:09 +02:00
Leonardo
bf5203348b feat: add portuguese (pt-BR) translation (#625) 2025-04-11 18:06:41 +02:00
lance chant
16fb1a52ca fix: Fixed the import of expo-application to be the as expo docs and it allowed application to be populated (#648) 2025-04-11 17:56:29 +02:00
sarendsen
d8be7b2463 fix: disable downloads for the moment 2025-04-10 19:39:05 +02:00
sarendsen
ec37b5ab2c fix: use ffmpegkit fork 2025-04-10 16:30:55 +02:00
sarendsen
29eb072e5d fix: use items endpoint for search 2025-04-08 12:32:43 +02:00
sarendsen
2a4a7f5f2d fix: return of export log 2025-04-07 14:33:03 +02:00
sarendsen
8b3f950bc5 fix: use ffmpegkit fork 2025-04-07 12:44:39 +02:00
sarendsen
db527311d6 fix: use ffmpegkit fork 2025-04-07 10:44:45 +02:00
lance chant
b76e834be1 feat: adding reportPlaybackStart which allows tracking to work well (#636) 2025-04-06 10:24:33 +02:00
herrrta
c9905d9d88 fix: add null safety to default folder path 2025-04-01 19:31:27 -04:00
Ahmed Sbai
b9bb109f4a chore: linting fixes && github actions for linting (#612) 2025-03-31 07:44:10 +02:00
Fredrik Burmester
16b834cf71 fix: lint issues 2025-03-30 10:21:41 +02:00
herrrta
f6baf490fb chore: add environment names to builds 2025-03-29 11:42:52 -04:00
herrrta
3201499397 chore: add export log string 2025-03-29 10:52:45 -04:00
herrrta
6555251c2e feat: expo env variables & export logs 2025-03-29 10:44:28 -04:00
sarendsen
71c15f3651 feat: Implement latest for custom home 2025-03-29 14:47:38 +01:00
herrrta
25da30d6e2 fix: env variable 2025-03-28 19:16:16 -04:00
herrrta
1394eae01e feat: better logs
- added ability to write debug logs for development builds
- added filtering to log page
- modified filter button to allow for multiple selection if required
2025-03-28 19:11:36 -04:00
herrrta
587d419502 feat: new notification deep links 2025-03-21 20:52:22 -04:00
herrrta
bc081b535e chore: version bump 2025-03-21 20:22:31 -04:00
herrrta
62d2d1f7ca fix: generate expo push tokens for real device only 2025-03-19 19:17:22 -04:00
lostb1t
66aab5b771 Update README.md 2025-03-19 12:14:00 +01:00
lostb1t
5c89100afd Update README.md 2025-03-19 11:58:08 +01:00
Fredrik Burmester
f1a3b48017 chore 2025-03-19 11:33:27 +01:00
Fredrik Burmester
8ab72b1262 chore 2025-03-17 10:11:25 +01:00
Fredrik Burmester
b9c02618d5 fix: intentionally commit the google-services file - not a secret 2025-03-17 10:11:22 +01:00
Fredrik Burmester
0b22f28bb6 fix: env 2025-03-17 09:56:24 +01:00
Fredrik Burmester
2932a7b324 chore 2025-03-17 09:56:18 +01:00
Fredrik Burmester
8e8ae32287 fix: remove patch 2025-03-17 09:45:51 +01:00
Fredrik Burmester
2189b3d3dd fix: android push notifications 2025-03-16 21:31:33 +01:00
Fredrik Burmester
f770cf174b fix: linting config 2025-03-16 18:12:10 +01:00
Fredrik Burmester
f7e771123f fix: lint config 2025-03-16 18:09:53 +01:00
Fredrik Burmester
5757b1c010 fix: lint 2025-03-16 18:08:55 +01:00
lostb1t
92513e234f chore: Apply linting rules and add git hok (#611)
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2025-03-16 18:01:12 +01:00
Ahmed Sbai
2688e1b981 feat: add Polish translation and update language options (#608) 2025-03-16 17:57:39 +01:00
Ahmed Sbai
54423a1267 fix: fixed app crash on next downloaded item && update biome schema v… (#610) 2025-03-16 17:57:25 +01:00
lostb1t
defe87debb fix: disable badge count for sessions 2025-03-16 17:19:48 +01:00
Ahmed Sbai
a1b2248f16 chore: add biome configuration (#590) 2025-03-16 08:12:22 +01:00
Ahmed Sbai
cd42a86d40 feat: enhance favorites with empty cell && added translations (#594) 2025-03-15 14:19:12 -04:00
Fredrik Burmester
76661c7599 Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-03-15 19:17:15 +01:00
Fredrik Burmester
6de829c16d fix: owner of app moved to org 2025-03-15 19:17:13 +01:00
herrrta
9b0ba285b3 feat: Ability to consume webhook notifications and forward to clients #595
- forward expo device tokens to users plugin instance
- added android notification icon
2025-03-15 14:14:38 -04:00
Danylo Kozhushko
cbcb160bdd feat: Added Ukrainian translation (#593) 2025-03-15 09:21:52 +01:00
Ahmed Sbai
10bfa95060 fix: update textContentType for username input to oneTimeCode (#587) 2025-03-15 09:21:24 +01:00
sarendsen
9f17f13175 refactor: remove tv version of home 2025-03-13 17:00:24 +01:00
sarendsen
c0e9f29c04 fix: home refresh 2025-03-13 16:41:43 +01:00
sarendsen
ab5df3c9ef fix: limit item titel to oneline 2025-03-13 15:57:56 +01:00
herrrta
7768939767 fix: NPE when unregistering receiver 2025-03-11 15:08:48 -04:00
Fredrik Burmester
8b72bde4a9 Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-03-11 07:21:55 +01:00
sarendsen
c29b2cb8da feat: Add location to session 2025-03-10 15:51:18 +01:00
Fredrik Burmester
96e3362f43 fix: rotate bugs in video player 2025-03-08 10:33:14 +01:00
Fredrik Burmester
7cdf0e5355 chore 2025-03-08 10:13:04 +01:00
Fredrik Burmester
ef355b1f04 fix: remove unused react-native-video 2025-03-08 08:27:28 +01:00
Fredrik Burmester
81535894e1 fix: unwanted rotate to portrait after a long time of watching 2025-03-08 08:27:18 +01:00
Fredrik Burmester
887f30e739 fix: rename auto rotate to follow device orientation 2025-03-07 07:54:13 +01:00
Fredrik Burmester
3d7889e19a fix: orientation lock being activated even when auto rotate is on 2025-03-07 07:53:50 +01:00
herrrta
4b8e8cddb5 fix: Flashlist container not changing height 2025-03-05 20:23:28 -05:00
herrrta
d33baf07d3 fix: Recent requests requester name 2025-03-05 20:16:42 -05:00
herrrta
66f61c3c38 fix: Recent request slider initial loading 2025-03-05 20:07:21 -05:00
herrrta
88efb09317 fix: fix jellyseerr search results 2025-03-05 19:45:36 -05:00
lostb1t
5df021a836 feat: Add session count to app badge (#575) 2025-03-05 08:21:59 +01:00
Ahmed Sbai
89eb0d7796 fix(https://github.com/streamyfin/streamyfin/issues/566): add Turkish (#583) 2025-03-05 08:14:34 +01:00
herrrta
ba9178a0f6 fix: Jellyseerr dont compare against tv original names during sorting 2025-03-05 01:30:42 -05:00
herrrta
27cd73efab fix: Jellyseerr slider bottom padding for posters 2025-03-05 01:24:09 -05:00
herrrta
e15b19deb3 fix: Jellyseerr filter buttons showing when library selected 2025-03-05 01:06:39 -05:00
herrrta
baccc931a2 fix: Jellyseerr order by "default" not preselected (ui)
- ui just didnt reflect this
2025-03-05 00:47:47 -05:00
herrrta
79a2873975 fix: Fix search item count translation 2025-03-05 00:35:54 -05:00
herrrta
e397be4b2e feat: Better Jellyseerr search results #586
- fetch 4 pages at once to maximize search results
- add local sorting options
2025-03-05 00:32:30 -05:00
herrrta
4dddc0f926 fix: Jellyseerr Recent Request slide fixes
- added src for backdrop + poster
- fixed horizontal height issues
2025-03-04 21:09:15 -05:00
Fredrik Burmester
ebcb414b89 fix: use sdk util 2025-03-03 16:10:47 +01:00
Fredrik Burmester
77dba04289 fix 2025-03-03 16:06:48 +01:00
lostb1t
12ceef02cd fix: mark as played 2025-03-03 16:01:27 +01:00
Fredrik Burmester
fe3b652b4f fix: more specified dep arr 2025-03-03 15:21:18 +01:00
Fredrik Burmester
9c9785ba9e chore 2025-03-03 15:20:23 +01:00
Fredrik Burmester
bce9ed2690 fix: wrong prop 2025-03-03 15:20:13 +01:00
Fredrik Burmester
ec914133d6 fix: undefined var 2025-03-03 15:19:59 +01:00
Fredrik Burmester
2d1b03e403 Merge branch 'feat/switch-players' into develop 2025-03-03 15:18:20 +01:00
Fredrik Burmester
7f07260177 fix: hide setting (use only vlc3 for now) 2025-03-03 15:18:09 +01:00
herrrta
e1314077e2 fix: only show recent requests when request is done loading 2025-03-03 01:25:58 -05:00
herrrta
09e9462ac0 feat: (iOS) Switch Video Players 2025-03-03 01:12:08 -05:00
herrrta
dd65505f7f feat: [Jellyseerr] Show recent requests #324
- Added recent requests slide
- updated JellyseerrPoster.tsx to handle more options
2025-03-03 01:04:49 -05:00
herrrta
951158bcd3 fix: Discover page key collisions #581
- add uniqBy for jellyseerr results
- add missing key in MovieTvSlide.tsx
2025-03-02 13:07:14 -05:00
herrrta
9b1dd0923a fix: Don't show all seasons numbers in request modal [Jellyseerr] #580 2025-03-02 12:38:58 -05:00
herrrta
bd908516b5 fix: Advanced request options not saving #543 2025-03-02 12:21:24 -05:00
lostb1t
8cb10d1062 Update _layout.tsx 2025-03-01 09:37:50 +01:00
lostb1t
446439c2e0 Update package.json 2025-02-28 00:11:03 +01:00
lostb1t
a5463d783d fix: use correct url on save for optimized 2025-02-26 19:25:20 +01:00
Little709
640db35456 fix: Update nl.json (#565) 2025-02-26 14:21:42 +01:00
Simon Eklundh
caa4b765c1 fix: makes the icon adaptive for android (#569) 2025-02-26 08:23:27 +01:00
sarendsen
9c6aebe66a small cleanup 2025-02-24 18:41:07 +01:00
sarendsen
ef42510383 small cleanup 2025-02-24 14:56:39 +01:00
sarendsen
5273dfd22b small cleanup 2025-02-24 14:23:48 +01:00
Fredrik Burmester
00bc4232fb fix: xcode warnings 2025-02-24 11:51:48 +01:00
sarendsen
35c9258062 fix: playback pause/play reporting 2025-02-24 10:30:01 +01:00
sarendsen
89bf51c3cc fix: playback reporting 2025-02-24 09:30:14 +01:00
sarendsen
f64c5a02db fix: add hw/sw badge to session 2025-02-23 19:15:10 +01:00
sarendsen
cf284eb3d8 fix: sort sessions by name 2025-02-23 18:42:00 +01:00
Alex
b581a077e1 General refactoring (#559) 2025-02-23 09:40:10 -05:00
Fredrik Burmester
e651b975b7 fix: ts error 2025-02-23 15:08:14 +01:00
lostb1t
1c550b1b77 feat: Mark/unmark favorite quick action (#561) 2025-02-23 15:03:52 +01:00
Ahmed Sbai
5bcae81538 fix(511): fixed long named translations for the subtitle can break UI (#564) 2025-02-23 15:03:22 +01:00
Fredrik Burmester
c951725222 chore 2025-02-23 15:02:50 +01:00
Fredrik Burmester
0b966d7c04 fix: add hevc to chromecast h265 profile 2025-02-23 14:50:14 +01:00
Fredrik Burmester
8e0e35afe3 fix: chromecast 2025-02-23 14:35:00 +01:00
Fredrik Burmester
daf7f35196 feat: scroll to top on tab home press 2025-02-22 13:23:33 +01:00
Fredrik Burmester
d5ac30b6d8 feat: scroll to top on tab home press 2025-02-22 13:20:52 +01:00
Fredrik Burmester
81b91bbb97 chore 2025-02-22 13:11:37 +01:00
Fredrik Burmester
af2bd030e9 feat: focus search bar on second tab press (#558) 2025-02-22 13:09:17 +01:00
Fredrik Burmester
5590c2f784 fix: added season and episode + updated icon 2025-02-22 12:08:58 +01:00
Fredrik Burmester
6cc70dd123 fix: type issues 2025-02-22 12:02:33 +01:00
Edmond
fae588b0f0 fix: Improve Chinese (Traditional) Translation (#557) 2025-02-22 11:06:43 +01:00
vuhe
bd2aeb2234 feat: Add Chinese (Simplified) Translation (#556) 2025-02-22 11:06:10 +01:00
sarendsen
cca0bbf42c bigger play button 2025-02-22 10:58:28 +01:00
Fredrik Burmester
06e0eb5c4e Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-02-21 20:38:45 +01:00
Fredrik Burmester
b478fbb6bf fix: tvos fixes 2025-02-21 20:38:31 +01:00
lostb1t
b98a7b0634 Update _layout.tsx 2025-02-21 18:22:05 +01:00
lostb1t
ce38024a3f Update settings.tsx 2025-02-21 14:57:53 +01:00
lostb1t
04dce9265b Update _layout.tsx 2025-02-21 14:56:59 +01:00
lostb1t
5b8418cd82 feat: Sessions view (#537) 2025-02-21 13:14:57 +01:00
tkymmm
b0c5255bd7 feat: add japanese translations (#552) 2025-02-21 11:09:36 +01:00
Fredrik Burmester
73dd171987 chore: version bump 2025-02-20 16:30:36 +01:00
Ahmed Sbai
ff35559687 fix: remove unused imports and optimize keepAwake usage in the player (#548) 2025-02-20 11:18:37 +01:00
Fredrik Burmester
5aadd50946 chore 2025-02-20 11:16:10 +01:00
herrrta
63b5ba2112 feat: add upcoming air dates for episodes 2025-02-19 21:49:28 -05:00
herrrta
8b955578a2 fix: Jellyseerr url input 2025-02-19 21:07:50 -05:00
herrrta
1e5c021c93 fix: Reset ios vout when media is not playing 2025-02-19 20:58:39 -05:00
Fredrik Burmester
0b86f56486 fix: spelling 2025-02-19 20:36:51 +01:00
Fredrik Burmester
728b93f4e5 fix: up stale issue time to 90 days, ignore feature requests 2025-02-19 20:36:31 +01:00
Fredrik Burmester
2fc483b24e Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-02-19 20:19:39 +01:00
Fredrik Burmester
fc901bc01e fix: jellyseerr login without password 2025-02-19 20:19:34 +01:00
lostb1t
2b0884b154 Update README.md 2025-02-19 17:14:01 +01:00
lostb1t
307d20e538 Update README.md 2025-02-19 17:13:12 +01:00
Fredrik Burmester
a2f03908f6 fix: remove splashscreen provider and handle loading in jellyfinprovider 2025-02-19 14:44:39 +01:00
Fredrik Burmester
77aef8877e fix: jellyseerr better login 2025-02-19 11:36:47 +01:00
Fredrik Burmester
0cf930d6e1 test: fix controls loading android? 2025-02-19 11:06:57 +01:00
Fredrik Burmester
4b0b949541 fix: button alignment pip 2025-02-19 10:55:29 +01:00
Fredrik Burmester
14b717f985 fix: half screen black on login 2025-02-19 10:54:05 +01:00
Fredrik Burmester
cfbac538f8 chore: refactor for tv stuff 2025-02-19 10:49:18 +01:00
Fredrik Burmester
1ac6b7e3df fix: chromecast not working 2025-02-18 17:56:10 +01:00
Davide Sirico
c9f6e8676b feat: add italian translations (#545) 2025-02-18 16:10:15 +01:00
Fredrik Burmester
5aab1450cd chore 2025-02-18 16:06:35 +01:00
Fredrik Burmester
1e7080a136 chore 2025-02-18 16:06:32 +01:00
Théo FORTIN
993cec4138 feat: remove stale issues (#515) 2025-02-17 16:54:25 +01:00
Ahmed Sbai
6c524499f9 chore: remove async-storage && moved @types/xxx dependencies to dev-deps (#538) 2025-02-17 16:54:12 +01:00
Maarten
b3463ffdfc feat: add dutch translations (#539)
Co-authored-by: Maarten Schroeven <maarten.schroeven@ae.be>
2025-02-17 16:53:10 +01:00
Edmond
50942b44f1 feat: Add Chinese (Traditional) Translation (#522) 2025-02-17 16:52:58 +01:00
Mustafa
f602f8919f fix: translation de.json grammar (#516) 2025-02-17 16:52:44 +01:00
herrrta
0e86d8a00f fix: IOS video player black screens pt2
- Looks like re-adding subview was not enough. We have to toggle the video tracks selection and play the media to trigger the re-render
2025-02-16 15:05:32 -05:00
Adrián
56b1e1977c fix: Change too long texts in the Spanish translation (#535) 2025-02-16 13:22:13 +01:00
herrrta
30e23b9079 fix: IOS video player black screens
- restores player view when re-entering apps foreground
- added logger
2025-02-15 22:18:16 -05:00
herrrta
d83ecb881b fix: Android PiP support fully working
- fixed black screen on re-entering
- ensured screen stays alive when video is playing
- PiP button states now reflect media status
2025-02-15 15:11:57 -05:00
Fredrik Burmester
4c14c08b35 fix: move from react-native-video -> VLC for transcoded streams (#529)
Co-authored-by: Alex Kim <alexkim5682@gmail.com>
2025-02-16 07:10:36 +11:00
herrrta
ecb9b90163 fix: Stop playback when gesture navigating back 2025-02-15 12:52:09 -05:00
333 changed files with 15662 additions and 7308 deletions

1
.env.development Normal file
View File

@@ -0,0 +1 @@
EXPO_PUBLIC_WRITE_DEBUG=1

1
.env.production Normal file
View File

@@ -0,0 +1 @@
EXPO_PUBLIC_WRITE_DEBUG=0

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text

View File

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

28
.github/workflows/lint.yaml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Lint
on:
pull_request:
branches: [ develop, master ]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20.x'
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Run linting checks
run: bun run check

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

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

8
.gitignore vendored
View File

@@ -10,6 +10,8 @@ npm-debug.*
*.orig.*
web-build/
modules/vlc-player/android/build
modules/vlc-player/android/.gradle
bun.lockb
# macOS
.DS_Store
@@ -41,4 +43,8 @@ credentials.json
.vscode/
.idea/
.ruby-lsp
.ruby-lsp
modules/hls-downloader/android/build
streamyfin-4fec1-firebase-adminsdk.json
.env
.env.local

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
lint-staged

19
.vscode/settings.json vendored
View File

@@ -1,15 +1,24 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"prettier.printWidth": 120,
"[swift]": {
"editor.defaultFormatter": "sswg.swift-lang"
"editor.defaultFormatter": "swiftlang.swift-vscode"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome",
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
}
}

6
Makefile Normal file
View File

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

View File

@@ -85,9 +85,10 @@ 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.
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. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
4. run `npm run prebuild`
5. Create an expo dev build by running `npm run ios` or `nom run android`. This will open a simulator on your computer and run the app.
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`.
@@ -123,6 +124,10 @@ Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmest
## ✨ Acknowledgements
We would like to thank the Jellyfin team for their great software and awesome support on discord.
Special shoutout to the JF official clients for being an inspiration to ours.
### Core Developers
Thanks to the following contributors for their significant contributions:

View File

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

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.26.1",
"version": "0.28.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -31,16 +31,18 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 53,
"versionCode": 54,
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png"
"foregroundImage": "./assets/images/adaptive_icon.png",
"backgroundColor": "#464646"
},
"package": "com.fredrikburmester.streamyfin",
"permissions": [
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.WRITE_SETTINGS"
]
],
"googleServicesFile": "./google-services.json"
},
"plugins": [
"@react-native-tvos/config-tv",
@@ -118,6 +120,13 @@
"image": "./assets/images/StreamyFinFinal.png",
"imageWidth": 100
}
],
[
"expo-notifications",
{
"icon": "./assets/images/notification.png",
"color": "#9333EA"
}
]
],
"experiments": {
@@ -131,7 +140,7 @@
"projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68"
}
},
"owner": "fredrikburmester",
"owner": "streamyfin",
"runtimeVersion": {
"policy": "appVersion"
},

View File

@@ -1,13 +1,13 @@
import {Stack} from "expo-router";
import { Platform } from "react-native";
import { Stack } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
export default function CustomMenuLayout() {
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
name="index"
name='index'
options={{
headerShown: true,
headerLargeTitle: true,

View File

@@ -1,13 +1,13 @@
import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/list/ListItem";
import { apiAtom } from "@/providers/JellyfinProvider";
import Ionicons from "@expo/vector-icons/Ionicons";
import { useAtom } from "jotai/index";
import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { FlatList, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import React, { useCallback, useEffect, useState } from "react";
import { useAtom } from "jotai/index";
import { apiAtom } from "@/providers/JellyfinProvider";
import { ListItem } from "@/components/list/ListItem";
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;
@@ -26,11 +26,11 @@ export default function menuLinks() {
const getMenuLinks = useCallback(async () => {
try {
const response = await api?.axiosInstance.get(
api?.basePath + "/web/config.json"
`${api?.basePath}/web/config.json`,
);
const config = response?.data;
if (!config && !config.hasOwnProperty("menuLinks")) {
if (!config && !Object.hasOwn(config, "menuLinks")) {
console.error("Menu links not found");
return;
}
@@ -46,7 +46,7 @@ export default function menuLinks() {
}, []);
return (
<FlatList
contentInsetAdjustmentBehavior="automatic"
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingTop: 10,
paddingLeft: insets.left,
@@ -63,7 +63,7 @@ export default function menuLinks() {
>
<ListItem
title={item.name}
iconAfter={<Ionicons name="link" size={24} color="white" />}
iconAfter={<Ionicons name='link' size={24} color='white' />}
/>
</TouchableOpacity>
)}
@@ -76,8 +76,10 @@ export default function menuLinks() {
/>
)}
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">{t("custom_links.no_links")}</Text>
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("custom_links.no_links")}
</Text>
</View>
}
/>

View File

@@ -1,14 +1,14 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
export default function SearchLayout() {
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
name="index"
name='index'
options={{
headerShown: true,
headerLargeTitle: true,
@@ -17,7 +17,7 @@ export default function SearchLayout() {
backgroundColor: "black",
},
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>

View File

@@ -18,7 +18,7 @@ export default function favorites() {
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
contentInsetAdjustmentBehavior='automatic'
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
@@ -28,7 +28,7 @@ export default function favorites() {
paddingBottom: 16,
}}
>
<View className="my-4">
<View className='my-4'>
<Favorites />
</View>
</ScrollView>

View File

@@ -1,17 +1,22 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Feather } from "@expo/vector-icons";
import { Feather, Ionicons } 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;
import { Platform, TouchableOpacity, View } from "react-native";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { userAtom } from "@/providers/JellyfinProvider";
import { useAtom } from "jotai";
export default function IndexLayout() {
const router = useRouter();
const [user] = useAtom(userAtom);
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
name="index"
name='index'
options={{
headerShown: true,
headerLargeTitle: true,
@@ -20,20 +25,15 @@ export default function IndexLayout() {
headerLargeStyle: {
backgroundColor: "black",
},
headerTransparent: Platform.OS === "ios" ? true : false,
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerRight: () => (
<View className="flex flex-row items-center space-x-2">
<View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && (
<>
<Chromecast.Chromecast />
<TouchableOpacity
onPress={() => {
router.push("/(auth)/settings");
}}
>
<Feather name="settings" color={"white"} size={22} />
</TouchableOpacity>
{user?.Policy?.IsAdministrator && <SessionsButton />}
<SettingsButton />
</>
)}
</View>
@@ -41,55 +41,61 @@ export default function IndexLayout() {
}}
/>
<Stack.Screen
name="downloads/index"
name='downloads/index'
options={{
title: t("home.downloads.downloads_title"),
}}
/>
<Stack.Screen
name="downloads/[seriesId]"
name='downloads/[seriesId]'
options={{
title: t("home.downloads.tvseries"),
}}
/>
<Stack.Screen
name="settings"
name='sessions/index'
options={{
title: t("home.sessions.title"),
}}
/>
<Stack.Screen
name='settings'
options={{
title: t("home.settings.settings_title"),
}}
/>
<Stack.Screen
name="settings/optimized-server/page"
name='settings/optimized-server/page'
options={{
title: "",
}}
/>
<Stack.Screen
name="settings/marlin-search/page"
name='settings/marlin-search/page'
options={{
title: "",
}}
/>
<Stack.Screen
name="settings/jellyseerr/page"
name='settings/jellyseerr/page'
options={{
title: "",
}}
/>
<Stack.Screen
name="settings/hide-libraries/page"
name='settings/hide-libraries/page'
options={{
title: "",
}}
/>
<Stack.Screen
name="settings/logs/page"
name='settings/logs/page'
options={{
title: "",
}}
/>
<Stack.Screen
name="intro/page"
name='intro/page'
options={{
headerShown: false,
title: "",
@@ -100,15 +106,50 @@ export default function IndexLayout() {
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name="collections/[collectionId]"
name='collections/[collectionId]'
options={{
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
</Stack>
);
}
const SettingsButton = () => {
const router = useRouter();
return (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/settings");
}}
>
<Feather name='settings' color={"white"} size={22} />
</TouchableOpacity>
);
};
const SessionsButton = () => {
const router = useRouter();
const { sessions = [] } = useSessions({} as useSessionsProps);
return (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/sessions");
}}
>
<View className='mr-4'>
<Ionicons
name='play-circle'
color={sessions.length === 0 ? "white" : "#9333ea"}
size={25}
/>
</View>
</TouchableOpacity>
);
};

View File

@@ -1,16 +1,16 @@
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
SeasonDropdown,
SeasonIndexState,
type SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
export default function page() {
const navigation = useNavigation();
@@ -21,7 +21,7 @@ export default function page() {
};
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{}
{},
);
const { downloadedFiles, deleteItems } = useDownload();
@@ -29,9 +29,9 @@ export default function page() {
try {
return (
downloadedFiles
?.filter((f) => f.item.SeriesId == seriesId)
?.filter((f) => f.item.SeriesId === seriesId)
?.sort(
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
) || []
);
} catch {
@@ -64,7 +64,7 @@ export default function page() {
() =>
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
series?.[0]?.item?.ParentIndexNumber,
[groupBySeason]
[groupBySeason],
);
useEffect(() => {
@@ -92,14 +92,14 @@ export default function page() {
onPress: () => deleteItems(groupBySeason),
style: "destructive",
},
]
],
);
}, [groupBySeason]);
return (
<View className="flex-1">
<View className='flex-1'>
{series.length > 0 && (
<View className="flex flex-row items-center justify-start my-2 px-4">
<View className='flex flex-row items-center justify-start my-2 px-4'>
<SeasonDropdown
item={series[0].item}
seasons={series.map((s) => s.item)}
@@ -112,17 +112,17 @@ export default function page() {
}));
}}
/>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2">
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
</View>
<View className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto">
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
<TouchableOpacity onPress={deleteSeries}>
<Ionicons name="trash" size={20} color="white" />
<Ionicons name='trash' size={20} color='white' />
</TouchableOpacity>
</View>
</View>
)}
<ScrollView key={seasonIndex} className="px-4">
<ScrollView key={seasonIndex} className='px-4'>
{groupBySeason.map((episode, index) => (
<EpisodeCard key={index} item={episode} />
))}

View File

@@ -1,28 +1,28 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { type DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { writeToLog } from "@/utils/log";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { Button } from "@/components/Button";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { t } from 'i18next';
import { DownloadSize } from "@/components/downloads/DownloadSize";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { writeToLog } from "@/utils/log";
export default function page() {
const navigation = useNavigation();
@@ -45,7 +45,7 @@ export default function page() {
const groupedBySeries = useMemo(() => {
try {
const episodes = downloadedFiles?.filter(
(f) => f.item.Type === "Episode"
(f) => f.item.Type === "Episode",
);
const series: { [key: string]: DownloadedItem[] } = {};
episodes?.forEach((e) => {
@@ -73,14 +73,22 @@ export default function page() {
const deleteMovies = () =>
deleteFileByType("Movie")
.then(() => toast.success(t("home.downloads.toasts.deleted_all_movies_successfully")))
.then(() =>
toast.success(
t("home.downloads.toasts.deleted_all_movies_successfully"),
),
)
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
});
const deleteShows = () =>
deleteFileByType("Episode")
.then(() => toast.success(t("home.downloads.toasts.deleted_all_tvseries_successfully")))
.then(() =>
toast.success(
t("home.downloads.toasts.deleted_all_tvseries_successfully"),
),
)
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
@@ -97,26 +105,28 @@ export default function page() {
paddingBottom: 100,
}}
>
<View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4">
<View className='py-4'>
<View className='mb-4 flex flex-col space-y-4 px-4'>
{settings?.downloadMethod === DownloadMethod.Remux && (
<View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">{t("home.downloads.queue")}</Text>
<Text className="text-xs opacity-70 text-red-600">
<View className='bg-neutral-900 p-4 rounded-2xl'>
<Text className='text-lg font-bold'>
{t("home.downloads.queue")}
</Text>
<Text className='text-xs opacity-70 text-red-600'>
{t("home.downloads.queue_hint")}
</Text>
<View className="flex flex-col space-y-2 mt-2">
<View className='flex flex-col space-y-2 mt-2'>
{queue.map((q, index) => (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
key={index}
>
<View>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">
<Text className='font-semibold'>{q.item.Name}</Text>
<Text className='text-xs opacity-50'>
{q.item.Type}
</Text>
</View>
@@ -129,14 +139,16 @@ export default function page() {
});
}}
>
<Ionicons name="close" size={24} color="red" />
<Ionicons name='close' size={24} color='red' />
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
{queue.length === 0 && (
<Text className="opacity-50">{t("home.downloads.no_items_in_queue")}</Text>
<Text className='opacity-50'>
{t("home.downloads.no_items_in_queue")}
</Text>
)}
</View>
)}
@@ -145,17 +157,19 @@ export default function page() {
</View>
{movies.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">{t("home.downloads.movies")}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text>
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.movies")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
<View className='px-4 flex flex-row'>
{movies?.map((item) => (
<View className="mb-2 last:mb-0" key={item.item.Id}>
<View className='mb-2 last:mb-0' key={item.item.Id}>
<MovieCard item={item.item} />
</View>
))}
@@ -164,20 +178,22 @@ export default function page() {
</View>
)}
{groupedBySeries.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">{t("home.downloads.tvseries")}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.tvseries")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{groupedBySeries?.length}
</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
<View className='px-4 flex flex-row'>
{groupedBySeries?.map((items) => (
<View
className="mb-2 last:mb-0"
className='mb-2 last:mb-0'
key={items[0].item.SeriesId}
>
<SeriesCard
@@ -191,8 +207,10 @@ export default function page() {
</View>
)}
{downloadedFiles?.length === 0 && (
<View className="flex px-4">
<Text className="opacity-50">{t("home.downloads.no_downloaded_items")}</Text>
<View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text>
</View>
)}
</View>
@@ -215,14 +233,14 @@ export default function page() {
)}
>
<BottomSheetView>
<View className="p-4 space-y-4 mb-4">
<Button color="purple" onPress={deleteMovies}>
<View className='p-4 space-y-4 mb-4'>
<Button color='purple' onPress={deleteMovies}>
{t("home.downloads.delete_all_movies_button")}
</Button>
<Button color="purple" onPress={deleteShows}>
<Button color='purple' onPress={deleteShows}>
{t("home.downloads.delete_all_tvseries_button")}
</Button>
<Button color="red" onPress={deleteAllMedia}>
<Button color='red' onPress={deleteAllMedia}>
{t("home.downloads.delete_all_button")}
</Button>
</View>
@@ -248,6 +266,6 @@ function migration_20241124() {
style: "destructive",
onPress: async () => await deleteAllFiles(),
},
]
],
);
}

View File

@@ -1,498 +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 { 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 { Platform } from "react-native";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
useSplashScreenLoading,
useSplashScreenVisible,
} from "@/providers/SplashScreenProvider";
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 navigation = useNavigation();
const insets = useSafeAreaInsets();
if (!Platform.isTV) {
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,
});
// show splash screen until query loaded
useSplashScreenLoading(l1);
const splashScreenVisible = useSplashScreenVisible();
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>
);
// this spinner should only show up, when user navigates here
// on launch the splash screen is used for loading
if (l1 && !splashScreenVisible)
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;
export default function page() {
return <HomeIndex />;
}

View File

@@ -15,26 +15,26 @@ export default function page() {
useFocusEffect(
useCallback(() => {
storage.set("hasShownIntro", true);
}, [])
}, []),
);
return (
<View className="bg-neutral-900 h-full py-16 px-4 space-y-8">
<View className='bg-neutral-900 h-full py-16 px-4 space-y-8'>
<View>
<Text className="text-3xl font-bold text-center mb-2">
<Text className='text-3xl font-bold text-center mb-2'>
{t("home.intro.welcome_to_streamyfin")}
</Text>
<Text className="text-center">
<Text className='text-center'>
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
</Text>
</View>
<View>
<Text className="text-lg font-bold">
<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">
<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")}
style={{
@@ -42,70 +42,70 @@ export default function page() {
height: 50,
}}
/>
<View className="shrink ml-2">
<Text className="font-bold mb-1">Jellyseerr</Text>
<Text className="shrink text-xs">
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>Jellyseerr</Text>
<Text className='shrink text-xs'>
{t("home.intro.jellyseerr_feature_description")}
</Text>
</View>
</View>
<View className="flex flex-row items-center mt-4">
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className="flex items-center justify-center"
className='flex items-center justify-center'
>
<Ionicons name="cloud-download-outline" size={32} color="white" />
<Ionicons name='cloud-download-outline' size={32} color='white' />
</View>
<View className="shrink ml-2">
<Text className="font-bold mb-1">
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>
{t("home.intro.downloads_feature_title")}
</Text>
<Text className="shrink text-xs">
<Text className='shrink text-xs'>
{t("home.intro.downloads_feature_description")}
</Text>
</View>
</View>
<View className="flex flex-row items-center mt-4">
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className="flex items-center justify-center"
className='flex items-center justify-center'
>
<Feather name="cast" size={28} color={"white"} />
<Feather name='cast' size={28} color={"white"} />
</View>
<View className="shrink ml-2">
<Text className="font-bold mb-1">Chromecast</Text>
<Text className="shrink text-xs">
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>Chromecast</Text>
<Text className='shrink text-xs'>
{t("home.intro.chromecast_feature_description")}
</Text>
</View>
</View>
<View className="flex flex-row items-center mt-4">
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className="flex items-center justify-center"
className='flex items-center justify-center'
>
<Feather name="settings" size={28} color={"white"} />
<Feather name='settings' size={28} color={"white"} />
</View>
<View className="shrink ml-2">
<Text className="font-bold mb-1">
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>
{t("home.intro.centralised_settings_plugin_title")}
</Text>
<Text className="shrink text-xs">
<Text className='shrink text-xs'>
{t("home.intro.centralised_settings_plugin_description")}{" "}
<Text
className="text-purple-600"
className='text-purple-600'
onPress={() => {
Linking.openURL(
"https://github.com/streamyfin/jellyfin-plugin-streamyfin"
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
);
}}
>
@@ -120,7 +120,7 @@ export default function page() {
onPress={() => {
router.back();
}}
className="mt-4"
className='mt-4'
>
{t("home.intro.done_button")}
</Button>
@@ -129,9 +129,9 @@ export default function page() {
router.back();
router.push("/settings");
}}
className="mt-4"
className='mt-4'
>
<Text className="text-purple-600 text-center">
<Text className='text-purple-600 text-center'>
{t("home.intro.go_to_settings_button")}
</Text>
</TouchableOpacity>

View File

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

View File

@@ -1,8 +1,10 @@
import { Platform } from "react-native";
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 { ChromecastSettings } from "@/components/settings/ChromecastSettings";
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,24 +12,23 @@ 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 { useJellyfin } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
import { useHaptic } from "@/hooks/useHaptic";
import { useJellyfin } from "@/providers/JellyfinProvider";
import { userAtom } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import React, { lazy, useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useAtom } from "jotai";
import React, { useEffect } from "react";
import { ScrollView, Switch, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { storage } from "@/utils/mmkv";
const DownloadSettings = lazy(
() => import("@/components/settings/DownloadSettings")
);
export default function settings() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom);
const { logout } = useJellyfin();
const successHapticFeedback = useHaptic("success");
@@ -45,7 +46,7 @@ export default function settings() {
logout();
}}
>
<Text className="text-red-600">
<Text className='text-red-600'>
{t("home.settings.log_out_button")}
</Text>
</TouchableOpacity>
@@ -60,24 +61,27 @@ export default function settings() {
paddingRight: insets.right,
}}
>
<View className="p-4 flex flex-col gap-y-4">
<View className='p-4 flex flex-col gap-y-4'>
<UserInfo />
<QuickConnect className="mb-4" />
<QuickConnect className='mb-4' />
<MediaProvider>
<MediaToggles className="mb-4" />
<AudioToggles className="mb-4" />
<SubtitleToggles className="mb-4" />
<MediaToggles className='mb-4' />
<AudioToggles className='mb-4' />
<SubtitleToggles className='mb-4' />
</MediaProvider>
<OtherSettings />
{!Platform.isTV && <DownloadSettings />}
<DownloadSettings />
<PluginSettings />
<AppLanguageSelector />
<ChromecastSettings />
<ListGroup title={"Intro"}>
<ListItem
onPress={() => {
@@ -86,7 +90,7 @@ export default function settings() {
title={t("home.settings.intro.show_intro")}
/>
<ListItem
textColor="red"
textColor='red'
onPress={() => {
storage.set("hasShownIntro", false);
}}
@@ -94,7 +98,7 @@ export default function settings() {
/>
</ListGroup>
<View className="mb-4">
<View className='mb-4'>
<ListGroup title={t("home.settings.logs.logs_title")}>
<ListItem
onPress={() => router.push("/settings/logs/page")}
@@ -102,7 +106,7 @@ export default function settings() {
title={t("home.settings.logs.logs_title")}
/>
<ListItem
textColor="red"
textColor='red'
onPress={onClearLogsClicked}
title={t("home.settings.logs.delete_all_logs")}
/>

View File

@@ -1,15 +1,15 @@
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { Loader } from "@/components/Loader";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { Switch, View } from "react-native";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const [settings, updateSettings, pluginSettings] = useSettings();
@@ -18,7 +18,7 @@ export default function page() {
const { t } = useTranslation();
const { data, isLoading: isLoading } = useQuery({
const { data, isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({
@@ -33,7 +33,7 @@ export default function page() {
if (isLoading)
return (
<View className="mt-4">
<View className='mt-4'>
<Loader />
</View>
);
@@ -41,7 +41,7 @@ export default function page() {
return (
<DisabledSetting
disabled={pluginSettings?.hiddenLibraries?.locked === true}
className="px-4"
className='px-4'
>
<ListGroup>
{data?.map((view) => (
@@ -59,8 +59,8 @@ export default function page() {
</ListItem>
))}
</ListGroup>
<Text className="px-4 text-xs text-neutral-500 mt-1">
{t("home.settings.other.select_liraries_you_want_to_hide")}
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_liraries_you_want_to_hide")}
</Text>
</DisabledSetting>
);

View File

@@ -1,6 +1,6 @@
import DisabledSetting from "@/components/settings/DisabledSetting";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const [settings, updateSettings, pluginSettings] = useSettings();
@@ -8,7 +8,7 @@ export default function page() {
return (
<DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className="p-4"
className='p-4'
>
<JellyseerrSettings />
</DisabledSetting>

View File

@@ -1,35 +1,157 @@
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { useLog } from "@/utils/log";
import { ScrollView, View } from "react-native";
import { FilterButton } from "@/components/filters/FilterButton";
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
import * as FileSystem from "expo-file-system";
import { useNavigation } from "expo-router";
import * as Sharing from "expo-sharing";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, TouchableOpacity, View } from "react-native";
import Collapsible from "react-native-collapsible";
export default function page() {
const navigation = useNavigation();
const { logs } = useLog();
const { t } = useTranslation();
const defaultLevels: LogLevel[] = ["INFO", "ERROR", "DEBUG", "WARN"];
const codeBlockStyle = {
backgroundColor: "#000",
padding: 10,
fontFamily: "monospace",
maxHeight: 300,
};
const [loading, setLoading] = useState<boolean>(false);
const [state, setState] = useState<Record<string, boolean>>({});
const [order, setOrder] = useState<"asc" | "desc">("desc");
const [levels, setLevels] = useState<LogLevel[]>(defaultLevels);
const filteredLogs = useMemo(
() =>
logs
?.filter((log) => levels.includes(log.level))
?.[
// Already in asc order as they are recorded. just reverse for desc
order === "desc" ? "reverse" : "concat"
]?.(),
[logs, order, levels],
);
// Sharing it as txt while its formatted allows us to share it with many more applications
const share = useCallback(async () => {
const uri = `${FileSystem.documentDirectory}logs.txt`;
setLoading(true);
FileSystem.writeAsStringAsync(uri, JSON.stringify(filteredLogs))
.then(() => {
setLoading(false);
Sharing.shareAsync(uri, { mimeType: "txt", UTI: "txt" });
})
.catch((e) =>
writeErrorLog("Something went wrong attempting to export", e),
)
.finally(() => setLoading(false));
}, [filteredLogs]);
useEffect(() => {
navigation.setOptions({
headerRight: () =>
loading ? (
<Loader />
) : (
<TouchableOpacity onPress={share}>
<Text>{t("home.settings.logs.export_logs")}</Text>
</TouchableOpacity>
),
});
}, [share, loading]);
return (
<ScrollView className="p-4">
<View className="flex flex-col space-y-2">
{logs?.map((log, index) => (
<View key={index} className="bg-neutral-900 rounded-xl p-3">
<Text
className={`
mb-1
${log.level === "INFO" && "text-blue-500"}
${log.level === "ERROR" && "text-red-500"}
`}
>
{log.level}
</Text>
<Text uiTextView selectable className="text-xs">
{log.message}
</Text>
</View>
))}
{logs?.length === 0 && (
<Text className="opacity-50">{t("home.settings.logs.no_logs_available")}</Text>
)}
<>
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
<FilterButton
id='order'
queryKey='log'
queryFn={async () => ["asc", "desc"]}
set={(values) => setOrder(values[0])}
values={[order]}
title={t("library.filters.sort_order")}
renderItemLabel={(order) => t(`library.filters.${order}`)}
showSearch={false}
/>
<FilterButton
id='levels'
queryKey='log'
queryFn={async () => defaultLevels}
set={setLevels}
values={levels}
title={t("home.settings.logs.level")}
renderItemLabel={(level) => level}
showSearch={false}
multiple={true}
/>
</View>
</ScrollView>
<ScrollView className='pb-4 px-4'>
<View className='flex flex-col space-y-2'>
{filteredLogs?.map((log, index) => (
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
<TouchableOpacity
disabled={!log.data}
onPress={() =>
setState((v) => ({
...v,
[log.timestamp]: !v[log.timestamp],
}))
}
>
<View className='flex flex-row justify-between'>
<Text
className={`mb-1
${log.level === "INFO" && "text-blue-500"}
${log.level === "ERROR" && "text-red-500"}
${log.level === "DEBUG" && "text-purple-500"}
`}
>
{log.level}
</Text>
<Text className='text-xs'>
{new Date(log.timestamp).toLocaleString()}
</Text>
</View>
<Text uiTextView selectable className='text-xs'>
{log.message}
</Text>
</TouchableOpacity>
{log.data && (
<>
{!state[log.timestamp] && (
<Text className='text-xs mt-0.5'>
{t("home.settings.logs.click_for_more_info")}
</Text>
)}
<Collapsible collapsed={!state[log.timestamp]}>
<View className='mt-2 flex flex-col space-y-2'>
<ScrollView className='rounded-xl' style={codeBlockStyle}>
<Text>{JSON.stringify(log.data, null, 2)}</Text>
</ScrollView>
</View>
</Collapsible>
</>
)}
</View>
))}
{filteredLogs?.length === 0 && (
<Text className='opacity-50'>
{t("home.settings.logs.no_logs_available")}
</Text>
)}
</View>
</ScrollView>
</>
);
}

View File

@@ -6,7 +6,8 @@ import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useTranslation } from "react-i18next";
import React, {useEffect, useMemo, useState} from "react";
import DisabledSetting from "@/components/settings/DisabledSetting";
import React, { useEffect, useMemo, useState } from "react";
import {
Linking,
Switch,
@@ -15,7 +16,6 @@ import {
View,
} from "react-native";
import { toast } from "sonner-native";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const navigation = useNavigation();
@@ -39,7 +39,10 @@ export default function page() {
};
const disabled = useMemo(() => {
return pluginSettings?.searchEngine?.locked === true && pluginSettings?.marlinServerUrl?.locked === true
return (
pluginSettings?.searchEngine?.locked === true &&
pluginSettings?.marlinServerUrl?.locked === true
);
}, [pluginSettings]);
useEffect(() => {
@@ -47,7 +50,9 @@ export default function page() {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)}>
<Text className="text-blue-500">{t("home.settings.plugins.marlin_search.save_button")}</Text>
<Text className='text-blue-500'>
{t("home.settings.plugins.marlin_search.save_button")}
</Text>
</TouchableOpacity>
),
});
@@ -57,17 +62,16 @@ export default function page() {
if (!settings) return null;
return (
<DisabledSetting
disabled={disabled}
className="px-4"
>
<DisabledSetting disabled={disabled} className='px-4'>
<ListGroup>
<DisabledSetting
disabled={pluginSettings?.searchEngine?.locked === true}
showText={!pluginSettings?.marlinServerUrl?.locked}
>
<ListItem
title={t("home.settings.plugins.marlin_search.enable_marlin_search")}
title={t(
"home.settings.plugins.marlin_search.enable_marlin_search",
)}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
@@ -87,28 +91,30 @@ export default function page() {
<DisabledSetting
disabled={pluginSettings?.marlinServerUrl?.locked === true}
showText={!pluginSettings?.searchEngine?.locked}
className="mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4"
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
>
<View
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
>
<Text className="mr-4">{t("home.settings.plugins.marlin_search.url")}</Text>
<View className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}>
<Text className='mr-4'>
{t("home.settings.plugins.marlin_search.url")}
</Text>
<TextInput
editable={settings.searchEngine === "Marlin"}
className="text-white"
placeholder={t("home.settings.plugins.marlin_search.server_url_placeholder")}
className='text-white'
placeholder={t(
"home.settings.plugins.marlin_search.server_url_placeholder",
)}
value={value}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={(text) => setValue(text)}
/>
</View>
</DisabledSetting>
<Text className="px-4 text-xs text-neutral-500 mt-1">
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
<Text className="text-blue-500" onPress={handleOpenLink}>
<Text className='text-blue-500' onPress={handleOpenLink}>
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
</Text>
</Text>

View File

@@ -1,4 +1,5 @@
import { Text } from "@/components/common/Text";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
@@ -8,10 +9,9 @@ import { useMutation } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const navigation = useNavigation();
@@ -31,14 +31,14 @@ export default function page() {
return;
}
const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/";
const updatedUrl = newVal.endsWith("/") ? newVal : `${newVal}/`;
updateSettings({
optimizedVersionsServerUrl: updatedUrl,
});
return await getStatistics({
url: settings?.optimizedVersionsServerUrl,
url: updatedUrl,
authHeader: api?.accessToken,
deviceId: getOrSetDeviceId(),
});
@@ -67,8 +67,12 @@ export default function page() {
saveMutation.isPending ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
<Text className="text-blue-500">{t("home.settings.downloads.save_button")}</Text>
<TouchableOpacity
onPress={() => onSave(optimizedVersionsServerUrl)}
>
<Text className='text-blue-500'>
{t("home.settings.downloads.save_button")}
</Text>
</TouchableOpacity>
),
});
@@ -78,7 +82,7 @@ export default function page() {
return (
<DisabledSetting
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
className="p-4"
className='p-4'
>
<OptimizedServerForm
value={optimizedVersionsServerUrl}

View File

@@ -10,15 +10,15 @@ import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
import type { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { View } from "react-native";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
const page: React.FC = () => {
const local = useLocalSearchParams();
@@ -68,7 +68,7 @@ const page: React.FC = () => {
return response.data;
},
[api, user?.Id, actorId]
[api, user?.Id, actorId],
);
const backdropUrl = useMemo(
@@ -79,12 +79,12 @@ const page: React.FC = () => {
quality: 90,
width: 1000,
}),
[item]
[item],
);
if (l1)
return (
<View className="justify-center items-center h-full">
<View className='justify-center items-center h-full'>
<Loader />
</View>
);
@@ -105,13 +105,13 @@ const page: React.FC = () => {
/>
}
>
<View className="flex flex-col space-y-4 my-4">
<View className="px-4 mb-4">
<MoviesTitleHeader item={item} className="mb-4" />
<View className='flex flex-col space-y-4 my-4'>
<View className='px-4 mb-4'>
<MoviesTitleHeader item={item} className='mb-4' />
<OverviewText text={item.Overview} />
</View>
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
<Text className='px-4 text-2xl font-bold mb-2 text-neutral-100'>
{t("item_card.appeared_in")}
</Text>
<InfiniteHorizontalScroll
@@ -133,7 +133,7 @@ const page: React.FC = () => {
queryFn={fetchItems}
queryKey={["actor", "movies", actorId]}
/>
<View className="h-12"></View>
<View className='h-12' />
</View>
</ParallaxScrollView>
);

View File

@@ -1,22 +1,23 @@
import { ItemCardText } from "@/components/ItemCardText";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { ItemPoster } from "@/components/posters/ItemPoster";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
SortByOption,
SortOrderOption,
genreFilterAtom,
sortByAtom,
SortByOption,
sortOptions,
sortOrderAtom,
SortOrderOption,
sortOrderOptions,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import {
import type {
BaseItemDto,
BaseItemDtoQueryResult,
ItemSortBy,
@@ -29,11 +30,11 @@ import {
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router";
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";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, View } from "react-native";
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
@@ -43,7 +44,7 @@ const page: React.FC = () => {
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const [orientation, setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP
ScreenOrientation.Orientation.PORTRAIT_UP,
);
const { t } = useTranslation();
@@ -111,7 +112,7 @@ const page: React.FC = () => {
recursive: true,
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => parseInt(year)),
years: selectedYears.map((year) => Number.parseInt(year)),
includeItemTypes: ["Movie", "Series"],
});
@@ -126,7 +127,7 @@ const page: React.FC = () => {
selectedTags,
sortBy,
sortOrder,
]
],
);
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
@@ -151,14 +152,13 @@ const page: React.FC = () => {
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0
0,
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
} else {
return undefined;
}
return undefined;
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!collection,
@@ -188,8 +188,8 @@ const page: React.FC = () => {
index % 3 === 0
? "flex-end"
: (index + 1) % 3 === 0
? "flex-start"
: "center",
? "flex-start"
: "center",
width: "89%",
}}
>
@@ -199,14 +199,14 @@ const page: React.FC = () => {
</View>
</TouchableItemRouter>
),
[orientation]
[orientation],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const ListHeaderComponent = useCallback(
() => (
<View className="">
<View className=''>
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
@@ -232,13 +232,13 @@ const page: React.FC = () => {
key: "genre",
component: (
<FilterButton
className="mr-1"
collectionId={collectionId}
queryKey="genreFilter"
className='mr-1'
id={collectionId}
queryKey='genreFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
@@ -259,13 +259,13 @@ const page: React.FC = () => {
key: "year",
component: (
<FilterButton
className="mr-1"
collectionId={collectionId}
queryKey="yearFilter"
className='mr-1'
id={collectionId}
queryKey='yearFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
@@ -284,13 +284,13 @@ const page: React.FC = () => {
key: "tags",
component: (
<FilterButton
className="mr-1"
collectionId={collectionId}
queryKey="tagsFilter"
className='mr-1'
id={collectionId}
queryKey='tagsFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
@@ -311,9 +311,9 @@ const page: React.FC = () => {
key: "sortBy",
component: (
<FilterButton
className="mr-1"
collectionId={collectionId}
queryKey="sortBy"
className='mr-1'
id={collectionId}
queryKey='sortBy'
queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy}
values={sortBy}
@@ -331,9 +331,9 @@ const page: React.FC = () => {
key: "sortOrder",
component: (
<FilterButton
className="mr-1"
collectionId={collectionId}
queryKey="sortOrder"
className='mr-1'
id={collectionId}
queryKey='sortOrder'
queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder}
values={sortOrder}
@@ -368,7 +368,7 @@ const page: React.FC = () => {
sortOrder,
setSortOrder,
isFetching,
]
],
);
if (!collection) return null;
@@ -376,8 +376,10 @@ const page: React.FC = () => {
return (
<FlashList
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">{t("search.no_results")}</Text>
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("search.no_results")}
</Text>
</View>
}
extraData={[
@@ -387,7 +389,7 @@ const page: React.FC = () => {
sortBy,
sortOrder,
]}
contentInsetAdjustmentBehavior="automatic"
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
@@ -409,7 +411,7 @@ const page: React.FC = () => {
width: 10,
height: 10,
}}
></View>
/>
)}
/>
);

View File

@@ -1,11 +1,13 @@
import { Text } from "@/components/common/Text";
import { ItemContent } from "@/components/ItemContent";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect } from "react";
import type React from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import Animated, {
runOnJS,
@@ -13,7 +15,6 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useTranslation } from "react-i18next";
const Page: React.FC = () => {
const [api] = useAtom(apiAtom);
@@ -75,36 +76,36 @@ const Page: React.FC = () => {
if (isError)
return (
<View className="flex flex-col items-center justify-center h-screen w-screen">
<View className='flex flex-col items-center justify-center h-screen w-screen'>
<Text>{t("item_card.could_not_load_item")}</Text>
</View>
);
return (
<View className="flex flex-1 relative">
<View className='flex flex-1 relative'>
<Animated.View
pointerEvents={"none"}
style={[animatedStyle]}
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
>
<View
style={{
height: item?.Type === "Episode" ? 300 : 450,
}}
className="bg-transparent rounded-lg mb-4 w-full"
></View>
<View className="h-6 bg-neutral-900 rounded mb-4 w-14"></View>
<View className="h-10 bg-neutral-900 rounded-lg mb-2 w-1/2"></View>
<View className="h-3 bg-neutral-900 rounded mb-3 w-8"></View>
<View className="flex flex-row space-x-1 mb-8">
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
className='bg-transparent rounded-lg mb-4 w-full'
/>
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
<View className='flex flex-row space-x-1 mb-8'>
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
</View>
<View className="h-3 bg-neutral-900 rounded w-2/3 mb-1"></View>
<View className="h-10 bg-neutral-900 rounded-lg w-full mb-2"></View>
<View className="h-12 bg-neutral-900 rounded-lg w-full mb-2"></View>
<View className="h-24 bg-neutral-900 rounded-lg mb-1 w-full"></View>
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
</Animated.View>
{item && <ItemContent item={item} />}
</View>

View File

@@ -1,45 +1,45 @@
import {router, useLocalSearchParams, useSegments,} from "expo-router";
import React, {useMemo,} from "react";
import {TouchableOpacity} from "react-native";
import {useInfiniteQuery} from "@tanstack/react-query";
import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
import {Text} from "@/components/common/Text";
import {Image} from "expo-image";
import Poster from "@/components/posters/Poster";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {COMPANY_LOGO_IMAGE_FILTER} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import {uniqBy} from "lodash";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import {
type MovieResult,
Results,
type TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { uniqBy } from "lodash";
import React, { useMemo } from "react";
export default function page() {
const local = useLocalSearchParams();
const {jellyseerrApi} = useJellyseerr();
const { jellyseerrApi } = useJellyseerr();
const {companyId, name, image, type} = local as unknown as {
companyId: string,
name: string,
image: string,
type: DiscoverSliderType
const { companyId, name, image, type } = local as unknown as {
companyId: string;
name: string;
image: string;
type: DiscoverSliderType;
};
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, companyId],
queryFn: async ({pageParam}) => {
let params: any = {
queryFn: async ({ pageParam }) => {
const params: any = {
page: Number(pageParam),
};
return jellyseerrApi?.discover(
(
type == DiscoverSliderType.NETWORKS
? Endpoints.DISCOVER_TV_NETWORK
: Endpoints.DISCOVER_MOVIES_STUDIO
) + `/${companyId}`,
params
)
`${
type === DiscoverSliderType.NETWORKS
? Endpoints.DISCOVER_TV_NETWORK
: Endpoints.DISCOVER_MOVIES_STUDIO
}/${companyId}`,
params,
);
},
enabled: !!jellyseerrApi && !!companyId,
initialPageParam: 1,
@@ -50,46 +50,58 @@ export default function page() {
});
const flatData = useMemo(
() => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
[data]
() =>
uniqBy(
data?.pages
?.filter((p) => p?.results.length)
.flatMap((p) => p?.results ?? []),
"id",
) ?? [],
[data],
);
const backdrops = useMemo(
() => jellyseerrApi
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
: [],
[jellyseerrApi, flatData]
() =>
jellyseerrApi
? flatData.map((r) =>
jellyseerrApi.imageProxy(
(r as TvResult | MovieResult).backdropPath,
"w1920_and_h800_multi_faces",
),
)
: [],
[jellyseerrApi, flatData],
);
return (
<ParallaxSlideShow
data={flatData}
images={backdrops}
listHeader=""
listHeader=''
keyExtractor={(item) => item.id.toString()}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage()
fetchNextPage();
}
}}
logo={
<Image
id={companyId}
key={companyId}
className="bottom-1 w-1/2"
className='bottom-1 w-1/2'
source={{
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
}}
cachePolicy={"memory-disk"}
contentFit="contain"
contentFit='contain'
style={{
aspectRatio: "4/3",
}}
/>
}
renderItem={(item, index) =>
renderItem={(item, index) => (
<JellyseerrPoster item={item as MovieResult | TvResult} />
}
)}
/>
);
}

View File

@@ -1,42 +1,46 @@
import {router, useLocalSearchParams, useSegments,} from "expo-router";
import React, {useMemo,} from "react";
import {TouchableOpacity} from "react-native";
import {useInfiniteQuery} from "@tanstack/react-query";
import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
import {Text} from "@/components/common/Text";
import Poster from "@/components/posters/Poster";
import { Text } from "@/components/common/Text";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {uniqBy} from "lodash";
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import Poster from "@/components/posters/Poster";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import {
type MovieResult,
Results,
type TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { useInfiniteQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams, useSegments } from "expo-router";
import { uniqBy } from "lodash";
import React, { useMemo } from "react";
import { TouchableOpacity } from "react-native";
export default function page() {
const local = useLocalSearchParams();
const {jellyseerrApi} = useJellyseerr();
const { jellyseerrApi } = useJellyseerr();
const {genreId, name, type} = local as unknown as {
genreId: string,
name: string,
type: DiscoverSliderType
const { genreId, name, type } = local as unknown as {
genreId: string;
name: string;
type: DiscoverSliderType;
};
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, genreId],
queryFn: async ({pageParam}) => {
let params: any = {
queryFn: async ({ pageParam }) => {
const params: any = {
page: Number(pageParam),
genre: genreId
genre: genreId,
};
return jellyseerrApi?.discover(
type == DiscoverSliderType.MOVIE_GENRES
? Endpoints.DISCOVER_MOVIES
: Endpoints.DISCOVER_TV,
params
)
type === DiscoverSliderType.MOVIE_GENRES
? Endpoints.DISCOVER_MOVIES
: Endpoints.DISCOVER_TV,
params,
);
},
enabled: !!jellyseerrApi && !!genreId,
initialPageParam: 1,
@@ -47,41 +51,54 @@ export default function page() {
});
const flatData = useMemo(
() => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
[data]
() =>
uniqBy(
data?.pages
?.filter((p) => p?.results.length)
.flatMap((p) => p?.results ?? []),
"id",
) ?? [],
[data],
);
const backdrops = useMemo(
() => jellyseerrApi
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
: [],
[jellyseerrApi, flatData]
() =>
jellyseerrApi
? flatData.map((r) =>
jellyseerrApi.imageProxy(
(r as TvResult | MovieResult).backdropPath,
"w1920_and_h800_multi_faces",
),
)
: [],
[jellyseerrApi, flatData],
);
return (
<ParallaxSlideShow
data={flatData}
images={backdrops}
listHeader=""
listHeader=''
keyExtractor={(item) => item.id.toString()}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage()
fetchNextPage();
}
}}
logo={
<Text
className="text-4xl font-bold text-center bottom-1"
className='text-4xl font-bold text-center bottom-1'
style={{
...textShadowStyle.shadow,
shadowRadius: 10
}}>
shadowRadius: 10,
}}
>
{name}
</Text>
}
renderItem={(item, index) =>
renderItem={(item, index) => (
<JellyseerrPoster item={item as MovieResult | TvResult} />
}
)}
/>
);
}

View File

@@ -1,27 +1,29 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { JellyserrRatings } from "@/components/Ratings";
import { Text } from "@/components/common/Text";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {
IssueType,
type IssueType,
IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { useTranslation } from "react-i18next";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
@@ -29,38 +31,37 @@ 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 type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
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 type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const { t } = useTranslation();
const { mediaTitle, releaseYear, posterSrc, ...result } =
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
params as unknown as {
mediaTitle: string;
releaseYear: number;
canRequest: string;
posterSrc: string;
} & Partial<MovieResult | TvResult>;
mediaType: MediaType;
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
const navigation = useNavigation();
const { jellyseerrApi, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -71,7 +72,7 @@ const Page: React.FC = () => {
refetch,
} = useQuery({
enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
queryKey: ["jellyseerr", "detail", mediaType, result.id],
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
@@ -79,9 +80,9 @@ const Page: React.FC = () => {
retryOnMount: true,
refetchInterval: 0,
queryFn: async () => {
return result.mediaType === MediaType.MOVIE
? jellyseerrApi?.movieDetails(result.id!!)
: jellyseerrApi?.tvDetails(result.id!!);
return mediaType === MediaType.MOVIE
? jellyseerrApi?.movieDetails(result.id!)
: jellyseerrApi?.tvDetails(result.id!);
},
});
@@ -96,7 +97,7 @@ const Page: React.FC = () => {
appearsOnIndex={0}
/>
),
[]
[],
);
const submitIssue = useCallback(() => {
@@ -111,10 +112,18 @@ const Page: React.FC = () => {
}
}, [jellyseerrApi, details, result, issueType, issueMessage]);
const setRequestBody = useCallback(
(body: MediaRequestBody) => {
_setRequestBody(body);
advancedReqModalRef?.current?.present?.();
},
[requestBody, _setRequestBody, advancedReqModalRef],
);
const request = useCallback(async () => {
const body: MediaRequestBody = {
mediaId: Number(result.id!!),
mediaType: result.mediaType!!,
mediaId: Number(result.id!),
mediaType: mediaType!,
tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
@@ -122,7 +131,7 @@ const Page: React.FC = () => {
};
if (hasAdvancedRequestPermission) {
advancedReqModalRef?.current?.present?.(body);
setRequestBody(body);
return;
}
@@ -132,15 +141,15 @@ const Page: React.FC = () => {
const isAnime = useMemo(
() =>
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
result.mediaType === MediaType.TV,
[details]
mediaType === MediaType.TV,
[details],
);
useEffect(() => {
if (details) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity className="rounded-full p-2 bg-neutral-800/80">
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
<ItemActions item={details} />
</TouchableOpacity>
),
@@ -150,14 +159,14 @@ const Page: React.FC = () => {
return (
<View
className="flex-1 relative"
className='flex-1 relative'
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<ParallaxScrollView
className="flex-1 opacity-100"
className='flex-1 opacity-100'
headerHeight={300}
headerImage={
<View>
@@ -172,7 +181,7 @@ const Page: React.FC = () => {
source={{
uri: jellyseerrApi?.imageProxy(
result.backdropPath,
"w1920_and_h800_multi_faces"
"w1920_and_h800_multi_faces",
),
}}
/>
@@ -182,12 +191,12 @@ const Page: React.FC = () => {
width: "100%",
height: "100%",
}}
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
className='flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900'
>
<Ionicons
name="image-outline"
name='image-outline'
size={24}
color="white"
color='white'
style={{ opacity: 0.4 }}
/>
</View>
@@ -195,23 +204,31 @@ const Page: React.FC = () => {
</View>
}
>
<View className="flex flex-col">
<View className="space-y-4">
<View className="px-4">
<View className="flex flex-row justify-between w-full">
<View className="flex flex-col w-56">
<JellyserrRatings result={result as MovieResult | TvResult} />
<View className='flex flex-col'>
<View className='space-y-4'>
<View className='px-4'>
<View className='flex flex-row justify-between w-full'>
<View className='flex flex-col w-56'>
<JellyserrRatings
result={
result as
| MovieResult
| TvResult
| MovieDetails
| TvDetails
}
/>
<Text
uiTextView
selectable
className="font-bold text-2xl mb-1"
className='font-bold text-2xl mb-1'
>
{mediaTitle}
</Text>
<Text className="opacity-50">{releaseYear}</Text>
<Text className='opacity-50'>{releaseYear}</Text>
</View>
<Image
className="absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl"
className='absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl'
cachePolicy={"memory-disk"}
transition={300}
source={{
@@ -219,22 +236,22 @@ const Page: React.FC = () => {
}}
/>
</View>
<View className="mb-4">
<View className='mb-4'>
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
</View>
{isLoading || isFetching ? (
<Button loading={true} disabled={true} color="purple"></Button>
<Button loading={true} disabled={true} color='purple' />
) : canRequest ? (
<Button color="purple" onPress={request}>
<Button color='purple' onPress={request}>
{t("jellyseerr.request_button")}
</Button>
) : (
<Button
className="bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100"
color="transparent"
className='bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
color='transparent'
onPress={() => bottomSheetModalRef?.current?.present()}
iconLeft={
<Ionicons name="warning-outline" size={24} color="white" />
<Ionicons name='warning-outline' size={24} color='white' />
}
style={{
borderWidth: 1,
@@ -244,23 +261,20 @@ const Page: React.FC = () => {
{t("jellyseerr.report_issue_button")}
</Button>
)}
<OverviewText text={result.overview} className="mt-4" />
<OverviewText text={result.overview} className='mt-4' />
</View>
{result.mediaType === MediaType.TV && (
{mediaType === MediaType.TV && (
<JellyseerrSeasons
isLoading={isLoading || isFetching}
result={result as TvResult}
details={details as TvDetails}
refetch={refetch}
hasAdvancedRequest={hasAdvancedRequestPermission}
onAdvancedRequest={(data) =>
advancedReqModalRef?.current?.present(data)
}
onAdvancedRequest={(data) => setRequestBody(data)}
/>
)}
<DetailFacts
className="p-2 border border-neutral-800 bg-neutral-900 rounded-xl"
className='p-2 border border-neutral-800 bg-neutral-900 rounded-xl'
details={details}
/>
<Cast details={details} />
@@ -269,14 +283,17 @@ const Page: React.FC = () => {
</ParallaxScrollView>
<RequestModal
ref={advancedReqModalRef}
requestBody={requestBody}
title={mediaTitle}
id={result.id!!}
type={result.mediaType as MediaType}
id={result.id!}
type={mediaType}
isAnime={isAnime}
onRequested={() => {
_setRequestBody(undefined);
advancedReqModalRef?.current?.close();
refetch();
}}
onDismiss={() => _setRequestBody(undefined)}
/>
<BottomSheetModal
ref={bottomSheetModalRef}
@@ -290,22 +307,22 @@ const Page: React.FC = () => {
backdropComponent={renderBackdrop}
>
<BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View>
<Text className="font-bold text-2xl text-neutral-100">
<Text className='font-bold text-2xl text-neutral-100'>
{t("jellyseerr.whats_wrong")}
</Text>
</View>
<View className="flex flex-col space-y-2 items-start">
<View className="flex flex-col">
<View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col'>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col">
<Text className="opacity-50 mb-1 text-xs">
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.issue_type")}
</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}>
<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}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
@@ -315,8 +332,8 @@ const Page: React.FC = () => {
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side="bottom"
align="center"
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
@@ -343,14 +360,14 @@ const Page: React.FC = () => {
</DropdownMenu.Root>
</View>
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
<BottomSheetTextInput
multiline
maxLength={254}
style={{ color: "white" }}
clearButtonMode="always"
clearButtonMode='always'
placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor="#9CA3AF"
placeholderTextColor='#9CA3AF'
// Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668
defaultValue={issueMessage}
@@ -358,7 +375,7 @@ const Page: React.FC = () => {
/>
</View>
</View>
<Button className="mt-auto" onPress={submitIssue} color="purple">
<Button className='mt-auto' onPress={submitIssue} color='purple'>
{t("jellyseerr.submit_button")}
</Button>
</View>

View File

@@ -1,25 +1,30 @@
import {
useLocalSearchParams,
useSegments,
} from "expo-router";
import React, { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { Text } from "@/components/common/Text";
import { Image } from "expo-image";
import { OverviewText } from "@/components/OverviewText";
import {orderBy, uniqBy} from "lodash";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import { Text } from "@/components/common/Text";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useSegments } from "expo-router";
import { orderBy, uniqBy } from "lodash";
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
export default function page() {
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,29 +37,29 @@ 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(
data?.combinedCredits?.cast,
["voteCount", "voteAverage"],
"desc"
), 'id'),
[data?.combinedCredits]
uniqBy(
orderBy(
data?.combinedCredits?.cast,
["voteCount", "voteAverage"],
"desc",
),
"id",
),
[data?.combinedCredits],
);
const backdrops = useMemo(
() => jellyseerrApi
? castedRoles.map((c) => jellyseerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"))
: [],
[jellyseerrApi, data?.combinedCredits]
() =>
jellyseerrApi
? castedRoles.map((c) =>
jellyseerrApi.imageProxy(
c.backdropPath,
"w1920_and_h800_multi_faces",
),
)
: [],
[jellyseerrApi, data?.combinedCredits],
);
return (
@@ -67,15 +72,15 @@ export default function page() {
<Image
key={data?.details?.id}
id={data?.details?.id.toString()}
className="rounded-full bottom-1"
className='rounded-full bottom-1'
source={{
uri: jellyseerrApi?.imageProxy(
data?.details?.profilePath,
"w600_and_h600_bestv2"
"w600_and_h600_bestv2",
),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
contentFit='cover'
style={{
width: 125,
height: 125,
@@ -84,27 +89,27 @@ export default function page() {
}
HeaderContent={() => (
<>
<Text className="font-bold text-2xl mb-1">
{data?.details?.name}
</Text>
<Text className="opacity-50">
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
<Text className='opacity-50'>
{t("jellyseerr.born")}{" "}
{new Date(data?.details?.birthday!!).toLocaleDateString(
{new Date(data?.details?.birthday!).toLocaleDateString(
`${locale}-${region}`,
{
year: "numeric",
month: "long",
day: "numeric",
}
},
)}{" "}
| {data?.details?.placeOfBirth}
</Text>
</>
)}
MainContent={() => (
<OverviewText text={data?.details?.biography} className="mt-4" />
<OverviewText text={data?.details?.biography} className='mt-4' />
)}
renderItem={(item, index) => (
<JellyseerrPoster item={item as MovieResult | TvResult} />
)}
renderItem={(item, index) => <JellyseerrPoster item={item as MovieResult | TvResult} />}
/>
);
}

View File

@@ -3,7 +3,10 @@ import type {
MaterialTopTabNavigationOptions,
} from "@react-navigation/material-top-tabs";
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
import { ParamListBase, TabNavigationState } from "@react-navigation/native";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { Stack, withLayoutContext } from "expo-router";
import React from "react";
@@ -21,8 +24,8 @@ const Layout = () => {
<>
<Stack.Screen options={{ title: "Live TV" }} />
<Tab
initialRouteName="programs"
keyboardDismissMode="none"
initialRouteName='programs'
keyboardDismissMode='none'
screenOptions={{
tabBarBounces: true,
tabBarLabelStyle: { fontSize: 10 },
@@ -37,10 +40,10 @@ const Layout = () => {
tabBarScrollEnabled: true,
}}
>
<Tab.Screen name="programs" />
<Tab.Screen name="guide" />
<Tab.Screen name="channels" />
<Tab.Screen name="recordings" />
<Tab.Screen name='programs' />
<Tab.Screen name='guide' />
<Tab.Screen name='channels' />
<Tab.Screen name='recordings' />
</Tab>
</>
);

View File

@@ -31,13 +31,13 @@ export default function page() {
});
return (
<View className="flex flex-1">
<View className='flex flex-1'>
<FlashList
data={channels?.Items}
estimatedItemSize={76}
renderItem={({ item }) => (
<View className="flex flex-row items-center px-4 mb-2">
<View className="w-22 mr-4 rounded-lg overflow-hidden">
<View className='flex flex-row items-center px-4 mb-2'>
<View className='w-22 mr-4 rounded-lg overflow-hidden'>
<ItemImage
style={{
aspectRatio: "1/1",
@@ -47,7 +47,7 @@ export default function page() {
item={item}
/>
</View>
<Text className="font-bold">{item.Name}</Text>
<Text className='font-bold'>{item.Name}</Text>
</View>
)}
/>

View File

@@ -9,6 +9,7 @@ import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React, { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Button,
Dimensions,
@@ -17,7 +18,6 @@ import {
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
const HOUR_HEIGHT = 30;
const ITEMS_PER_PAGE = 20;
@@ -71,7 +71,7 @@ export default function page() {
MaxStartDate: endOfDay.toISOString(),
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
ChannelIds: channels?.Items?.map((c) => c.Id).filter(
Boolean
Boolean,
) as string[],
ImageTypeLimit: 1,
EnableImages: false,
@@ -100,7 +100,7 @@ export default function page() {
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
contentInsetAdjustmentBehavior='automatic'
key={"home"}
contentContainerStyle={{
paddingLeft: insets.left,
@@ -117,16 +117,16 @@ export default function page() {
}
/>
<View className="flex flex-row">
<View className="flex flex-col w-[64px]">
<View className='flex flex-row'>
<View className='flex flex-col w-[64px]'>
<View
style={{
height: HOUR_HEIGHT,
}}
className="bg-neutral-800"
></View>
className='bg-neutral-800'
/>
{channels?.Items?.map((c, i) => (
<View className="h-16 w-16 mr-4 rounded-lg overflow-hidden" key={i}>
<View className='h-16 w-16 mr-4 rounded-lg overflow-hidden' key={i}>
<ItemImage
style={{
width: "100%",
@@ -148,7 +148,7 @@ export default function page() {
setScrollX(e.nativeEvent.contentOffset.x);
}}
>
<View className="flex flex-col">
<View className='flex flex-col'>
<HourHeader height={HOUR_HEIGHT} />
{channels?.Items?.map((c, i) => (
<MemoizedLiveTVGuideRow
@@ -180,14 +180,14 @@ const PageButtons: React.FC<PageButtonsProps> = ({
}) => {
const { t } = useTranslation();
return (
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
<View className='flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2'>
<TouchableOpacity
onPress={onPrevPage}
disabled={currentPage === 1}
className="flex flex-row items-center"
className='flex flex-row items-center'
>
<Ionicons
name="chevron-back"
name='chevron-back'
size={24}
color={currentPage === 1 ? "gray" : "white"}
/>
@@ -199,11 +199,11 @@ const PageButtons: React.FC<PageButtonsProps> = ({
{t("live_tv.previous")}
</Text>
</TouchableOpacity>
<Text className="text-white">Page {currentPage}</Text>
<Text className='text-white'>Page {currentPage}</Text>
<TouchableOpacity
onPress={onNextPage}
disabled={isNextDisabled}
className="flex flex-row items-center"
className='flex flex-row items-center'
>
<Text
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
@@ -211,7 +211,7 @@ const PageButtons: React.FC<PageButtonsProps> = ({
{t("live_tv.next")}
</Text>
<Ionicons
name="chevron-forward"
name='chevron-forward'
size={24}
color={isNextDisabled ? "gray" : "white"}
/>

View File

@@ -1,13 +1,13 @@
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { TAB_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import React from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
export default function page() {
const [api] = useAtom(apiAtom);
@@ -19,7 +19,7 @@ export default function page() {
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
contentInsetAdjustmentBehavior='automatic'
key={"home"}
contentContainerStyle={{
paddingLeft: insets.left,
@@ -28,7 +28,7 @@ export default function page() {
paddingTop: 8,
}}
>
<View className="flex flex-col space-y-2">
<View className='flex flex-col space-y-2'>
<ScrollingCollectionList
queryKey={["livetv", "recommended"]}
title={t("live_tv.on_now")}
@@ -45,7 +45,7 @@ export default function page() {
});
return res.data.Items || [];
}}
orientation="horizontal"
orientation='horizontal'
/>
<ScrollingCollectionList
queryKey={["livetv", "shows"]}
@@ -67,7 +67,7 @@ export default function page() {
});
return res.data.Items || [];
}}
orientation="horizontal"
orientation='horizontal'
/>
<ScrollingCollectionList
queryKey={["livetv", "movies"]}
@@ -85,7 +85,7 @@ export default function page() {
});
return res.data.Items || [];
}}
orientation="horizontal"
orientation='horizontal'
/>
<ScrollingCollectionList
queryKey={["livetv", "sports"]}
@@ -103,7 +103,7 @@ export default function page() {
});
return res.data.Items || [];
}}
orientation="horizontal"
orientation='horizontal'
/>
<ScrollingCollectionList
queryKey={["livetv", "kids"]}
@@ -121,7 +121,7 @@ export default function page() {
});
return res.data.Items || [];
}}
orientation="horizontal"
orientation='horizontal'
/>
<ScrollingCollectionList
queryKey={["livetv", "news"]}
@@ -139,7 +139,7 @@ export default function page() {
});
return res.data.Items || [];
}}
orientation="horizontal"
orientation='horizontal'
/>
</View>
</ScrollView>

View File

@@ -1,12 +1,12 @@
import { Text } from "@/components/common/Text";
import React from "react";
import { View } from "react-native";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
export default function page() {
const { t } = useTranslation();
return (
<View className="flex items-center justify-center h-full -mt-12">
<View className='flex items-center justify-center h-full -mt-12'>
<Text>{t("live_tv.coming_soon")}</Text>
</View>
);

View File

@@ -14,9 +14,10 @@ import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect, useMemo } from "react";
import { View } from "react-native";
import type React from "react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
const page: React.FC = () => {
const navigation = useNavigation();
@@ -49,7 +50,7 @@ const page: React.FC = () => {
quality: 90,
width: 1000,
}),
[item]
[item],
);
const logoUrl = useMemo(
@@ -58,7 +59,7 @@ const page: React.FC = () => {
api,
item,
}),
[item]
[item],
);
const { data: allEpisodes, isLoading } = useQuery({
@@ -83,23 +84,25 @@ const page: React.FC = () => {
item &&
allEpisodes &&
allEpisodes.length > 0 && (
<View className="flex flex-row items-center space-x-2">
<AddToFavorites item={item} type="series" />
<DownloadItems
size="large"
title={t("item_card.download.download_series")}
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name="download" size={22} color="white" />
)}
DownloadedIconComponent={() => (
<Ionicons
name="checkmark-done-outline"
size={24}
color="#9333ea"
/>
)}
/>
<View className='flex flex-row items-center space-x-2'>
<AddToFavorites item={item} />
{!Platform.isTV && (
<DownloadItems
size='large'
title={t("item_card.download.download_series")}
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name='download' size={22} color='white' />
)}
DownloadedIconComponent={() => (
<Ionicons
name='checkmark-done-outline'
size={24}
color='#9333ea'
/>
)}
/>
)}
</View>
),
});
@@ -122,25 +125,23 @@ const page: React.FC = () => {
/>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
) : null}
</>
logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
) : null
}
>
<View className="flex flex-col pt-4">
<View className='flex flex-col pt-4'>
<SeriesHeader item={item} />
<View className="mb-4">
<View className='mb-4'>
<NextUp seriesId={seriesId} />
</View>
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />

View File

@@ -1,35 +1,35 @@
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router";
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";
import { FlatList, View, useWindowDimensions } from "react-native";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
SortByOption,
SortOrderOption,
genreFilterAtom,
getSortByPreference,
getSortOrderPreference,
sortByAtom,
SortByOption,
sortByPreferenceAtom,
sortOptions,
sortOrderAtom,
SortOrderOption,
sortOrderOptions,
sortOrderPreferenceAtom,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import {
import type {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
@@ -40,8 +40,8 @@ import {
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const Page = () => {
const searchParams = useLocalSearchParams();
@@ -58,7 +58,7 @@ const Page = () => {
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
const [sortOrderPreference, setOderByPreference] = useAtom(
sortOrderPreferenceAtom
sortOrderPreferenceAtom,
);
const { orientation } = useOrientation();
@@ -88,7 +88,7 @@ const Page = () => {
}
_setSortBy(sortBy);
},
[libraryId, sortByPreference]
[libraryId, sortByPreference],
);
const setSortOrder = useCallback(
@@ -102,7 +102,7 @@ const Page = () => {
}
_setSortOrder(sortOrder);
},
[libraryId, sortOrderPreference]
[libraryId, sortOrderPreference],
);
const nrOfCols = useMemo(() => {
@@ -169,7 +169,7 @@ const Page = () => {
fields: ["PrimaryImageAspectRatio", "SortName"],
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => parseInt(year)),
years: selectedYears.map((year) => Number.parseInt(year)),
includeItemTypes: itemType ? [itemType] : undefined,
});
@@ -185,7 +185,7 @@ const Page = () => {
selectedTags,
sortBy,
sortOrder,
]
],
);
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
@@ -211,14 +211,13 @@ const Page = () => {
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0
0,
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
} else {
return undefined;
}
return undefined;
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!library,
@@ -248,8 +247,8 @@ const Page = () => {
? index % nrOfCols === 0
? "flex-end"
: (index + 1) % nrOfCols === 0
? "flex-start"
: "center"
? "flex-start"
: "center"
: "center",
width: "89%",
}}
@@ -260,14 +259,14 @@ const Page = () => {
</View>
</TouchableItemRouter>
),
[orientation]
[orientation],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const ListHeaderComponent = useCallback(
() => (
<View className="">
<View className=''>
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
@@ -286,13 +285,13 @@ const Page = () => {
key: "genre",
component: (
<FilterButton
className="mr-1"
collectionId={libraryId}
queryKey="genreFilter"
className='mr-1'
id={libraryId}
queryKey='genreFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
@@ -313,13 +312,13 @@ const Page = () => {
key: "year",
component: (
<FilterButton
className="mr-1"
collectionId={libraryId}
queryKey="yearFilter"
className='mr-1'
id={libraryId}
queryKey='yearFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
@@ -338,13 +337,13 @@ const Page = () => {
key: "tags",
component: (
<FilterButton
className="mr-1"
collectionId={libraryId}
queryKey="tagsFilter"
className='mr-1'
id={libraryId}
queryKey='tagsFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
@@ -365,9 +364,9 @@ const Page = () => {
key: "sortBy",
component: (
<FilterButton
className="mr-1"
collectionId={libraryId}
queryKey="sortBy"
className='mr-1'
id={libraryId}
queryKey='sortBy'
queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy}
values={sortBy}
@@ -385,9 +384,9 @@ const Page = () => {
key: "sortOrder",
component: (
<FilterButton
className="mr-1"
collectionId={libraryId}
queryKey="sortOrder"
className='mr-1'
id={libraryId}
queryKey='sortOrder'
queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder}
values={sortOrder}
@@ -422,22 +421,24 @@ const Page = () => {
sortOrder,
setSortOrder,
isFetching,
]
],
);
const insets = useSafeAreaInsets();
if (isLoading || isLibraryLoading)
return (
<View className="w-full h-full flex items-center justify-center">
<View className='w-full h-full flex items-center justify-center'>
<Loader />
</View>
);
if (flatData.length === 0)
return (
<View className="h-full w-full flex justify-center items-center">
<Text className="text-lg text-neutral-500">{t("library.no_items_found")}</Text>
<View className='h-full w-full flex justify-center items-center'>
<Text className='text-lg text-neutral-500'>
{t("library.no_items_found")}
</Text>
</View>
);
@@ -445,11 +446,13 @@ const Page = () => {
<FlashList
key={orientation}
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">{t("library.no_results")}</Text>
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("library.no_results")}
</Text>
</View>
}
contentInsetAdjustmentBehavior="automatic"
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
@@ -474,7 +477,7 @@ const Page = () => {
width: 10,
height: 10,
}}
></View>
/>
)}
/>
);

View File

@@ -16,7 +16,7 @@ export default function IndexLayout() {
return (
<Stack>
<Stack.Screen
name="index"
name='index'
options={{
headerShown: true,
headerLargeTitle: true,
@@ -25,7 +25,7 @@ export default function IndexLayout() {
headerLargeStyle: {
backgroundColor: "black",
},
headerTransparent: Platform.OS === "ios" ? true : false,
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerRight: () =>
!pluginSettings?.libraryOptions?.locked &&
@@ -33,9 +33,9 @@ export default function IndexLayout() {
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Ionicons
name="ellipsis-horizontal-outline"
name='ellipsis-horizontal-outline'
size={24}
color="white"
color='white'
/>
</DropdownMenu.Trigger>
<DropdownMenu.Content
@@ -50,9 +50,9 @@ export default function IndexLayout() {
<DropdownMenu.Label>
{t("library.options.display")}
</DropdownMenu.Label>
<DropdownMenu.Group key="display-group">
<DropdownMenu.Group key='display-group'>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="image-style-trigger">
<DropdownMenu.SubTrigger key='image-style-trigger'>
{t("library.options.display")}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
@@ -63,7 +63,7 @@ export default function IndexLayout() {
sideOffset={10}
>
<DropdownMenu.CheckboxItem
key="display-option-1"
key='display-option-1'
value={settings.libraryOptions.display === "row"}
onValueChange={() =>
updateSettings({
@@ -75,12 +75,12 @@ export default function IndexLayout() {
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="display-title-1">
<DropdownMenu.ItemTitle key='display-title-1'>
{t("library.options.row")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key="display-option-2"
key='display-option-2'
value={settings.libraryOptions.display === "list"}
onValueChange={() =>
updateSettings({
@@ -92,14 +92,14 @@ export default function IndexLayout() {
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="display-title-2">
<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">
<DropdownMenu.SubTrigger key='image-style-trigger'>
{t("library.options.image_style")}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
@@ -110,7 +110,7 @@ export default function IndexLayout() {
sideOffset={10}
>
<DropdownMenu.CheckboxItem
key="poster-option"
key='poster-option'
value={
settings.libraryOptions.imageStyle === "poster"
}
@@ -124,12 +124,12 @@ export default function IndexLayout() {
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="poster-title">
<DropdownMenu.ItemTitle key='poster-title'>
{t("library.options.poster")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key="cover-option"
key='cover-option'
value={settings.libraryOptions.imageStyle === "cover"}
onValueChange={() =>
updateSettings({
@@ -141,17 +141,17 @@ export default function IndexLayout() {
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="cover-title">
<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.Group key='show-titles-group'>
<DropdownMenu.CheckboxItem
disabled={settings.libraryOptions.imageStyle === "poster"}
key="show-titles-option"
key='show-titles-option'
value={settings.libraryOptions.showTitles}
onValueChange={(newValue: string) => {
if (settings.libraryOptions.imageStyle === "poster")
@@ -159,30 +159,30 @@ export default function IndexLayout() {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showTitles: newValue === "on" ? true : false,
showTitles: newValue === "on",
},
});
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="show-titles-title">
<DropdownMenu.ItemTitle key='show-titles-title'>
{t("library.options.show_titles")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key="show-stats-option"
key='show-stats-option'
value={settings.libraryOptions.showStats}
onValueChange={(newValue: string) => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showStats: newValue === "on" ? true : false,
showStats: newValue === "on",
},
});
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="show-stats-title">
<DropdownMenu.ItemTitle key='show-stats-title'>
{t("library.options.show_stats")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
@@ -195,12 +195,12 @@ export default function IndexLayout() {
}}
/>
<Stack.Screen
name="[libraryId]"
name='[libraryId]'
options={{
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
@@ -208,12 +208,12 @@ export default function IndexLayout() {
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name="collections/[collectionId]"
name='collections/[collectionId]'
options={{
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>

View File

@@ -1,6 +1,6 @@
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
import { Loader } from "@/components/Loader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
@@ -11,9 +11,9 @@ import { FlashList } from "@shopify/flash-list";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
export default function index() {
const [api] = useAtom(apiAtom);
@@ -23,7 +23,7 @@ export default function index() {
const { t } = useTranslation();
const { data, isLoading: isLoading } = useQuery({
const { data, isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({
@@ -41,7 +41,7 @@ export default function index() {
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
.filter((l) => l.CollectionType !== "music")
.filter((l) => l.CollectionType !== "books") || [],
[data, settings?.hiddenLibraries]
[data, settings?.hiddenLibraries],
);
useEffect(() => {
@@ -65,22 +65,24 @@ export default function index() {
if (isLoading)
return (
<View className="justify-center items-center h-full">
<View className='justify-center items-center h-full'>
<Loader />
</View>
);
if (!libraries)
return (
<View className="h-full w-full flex justify-center items-center">
<Text className="text-lg text-neutral-500">{t("library.no_libraries_found")}</Text>
<View className='h-full w-full flex justify-center items-center'>
<Text className='text-lg text-neutral-500'>
{t("library.no_libraries_found")}
</Text>
</View>
);
return (
<FlashList
extraData={settings}
contentInsetAdjustmentBehavior="automatic"
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingTop: 17,
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
@@ -97,10 +99,10 @@ export default function index() {
style={{
height: StyleSheet.hairlineWidth,
}}
className="bg-neutral-800 mx-2 my-4"
></View>
className='bg-neutral-800 mx-2 my-4'
/>
) : (
<View className="h-4" />
<View className='h-4' />
)
}
estimatedItemSize={200}

View File

@@ -3,15 +3,15 @@ import {
nestedTabPageScreenOptions,
} from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
export default function SearchLayout() {
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
name="index"
name='index'
options={{
headerShown: true,
headerLargeTitle: true,
@@ -20,7 +20,7 @@ export default function SearchLayout() {
backgroundColor: "black",
},
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
@@ -28,26 +28,26 @@ export default function SearchLayout() {
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name="collections/[collectionId]"
name='collections/[collectionId]'
options={{
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
<Stack.Screen name='jellyseerr/page' options={commonScreenOptions} />
<Stack.Screen
name="jellyseerr/person/[personId]"
name='jellyseerr/person/[personId]'
options={commonScreenOptions}
/>
<Stack.Screen
name="jellyseerr/company/[companyId]"
name='jellyseerr/company/[companyId]'
options={commonScreenOptions}
/>
<Stack.Screen
name="jellyseerr/genre/[genreId]"
name='jellyseerr/genre/[genreId]'
options={commonScreenOptions}
/>
</Stack>

View File

@@ -1,10 +1,13 @@
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Tag } from "@/components/GenreTags";
import { ItemCardText } from "@/components/ItemCardText";
import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import {
JellyseerrSearchSort,
JellyserrIndexPage,
} from "@/components/jellyseerr/JellyseerrIndexPage";
import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
@@ -12,26 +15,28 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
import { eventBus } from "@/utils/eventBus";
import type {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce";
import { useTranslation } from "react-i18next";
type SearchType = "Library" | "Discover";
@@ -48,6 +53,8 @@ export default function search() {
const params = useLocalSearchParams();
const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom);
const { t } = useTranslation();
const { q } = params as { q: string };
@@ -58,17 +65,27 @@ export default function search() {
const [debouncedSearch] = useDebounce(search, 500);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [settings] = useSettings();
const { jellyseerrApi } = useJellyseerr();
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
useState<JellyseerrSearchSort>(
JellyseerrSearchSort[
JellyseerrSearchSort.DEFAULT
] as unknown as JellyseerrSearchSort,
);
const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<
"asc" | "desc"
>("desc");
const searchEngine = useMemo(() => {
return settings?.searchEngine || "Jellyfin";
}, [settings]);
useEffect(() => {
if (q && q.length > 0) setSearch(q);
if (q && q.length > 0) {
setSearch(q);
}
}, [q]);
const searchFn = useCallback(
@@ -79,62 +96,94 @@ export default function search() {
types: BaseItemKind[];
query: string;
}): Promise<BaseItemDto[]> => {
if (!api || !query) return [];
if (!api || !query) {
return [];
}
try {
if (searchEngine === "Jellyfin") {
const searchApi = await getSearchApi(api).getSearchHints({
const searchApi = await getItemsApi(api).getItems({
searchTerm: query,
limit: 10,
includeItemTypes: types,
recursive: true,
userId: user?.Id,
});
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
} else {
if (!settings?.marlinServerUrl) return [];
const url = `${
settings.marlinServerUrl
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
.map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`;
const response1 = await axios.get(url);
const ids = response1.data.ids;
if (!ids || !ids.length) return [];
const response2 = await getItemsApi(api).getItems({
ids,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
return (response2.data.Items as BaseItemDto[]) || [];
return (searchApi.data.Items as BaseItemDto[]) || [];
}
if (!settings?.marlinServerUrl) {
return [];
}
const url = `${
settings.marlinServerUrl
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
.map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`;
const response1 = await axios.get(url);
const ids = response1.data.ids;
if (!ids || !ids.length) {
return [];
}
const response2 = await getItemsApi(api).getItems({
ids,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
return (response2.data.Items as BaseItemDto[]) || [];
} catch (error) {
console.error("Error during search:", error);
return []; // Ensure an empty array is returned in case of an error
}
},
[api, searchEngine, settings]
[api, searchEngine, settings],
);
type HeaderSearchBarRef = {
focus: () => void;
blur: () => void;
setText: (text: string) => void;
clearText: () => void;
cancelSearch: () => void;
};
const searchBarRef = useRef<HeaderSearchBarRef>(null);
const navigation = useNavigation();
useLayoutEffect(() => {
navigation.setOptions({
headerSearchBarOptions: {
ref: searchBarRef,
placeholder: t("search.search"),
onChangeText: (e: any) => {
router.setParams({ q: "" });
setSearch(e.nativeEvent.text);
},
hideWhenScrolling: false,
autoFocus: true,
autoFocus: false,
},
});
}, [navigation]);
useEffect(() => {
const unsubscribe = eventBus.on("searchTabPressed", () => {
// Screen not active
if (!searchBarRef.current) {
return;
}
// Screen is active, focus search bar
searchBarRef.current?.focus();
});
return () => {
unsubscribe();
};
}, []);
const { data: movies, isFetching: l1 } = useQuery({
queryKey: ["search", "movies", debouncedSearch],
queryFn: () =>
@@ -202,20 +251,28 @@ export default function search() {
return (
<>
<ScrollView
keyboardDismissMode="on-drag"
contentInsetAdjustmentBehavior="automatic"
keyboardDismissMode='on-drag'
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View className="flex flex-col">
<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">
<ScrollView
horizontal
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
>
<TouchableOpacity onPress={() => setSearchType("Library")}>
<Tag
text={t("search.library")}
textClass="p-1"
textClass='p-1'
className={
searchType === "Library" ? "bg-purple-600" : undefined
}
@@ -224,16 +281,49 @@ export default function search() {
<TouchableOpacity onPress={() => setSearchType("Discover")}>
<Tag
text={t("search.discover")}
textClass="p-1"
textClass='p-1'
className={
searchType === "Discover" ? "bg-purple-600" : undefined
}
/>
</TouchableOpacity>
</View>
{searchType === "Discover" &&
!loading &&
noResults &&
debouncedSearch.length > 0 && (
<View className='flex flex-row justify-end items-center space-x-1'>
<FilterButton
id='search'
queryKey='jellyseerr_search'
queryFn={async () =>
Object.keys(JellyseerrSearchSort).filter((v) =>
Number.isNaN(Number(v)),
)
}
set={(value) => setJellyseerrOrderBy(value[0])}
values={[jellyseerrOrderBy]}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
}
showSearch={false}
/>
<FilterButton
id='order'
queryKey='jellysearr_search'
queryFn={async () => ["asc", "desc"]}
set={(value) => setJellyseerrSortOrder(value[0])}
values={[jellyseerrSortOrder]}
title={t("library.filters.sort_order")}
renderItemLabel={(item) => t(`library.filters.${item}`)}
showSearch={false}
/>
</View>
)}
</ScrollView>
)}
<View className="mt-2">
<View className='mt-2'>
<LoadingSkeleton isLoading={loading} />
</View>
@@ -245,14 +335,14 @@ export default function search() {
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
className="flex flex-col w-28 mr-2"
className='flex flex-col w-28 mr-2'
item={item}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
<Text className='opacity-50 text-xs'>
{item.ProductionYear}
</Text>
</TouchableItemRouter>
@@ -265,13 +355,13 @@ export default function search() {
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28 mr-2"
className='flex flex-col w-28 mr-2'
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
<Text className='opacity-50 text-xs'>
{item.ProductionYear}
</Text>
</TouchableItemRouter>
@@ -284,7 +374,7 @@ export default function search() {
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-44 mr-2"
className='flex flex-col w-44 mr-2'
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
@@ -298,10 +388,10 @@ export default function search() {
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28 mr-2"
className='flex flex-col w-28 mr-2'
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
</TouchableItemRouter>
@@ -314,7 +404,7 @@ export default function search() {
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
className='flex flex-col w-28 mr-2'
>
<MoviePoster item={item} />
<ItemCardText item={item} />
@@ -323,35 +413,36 @@ export default function search() {
/>
</View>
) : (
<JellyserrIndexPage searchQuery={debouncedSearch} />
<JellyserrIndexPage
searchQuery={debouncedSearch}
sortType={jellyseerrOrderBy}
order={jellyseerrSortOrder}
/>
)}
{searchType === "Library" && (
<>
{!loading && noResults && debouncedSearch.length > 0 ? (
<View>
<Text className="text-center text-lg font-bold mt-4">
{t("search.no_results_found_for")}
</Text>
<Text className="text-xs text-purple-600 text-center">
"{debouncedSearch}"
</Text>
</View>
) : debouncedSearch.length === 0 ? (
<View className="mt-4 flex flex-col items-center space-y-2">
{exampleSearches.map((e) => (
<TouchableOpacity
onPress={() => setSearch(e)}
key={e}
className="mb-2"
>
<Text className="text-purple-600">{e}</Text>
</TouchableOpacity>
))}
</View>
) : null}
</>
)}
{searchType === "Library" &&
(!loading && noResults && debouncedSearch.length > 0 ? (
<View>
<Text className='text-center text-lg font-bold mt-4'>
{t("search.no_results_found_for")}
</Text>
<Text className='text-xs text-purple-600 text-center'>
"{debouncedSearch}"
</Text>
</View>
) : debouncedSearch.length === 0 ? (
<View className='mt-4 flex flex-col items-center space-y-2'>
{exampleSearches.map((e) => (
<TouchableOpacity
onPress={() => setSearch(e)}
key={e}
className='mb-2'
>
<Text className='text-purple-600'>{e}</Text>
</TouchableOpacity>
))}
</View>
) : null)}
</View>
</ScrollView>
</>

View File

@@ -1,20 +1,20 @@
import React, { useCallback, useRef } from "react";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
import {
type NativeBottomTabNavigationEventMap,
createNativeBottomTabNavigator,
NativeBottomTabNavigationEventMap,
} from "@bottom-tabs/react-navigation";
const { Navigator } = createNativeBottomTabNavigator();
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import type { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import { Colors } from "@/constants/Colors";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
import { storage } from "@/utils/mmkv";
import type {
ParamListBase,
@@ -46,26 +46,33 @@ export default function TabLayout() {
clearTimeout(timer);
};
}
}, [])
}, []),
);
return (
<>
<SystemBars hidden={false} style="light" />
<SystemBars hidden={false} style='light' />
<NativeTabs
sidebarAdaptable={false}
ignoresTopSafeArea
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
tabBarStyle={{
backgroundColor: "#121212",
}}
tabBarActiveTintColor={Colors.primary}
scrollEdgeAppearance="default"
scrollEdgeAppearance='default'
>
<NativeTabs.Screen redirect name="index" />
<NativeTabs.Screen redirect name='index' />
<NativeTabs.Screen
name="(home)"
listeners={({ navigation }) => ({
tabPress: (e) => {
eventBus.emit("scrollToTop");
},
})}
name='(home)'
options={{
title: t("tabs.home"),
tabBarIcon:
Platform.OS == "android"
Platform.OS === "android"
? ({ color, focused, size }) =>
require("@/assets/icons/house.fill.png")
: ({ focused }) =>
@@ -75,11 +82,16 @@ export default function TabLayout() {
}}
/>
<NativeTabs.Screen
name="(search)"
listeners={({ navigation }) => ({
tabPress: (e) => {
eventBus.emit("searchTabPressed");
},
})}
name='(search)'
options={{
title: t("tabs.search"),
tabBarIcon:
Platform.OS == "android"
Platform.OS === "android"
? ({ color, focused, size }) =>
require("@/assets/icons/magnifyingglass.png")
: ({ focused }) =>
@@ -89,11 +101,11 @@ export default function TabLayout() {
}}
/>
<NativeTabs.Screen
name="(favorites)"
name='(favorites)'
options={{
title: t("tabs.favorites"),
tabBarIcon:
Platform.OS == "android"
Platform.OS === "android"
? ({ color, focused, size }) =>
focused
? require("@/assets/icons/heart.fill.png")
@@ -105,11 +117,11 @@ export default function TabLayout() {
}}
/>
<NativeTabs.Screen
name="(libraries)"
name='(libraries)'
options={{
title: t("tabs.library"),
tabBarIcon:
Platform.OS == "android"
Platform.OS === "android"
? ({ color, focused, size }) =>
require("@/assets/icons/server.rack.png")
: ({ focused }) =>
@@ -119,13 +131,13 @@ export default function TabLayout() {
}}
/>
<NativeTabs.Screen
name="(custom-links)"
name='(custom-links)'
options={{
title: t("tabs.custom_links"),
// @ts-expect-error
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
tabBarItemHidden: !settings?.showCustomMenuLinks,
tabBarIcon:
Platform.OS == "android"
Platform.OS === "android"
? ({ focused }) => require("@/assets/icons/list.png")
: ({ focused }) =>
focused

View File

@@ -1,43 +1,39 @@
import { Stack } from "expo-router";
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";
import { Stack } from "expo-router";
import React, { useLayoutEffect } from "react";
import { Platform } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
export default function Layout() {
const [settings] = useSettings();
useEffect(() => {
if (settings.defaultVideoOrientation) {
useLayoutEffect(() => {
if (Platform.isTV) return;
if (!settings.followDeviceOrientation && settings.defaultVideoOrientation) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
}
return () => {
if (settings.autoRotate === true) {
if (Platform.isTV) return;
if (settings.followDeviceOrientation === true) {
ScreenOrientation.unlockAsync();
} else {
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
}
};
}, [settings]);
});
return (
<>
<SystemBars hidden />
<Stack>
<Stack.Screen
name="direct-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="transcoding-player"
name='direct-player'
options={{
headerShown: false,
autoHideHomeIndicator: true,

View File

@@ -1,59 +1,66 @@
import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useHaptic } from "@/hooks/useHaptic";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules/vlc-player";
import {
import { MpvPlayerView, ProgressUpdatePayload, VlcPlayerView } from "@/modules";
// import type {
// PipStartedPayload,
// PlaybackStatePayload,
// ProgressUpdatePayload,
// VlcPlayerViewRef,
// } from "@/modules/VlcPlayer.types";
import type {
MpvPlayerViewRef,
PipStartedPayload,
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types";
// import { useDownload } from "@/providers/DownloadProvider";
const downloadProvider = !Platform.isTV
? require("@/providers/DownloadProvider")
: null;
} from "@/modules/MpvPlayer.types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log";
import native from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
import {
type BaseItemDto,
type MediaSourceInfo,
PlaybackOrder,
type PlaybackProgressInfo,
PlaybackStartInfo,
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useHaptic } from "@/hooks/useHaptic";
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useEffect,
} from "react";
import {
Alert,
View,
AppState,
AppStateStatus,
Platform,
} from "react-native";
import { useSharedValue } from "react-native-reanimated";
import { useSettings } from "@/utils/atoms/settings";
import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const downloadProvider = !Platform.isTV
? require("@/providers/DownloadProvider")
: null;
export default function page() {
console.log("Direct Player");
const videoRef = useRef<VlcPlayerViewRef>(null);
const videoRef = useRef<MpvPlayerViewRef>(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);
@@ -96,131 +103,140 @@ export default function page() {
offline: string;
}>();
const [settings] = useSettings();
const insets = useSafeAreaInsets();
const offline = offlineStr === "true";
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
const audioIndex = audioIndexStr
? Number.parseInt(audioIndexStr, 10)
: undefined;
const subtitleIndex = subtitleIndexStr
? Number.parseInt(subtitleIndexStr, 10)
: -1;
const bitrateValue = bitrateValueStr
? parseInt(bitrateValueStr, 10)
? Number.parseInt(bitrateValueStr, 10)
: BITRATES[0].value;
const {
data: item,
isLoading: isLoadingItem,
isError: isErrorItem,
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
if (offline && !Platform.isTV) {
const item = await getDownloadedItem.getDownloadedItem(itemId);
if (item) return item.item;
}
const res = await getUserLibraryApi(api!).getItem({
itemId,
userId: user?.Id,
});
return res.data;
},
enabled: !!itemId,
staleTime: 0,
const [item, setItem] = useState<BaseItemDto | null>(null);
const [itemStatus, setItemStatus] = useState({
isLoading: true,
isError: false,
});
const {
data: stream,
isLoading: isLoadingStreamUrl,
isError: isErrorStreamUrl,
} = useQuery({
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
queryFn: async () => {
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (!data?.mediaSource) return null;
const url = await getDownloadedFileUrl(data.item.Id!);
if (item)
return {
mediaSource: data.mediaSource,
url,
sessionId: undefined,
};
useEffect(() => {
const fetchItemData = async () => {
setItemStatus({ isLoading: true, isError: false });
try {
let fetchedItem: BaseItemDto | null = null;
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (data) fetchedItem = data.item as BaseItemDto;
} else {
const res = await getUserLibraryApi(api!).getItem({
itemId,
userId: user?.Id,
});
fetchedItem = res.data;
}
setItem(fetchedItem);
} catch (error) {
console.error("Failed to fetch item:", error);
setItemStatus({ isLoading: false, isError: true });
} finally {
setItemStatus({ isLoading: false, isError: 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 (itemId) {
fetchItemData();
}
}, [itemId, offline, api, user?.Id]);
if (!res) return null;
interface Stream {
mediaSource: MediaSourceInfo;
sessionId: string;
url: string;
}
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,
const [stream, setStream] = useState<Stream | null>(null);
const [streamStatus, setStreamStatus] = useState({
isLoading: true,
isError: false,
});
const togglePlay = useCallback(async () => {
if (!api) return;
useEffect(() => {
const fetchStreamData = async () => {
try {
let result: Stream | null = null;
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (!data?.mediaSource) return;
const url = await getDownloadedFileUrl(data.item.Id!);
if (item) {
result = { mediaSource: data.mediaSource, sessionId: "", url };
}
} else {
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: native,
});
if (!res) return;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(
t("player.error"),
t("player.failed_to_get_stream_url"),
);
return;
}
result = { mediaSource, sessionId, url };
}
setStream(result);
} catch (error) {
console.error("Failed to fetch stream:", error);
setStreamStatus({ isLoading: false, isError: true });
} finally {
setStreamStatus({ isLoading: false, isError: false });
}
};
fetchStreamData();
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
useEffect(() => {
if (!stream) return;
const reportPlaybackStart = async () => {
await getPlaystateApi(api!).reportPlaybackStart({
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
});
};
reportPlaybackStart();
}, [stream]);
const togglePlay = async () => {
lightHapticFeedback();
setIsPlaying(!isPlaying);
if (isPlaying) {
await videoRef.current?.pause();
reportPlaybackStopped();
} 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.get()),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
await getPlaystateApi(api!).reportPlaybackStart({
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
});
}
}, [
isPlaying,
api,
item,
stream,
videoRef,
audioIndex,
subtitleIndex,
mediaSourceId,
offline,
progress,
]);
};
const reportPlaybackStopped = useCallback(async () => {
if (offline) return;
const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!,
mediaSourceId: mediaSourceId,
@@ -237,27 +253,36 @@ export default function page() {
videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]);
// TODO: unused should remove.
const reportPlaybackStart = useCallback(async () => {
if (offline) return;
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => {
beforeRemoveListener();
};
}, [navigation, stop]);
const currentPlayStateInfo = () => {
if (!stream) return;
await getPlaystateApi(api!).onPlaybackStart({
return {
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]);
positionTicks: msToTicks(progress.get()),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
isMuted: false,
canSeek: true,
repeatMode: RepeatMode.RepeatNone,
playbackOrder: PlaybackOrder.Default,
};
};
const onProgress = useCallback(
async (data: ProgressUpdatePayload) => {
if (isSeeking.get() || isPlaybackStopped) return;
const { currentTime } = data.nativeEvent;
if (isBuffering) {
setIsBuffering(false);
}
@@ -266,24 +291,52 @@ export default function page() {
if (offline) return;
const currentTimeInTicks = msToTicks(currentTime);
if (!item?.Id || !stream) return;
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(currentTimeInTicks),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
});
reportPlaybackProgress();
},
[item?.Id, isSeeking, api, isPlaybackStopped, audioIndex, subtitleIndex]
[
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
isPlaying,
stream,
isSeeking,
isPlaybackStopped,
isBuffering,
],
);
const onPipStarted = useCallback((e: PipStartedPayload) => {
const { pipStarted } = e.nativeEvent;
setIsPipStarted(pipStarted);
}, []);
const reportPlaybackProgress = useCallback(async () => {
if (!api || offline || !stream) return;
await getPlaystateApi(api).reportPlaybackProgress({
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
});
}, [
api,
isPlaying,
offline,
stream,
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
progress,
]);
const startPosition = useMemo(() => {
if (offline) return 0;
return item?.UserData?.PlaybackPositionTicks
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
: 0;
}, [item]);
useWebSocket({
isPlaying: isPlaying,
togglePlay: togglePlay,
@@ -291,122 +344,97 @@ export default function page() {
offline,
});
const onPipStarted = useCallback((e: PipStartedPayload) => {
const { pipStarted } = e.nativeEvent;
setIsPipStarted(pipStarted)
}, [])
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") {
setIsPlaying(true);
return;
}
if (state === "Paused") {
setIsPlaying(false);
return;
}
if (isPlaying) {
setIsPlaying(true);
setIsBuffering(false);
} else if (isBuffering) {
setIsBuffering(true);
}
}, []);
const startPosition = useMemo(() => {
if (offline) return 0;
return item?.UserData?.PlaybackPositionTicks
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
: 0;
}, [item]);
const [appState, setAppState] = useState(AppState.currentState);
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
// Handle app going to the background
if (nextAppState.match(/inactive|background/)) {
_setShowControls(false)
const onPlaybackStateChanged = useCallback(
async (e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") {
setIsPlaying(true);
reportPlaybackProgress();
if (!Platform.isTV) await activateKeepAwakeAsync();
return;
}
setAppState(nextAppState);
};
// Use AppState.addEventListener and return a cleanup function
const subscription = AppState.addEventListener(
"change",
handleAppStateChange
);
if (state === "Paused") {
setIsPlaying(false);
reportPlaybackProgress();
if (!Platform.isTV) await deactivateKeepAwake();
return;
}
return () => {
// Cleanup the event listener when the component is unmounted
subscription.remove();
};
}, [appState, isPipStarted, isPlaying]);
// 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: { Type: string }) => sub.Type === "Subtitle"
) || [];
const chosenSubtitleTrack = allSubs.find(
(sub: { Index: number }) => sub.Index === subtitleIndex
if (isPlaying) {
setIsPlaying(true);
setIsBuffering(false);
} else if (isBuffering) {
setIsBuffering(true);
}
},
[reportPlaybackProgress],
);
const allAudio =
stream?.mediaSource.MediaStreams?.filter(
(audio: { Type: string }) => audio.Type === "Audio"
(audio) => audio.Type === "Audio",
) || [];
const chosenAudioTrack = allAudio.find(
(audio: { Index: number | undefined }) => audio.Index === audioIndex
// Move all the external subtitles last, because vlc places them last.
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle",
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
}));
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex,
);
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
// 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}`,
};
}
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
const initOptions = [
`--sub-text-scale=${settings.subtitleSize}`,
`--start=${startPosition}`,
];
// if (
// chosenSubtitleTrack &&
// (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
// ) {
// const finalIndex = notTranscoding
// ? allSubs.indexOf(chosenSubtitleTrack)
// : textSubs.indexOf(chosenSubtitleTrack);
// initOptions.push(`--sub-track=${finalIndex}`);
// }
if (chosenAudioTrack)
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
} else {
// Transcoded playback CASE
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
externalTrack = {
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
DeliveryUrl: "",
};
}
}
// if (notTranscoding && chosenAudioTrack) {
// initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
// }
const insets = useSafeAreaInsets();
const [isMounted, setIsMounted] = useState(false);
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
// Add useEffect to handle mounting
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []);
if (itemStatus.isLoading || streamStatus.isLoading) {
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
<Loader />
</View>
);
}
if (isErrorItem || isErrorStreamUrl)
if (!item || !stream || itemStatus.isError || streamStatus.isError)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">{t("player.error")}</Text>
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
<Text className='text-white'>{t("player.error")}</Text>
</View>
);
@@ -424,14 +452,14 @@ export default function page() {
paddingRight: ignoreSafeAreas ? 0 : insets.right,
}}
>
<VlcPlayerView
<MpvPlayerView
ref={videoRef}
source={{
uri: stream.url,
uri: stream?.url || "",
autoplay: true,
isNetwork: true,
startPosition,
externalTrack,
externalSubtitles,
initOptions,
}}
style={{ width: "100%", height: "100%" }}
@@ -439,7 +467,6 @@ export default function page() {
progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged}
onPipStarted={onPipStarted}
onVideoLoadStart={() => {}}
onVideoLoadEnd={() => {
setIsVideoLoaded(true);
}}
@@ -447,13 +474,13 @@ export default function page() {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
t("player.error"),
t("player.an_error_occured_while_playing_the_video")
t("player.an_error_occured_while_playing_the_video"),
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
/>
</View>
{videoRef.current && (
{videoRef.current && !isPipStarted && isMounted === true ? (
<Controls
mediaSource={stream?.mediaSource}
item={item}
@@ -469,7 +496,6 @@ 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}
@@ -480,10 +506,9 @@ export default function page() {
setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack}
stop={stop}
isVlc
/>
)}
) : null}
</View>
);
}
}

View File

@@ -1,546 +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 = () => {
console.log("Transcoding 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,
]
);
useWebSocket({
isPlaying: isPlaying,
togglePlay: togglePlay,
stopPlayback: stop,
offline: false,
});
const [selectedTextTrack, setSelectedTextTrack] = useState<
SelectedTrack | undefined
>();
const [embededTextTracks, setEmbededTextTracks] = useState<
{
index: number;
language?: string | undefined;
selected?: boolean | undefined;
title?: string | undefined;
type: any;
}[]
>([]);
const [audioTracks, setAudioTracks] = useState<TrackInfo[]>([]);
const [selectedAudioTrack, setSelectedAudioTrack] = useState<
SelectedTrack | undefined
>(undefined);
useEffect(() => {
if (selectedTextTrack === undefined) {
const subtitleHelper = new SubtitleHelper(
stream?.mediaSource.MediaStreams ?? []
);
const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex(
subtitleIndex!
);
// Most likely the subtitle is burned in.
if (embeddedTrackIndex === -1) return;
setSelectedTextTrack({
type: SelectedTrackType.INDEX,
value: embeddedTrackIndex,
});
}
}, [embededTextTracks]);
const getAudioTracks = (): TrackInfo[] => {
return audioTracks.map((t) => ({
name: t.name,
index: t.index,
}));
};
const getSubtitleTracks = (): TrackInfo[] => {
return embededTextTracks.map((t) => ({
name: t.title ?? "",
index: t.index,
language: t.language,
}));
};
useFocusEffect(
React.useCallback(() => {
return async () => {
stop();
};
}, [])
);
if (isLoadingItem || isLoadingStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
if (isErrorItem || isErrorStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">{t("player.error")}</Text>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: "black" }}>
<View
style={{
display: "flex",
width: "100%",
height: "100%",
position: "relative",
flexDirection: "column",
justifyContent: "center",
}}
>
{videoSource ? (
<>
<Video
ref={videoRef}
source={videoSource}
style={{
height: "100%",
width: "100%",
}}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={(e) => {
console.error("Error playing video", e);
}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
progressUpdateInterval={500}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
}}
onTextTracks={(data) => {
setEmbededTextTracks(data.textTracks as any);
}}
onBuffer={(e) => {
setIsBuffering(e.isBuffering);
}}
onAudioTracks={(e) => {
setAudioTracks(
e.audioTracks.map((t) => ({
index: t.index,
name: t.title ?? "",
language: t.language,
}))
);
}}
selectedTextTrack={selectedTextTrack}
selectedAudioTrack={selectedAudioTrack}
/>
</>
) : (
<Text>{t("player.no_video_source")}</Text>
)}
</View>
{item && (
<Controls
mediaSource={stream?.mediaSource}
videoRef={videoRef}
enableTrickplay={true}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
seek={seek}
play={play}
pause={pause}
stop={stop}
getSubtitleTracks={getSubtitleTracks}
setSubtitleTrack={(i) => {
if (i === -1) {
setSelectedTextTrack({
type: SelectedTrackType.DISABLED,
value: undefined,
});
return;
}
setSelectedTextTrack({
type: SelectedTrackType.INDEX,
value: i,
});
}}
getAudioTracks={getAudioTracks}
setAudioTrack={(i) => {
setSelectedAudioTrack({
type: SelectedTrackType.INDEX,
value: i,
});
}}
/>
)}
</View>
);
};
export function usePoster(
item: BaseItemDto | null | undefined,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!item || !api) return undefined;
return item.Type === "Audio"
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: item,
quality: 70,
width: 200,
});
}, [item, api]);
return poster ?? undefined;
}
export function useVideoSource(
item: BaseItemDto | null | undefined,
api: Api | null,
poster: string | undefined,
url?: string | null
) {
const videoSource = useMemo(() => {
if (!item || !api || !url) {
return null;
}
const startPosition = item?.UserData?.PlaybackPositionTicks
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
title: item?.Name || "Unknown",
description: item?.Overview ?? undefined,
imageUri: poster,
subtitle: item?.Album ?? undefined,
},
};
}, [item, api, poster, url]);
return videoSource;
}
export default Player;

View File

@@ -1,5 +1,5 @@
import { ScrollViewStyleReset } from "expo-router/html";
import { type PropsWithChildren } from "react";
import type { PropsWithChildren } from "react";
/**
* This file is web-only and used to configure the root HTML for every web page during static rendering.
@@ -7,13 +7,13 @@ import { type PropsWithChildren } from "react";
*/
export default function Root({ children }: PropsWithChildren) {
return (
<html lang="en">
<html lang='en'>
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta charSet='utf-8' />
<meta httpEquiv='X-UA-Compatible' content='IE=edge' />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
name='viewport'
content='width=device-width, initial-scale=1, shrink-to-fit=no'
/>
{/*

View File

@@ -9,9 +9,9 @@ export default function NotFoundScreen() {
<>
<Stack.Screen options={{ title: "Oops!" }} />
<ThemedView style={styles.container}>
<ThemedText type="title">This screen doesn't exist.</ThemedText>
<ThemedText type='title'>This screen doesn't exist.</ThemedText>
<Link href={"/home"} style={styles.link}>
<ThemedText type="link">Go to home screen!</ThemedText>
<ThemedText type='link'>Go to home screen!</ThemedText>
</Link>
</ThemedView>
</>

View File

@@ -1,28 +1,33 @@
import "@/augmentations";
import { Platform } from "react-native";
import { Text } from "@/components/common/Text";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
JellyfinProvider,
apiAtom,
getOrSetDeviceId,
getTokenFromStorage,
JellyfinProvider,
} from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import {
SplashScreenProvider,
useSplashScreenLoading,
} from "@/providers/SplashScreenProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { Settings, useSettings } from "@/utils/atoms/settings";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
import { LogProvider, writeToLog } from "@/utils/log";
import { type Settings, useSettings } from "@/utils/atoms/settings";
import {
BACKGROUND_FETCH_TASK,
BACKGROUND_FETCH_TASK_SESSIONS,
registerBackgroundFetchAsyncSessions,
} from "@/utils/background-tasks";
import {
LogProvider,
writeDebugLog,
writeErrorLog,
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 type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Platform } from "react-native";
const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
@@ -31,21 +36,31 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const BackgroundFetch = !Platform.isTV
? require("expo-background-fetch")
: null;
import * as Device from "expo-device";
import * as FileSystem from "expo-file-system";
import { useFonts } from "expo-font";
import { useKeepAwake } from "expo-keep-awake";
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import { router, Stack } from "expo-router";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { Stack, router, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
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 { I18nextProvider, useTranslation } from "react-i18next";
import { Appearance, AppState } from "react-native";
import { useEffect, useRef, useState } from "react";
import { I18nextProvider } from "react-i18next";
import { AppState, Appearance } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated";
import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import type { EventSubscription } from "expo-modules-core";
import type {
Notification,
NotificationResponse,
} from "expo-notifications/build/Notifications.types";
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
import { useAtom } from "jotai";
import { Toaster } from "sonner-native";
if (!Platform.isTV) {
@@ -58,6 +73,15 @@ if (!Platform.isTV) {
});
}
// Keep the splash screen visible while we fetch resources
SplashScreen.preventAutoHideAsync();
// Set the animation options. This is optional.
SplashScreen.setOptions({
duration: 500,
fade: true,
});
function useNotificationObserver() {
if (Platform.isTV) return;
@@ -77,13 +101,13 @@ function useNotificationObserver() {
return;
}
redirect(response?.notification);
}
},
);
const subscription = Notifications.addNotificationResponseReceivedListener(
(response: { notification: any }) => {
redirect(response.notification);
}
},
);
return () => {
@@ -94,6 +118,22 @@ function useNotificationObserver() {
}
if (!Platform.isTV) {
TaskManager.defineTask(BACKGROUND_FETCH_TASK_SESSIONS, async () => {
console.log("TaskManager ~ sessions trigger");
const api = store.get(apiAtom);
if (api === null || api === undefined) return;
const response = await getSessionApi(api).getSessions({
activeWithinSeconds: 360,
});
const result = response.data.filter((s) => s.NowPlayingItem);
Notifications.setBadgeCountAsync(result.length);
return BackgroundFetch.BackgroundFetchResult.NewData;
});
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger");
@@ -124,9 +164,9 @@ if (!Platform.isTV) {
console.log("TaskManager ~ Active jobs: ", jobs.length);
for (let job of jobs) {
for (const job of jobs) {
if (job.status === "completed") {
const downloadUrl = url + "download/" + job.id;
const downloadUrl = `${url}download/${job.id}`;
const tasks = await BackGroundDownloader.checkForExistingDownloads();
if (tasks.find((task: { id: string }) => task.id === job.id)) {
@@ -159,7 +199,7 @@ if (!Platform.isTV) {
title: job.item.Name,
body: "Download completed",
data: {
url: `/downloads`,
url: "/downloads",
},
},
trigger: null,
@@ -173,7 +213,7 @@ if (!Platform.isTV) {
title: job.item.Name,
body: "Download failed",
data: {
url: `/downloads`,
url: "/downloads",
},
},
trigger: null,
@@ -192,7 +232,7 @@ if (!Platform.isTV) {
const checkAndRequestPermissions = async () => {
try {
const hasAskedBefore = storage.getString(
"hasAskedForNotificationPermission"
"hasAskedForNotificationPermission",
);
if (hasAskedBefore !== "true") {
@@ -214,7 +254,7 @@ const checkAndRequestPermissions = async () => {
writeToLog(
"ERROR",
"Error checking/requesting notification permissions:",
error
error,
);
console.error("Error checking/requesting notification permissions:", error);
}
@@ -224,17 +264,15 @@ export default function RootLayout() {
Appearance.setColorScheme("dark");
return (
<SplashScreenProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<JotaiProvider>
<ActionSheetProvider>
<I18nextProvider i18n={i18n}>
<Layout />
</I18nextProvider>
</ActionSheetProvider>
</JotaiProvider>
</GestureHandlerRootView>
</SplashScreenProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<JotaiProvider>
<ActionSheetProvider>
<I18nextProvider i18n={i18n}>
<Layout />
</I18nextProvider>
</ActionSheetProvider>
</JotaiProvider>
</GestureHandlerRootView>
);
}
@@ -252,35 +290,145 @@ const queryClient = new QueryClient({
function Layout() {
const [settings] = useSettings();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const appState = useRef(AppState.currentState);
const segments = useSegments();
useEffect(() => {
i18n.changeLanguage(
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en"
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en",
);
}, [settings?.preferedLanguage, i18n]);
if (!Platform.isTV) {
useKeepAwake();
useNotificationObserver();
const { i18n } = useTranslation();
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>();
const notificationListener = useRef<EventSubscription>();
const responseListener = useRef<EventSubscription>();
useEffect(() => {
checkAndRequestPermissions();
if (expoPushToken && api && user) {
api
?.post("/Streamyfin/device", {
token: expoPushToken.data,
deviceId: getOrSetDeviceId(),
userId: user.Id,
})
.then((_) => console.log("Posted expo push token"))
.catch((_) =>
writeErrorLog("Failed to push expo push token to plugin"),
);
} else console.log("No token available");
}, [api, expoPushToken, user]);
async function registerNotifications() {
if (Platform.OS === "android") {
console.log("Setting android notification channel 'default'");
await Notifications?.setNotificationChannelAsync("default", {
name: "default",
});
}
await checkAndRequestPermissions();
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
await registerBackgroundFetchAsyncSessions();
}
// only create push token for real devices (pointless for emulators)
if (Device.isDevice) {
Notifications?.getExpoPushTokenAsync()
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
.catch((reason: any) => console.log("Failed to get token", reason));
}
}
useEffect(() => {
registerNotifications();
notificationListener.current =
Notifications?.addNotificationReceivedListener(
(notification: Notification) => {
console.log(
"Notification received while app running",
notification,
);
},
);
responseListener.current =
Notifications?.addNotificationResponseReceivedListener(
(response: NotificationResponse) => {
// Currently the notifications supported by the plugin will send data for deep links.
const { title, data } = response.notification.request.content;
writeDebugLog(
`Notification ${title} opened`,
response.notification.request.content,
);
if (data && Object.keys(data).length > 0) {
const type = data?.type?.toLower?.();
const itemId = data?.id;
switch (type) {
case "movie":
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
break;
case "episode":
// We just clicked a notification for an individual episode.
if (itemId) {
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
}
// summarized season notification for multiple episodes. Bring them to series season
else {
const seriesId = data.seriesId;
const seasonIndex = data.seasonIndex;
if (seasonIndex) {
router.push(
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`,
);
} else {
router.push(`/(auth)/(tabs)/home/series/${seriesId}`);
}
}
break;
}
}
},
);
return () => {
notificationListener.current &&
Notifications?.removeNotificationSubscription(
notificationListener.current,
);
responseListener.current &&
Notifications?.removeNotificationSubscription(
responseListener.current,
);
};
}, []);
useEffect(() => {
if (Platform.isTV) return;
if (segments.includes("direct-player" as never)) {
return;
}
// If the user has auto rotate enabled, unlock the orientation
if (settings.autoRotate === true) {
if (settings.followDeviceOrientation === true) {
ScreenOrientation.unlockAsync();
} else {
// If the user has auto rotate disabled, lock the orientation to portrait
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
}
}, [settings]);
}, [settings.followDeviceOrientation, segments]);
useEffect(() => {
const subscription = AppState.addEventListener(
@@ -292,7 +440,7 @@ function Layout() {
) {
BackGroundDownloader.checkForExistingDownloads();
}
}
},
);
BackGroundDownloader.checkForExistingDownloads();
@@ -303,16 +451,6 @@ function Layout() {
}, []);
}
const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
useSplashScreenLoading(!loaded);
if (!loaded) {
return null;
}
return (
<QueryClientProvider client={queryClient}>
<JobQueueProvider>
@@ -322,11 +460,11 @@ function Layout() {
<WebSocketProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false} />
<SystemBars style='light' hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack>
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name="(auth)/(tabs)"
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
@@ -334,7 +472,7 @@ function Layout() {
}}
/>
<Stack.Screen
name="(auth)/player"
name='(auth)/player'
options={{
headerShown: false,
title: "",
@@ -342,14 +480,14 @@ function Layout() {
}}
/>
<Stack.Screen
name="login"
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name="+not-found" />
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
@@ -380,7 +518,7 @@ function Layout() {
function saveDownloadedItemInfo(item: BaseItemDto) {
try {
const downloadedItems = storage.getString("downloadedItems");
let items: BaseItemDto[] = downloadedItems
const items: BaseItemDto[] = downloadedItems
? JSON.parse(downloadedItems)
: [];

View File

@@ -1,16 +1,17 @@
import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useState } from "react";
import { useAtomValue } from "jotai";
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
@@ -19,17 +20,20 @@ import {
TouchableOpacity,
View,
} from "react-native";
import { Keyboard } from "react-native";
import { t } from "i18next";
import { z } from "zod";
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 +41,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,12 +53,13 @@ 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({
await setServer({
address: _apiUrl,
});
@@ -66,7 +73,6 @@ const CredentialsSchema = z.object({
})();
}, [_apiUrl, _username, _password]);
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerTitle: serverName,
@@ -76,18 +82,20 @@ const CredentialsSchema = z.object({
onPress={() => {
removeServer();
}}
className="flex flex-row items-center"
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>
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
<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 +106,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.
*
@@ -168,26 +177,33 @@ const CredentialsSchema = z.object({
if (result === undefined) {
Alert.alert(
t("login.connection_failed"),
t("login.could_not_connect_to_server")
t("login.could_not_connect_to_server"),
);
return;
}
setServer({ address: url });
await setServer({ address: url });
}, []);
const handleQuickConnect = async () => {
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"),
);
}
};
@@ -198,20 +214,20 @@ const CredentialsSchema = z.object({
>
{api?.basePath ? (
<>
<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">
<>
<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_to_title")} `}
<Text className='text-purple-600'>{serverName}</Text>
</>
) : t("login.login_title")}
</>
</Text>
<Text className="text-xs text-neutral-400">
) : (
t("login.login_title")
)}
</Text>
<Text className='text-xs text-neutral-400'>
{api.basePath}
</Text>
<Input
@@ -220,13 +236,13 @@ const CredentialsSchema = z.object({
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
autoFocus
secureTextEntry={false}
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="username"
clearButtonMode="while-editing"
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
// Changed from username to oneTimeCode because it is a known issue in RN
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
textContentType='oneTimeCode'
clearButtonMode='while-editing'
maxLength={500}
/>
@@ -237,42 +253,42 @@ const CredentialsSchema = z.object({
}
value={credentials.password}
secureTextEntry
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="password"
clearButtonMode="while-editing"
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
clearButtonMode='while-editing'
maxLength={500}
/>
<View className="flex flex-row items-center justify-between">
<View className='flex flex-row items-center justify-between'>
<Button
onPress={handleLogin}
loading={loading}
className="flex-1 mr-2"
className='flex-1 mr-2'
>
{t("login.login_button")}
</Button>
<TouchableOpacity
onPress={handleQuickConnect}
className="p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center"
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
>
<MaterialCommunityIcons
name="cellphone-lock"
name='cellphone-lock'
size={24}
color="white"
color='white'
/>
</TouchableOpacity>
</View>
</View>
</View>
<View className="absolute bottom-0 left-0 w-full px-4 mb-2"></View>
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
</View>
</>
) : (
<>
<View className="flex flex-col h-full items-center justify-center w-full">
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
<View className='flex flex-col h-full items-center justify-center w-full'>
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
<Image
style={{
width: 100,
@@ -282,41 +298,43 @@ const CredentialsSchema = z.object({
}}
source={require("@/assets/images/StreamyFinFinal.png")}
/>
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="text-neutral-500">
<Text className='text-3xl font-bold'>Streamyfin</Text>
<Text className='text-neutral-500'>
{t("server.enter_url_to_jellyfin_server")}
</Text>
<Input
aria-label="Server URL"
aria-label='Server URL'
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
maxLength={500}
/>
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)}
className="w-full grow"
onPress={async () => {
await handleConnect(serverURL);
}}
className='w-full grow'
>
{t("server.connect_button")}
</Button>
<JellyfinServerDiscovery
onServerSelect={(server) => {
onServerSelect={async (server) => {
setServerURL(server.address);
if (server.serverName) {
setServerName(server.serverName);
}
handleConnect(server.address);
await handleConnect(server.address);
}}
/>
<PreviousServersList
onServerSelect={(s) => {
handleConnect(s.address);
onServerSelect={async (s) => {
await handleConnect(s.address);
}}
/>
</View>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,17 +1,21 @@
import { Api, AUTHORIZATION_HEADER } from "@jellyfin/sdk";
import { AxiosRequestConfig, AxiosResponse } from "axios";
import { StreamyfinPluginConfig } from "@/utils/atoms/settings";
import type { StreamyfinPluginConfig } from "@/utils/atoms/settings";
import { AUTHORIZATION_HEADER, Api } from "@jellyfin/sdk";
import type { AxiosRequestConfig, AxiosResponse } from "axios";
declare module "@jellyfin/sdk" {
interface Api {
get<T, D = any>(
url: string,
config?: AxiosRequestConfig<D>
config?: AxiosRequestConfig<D>,
): Promise<AxiosResponse<T>>;
post<T, D = any>(
url: string,
data: D,
config?: AxiosRequestConfig<D>
config?: AxiosRequestConfig<D>,
): Promise<AxiosResponse<T>>;
delete<T, D = any>(
url: string,
config?: AxiosRequestConfig<D>,
): Promise<AxiosResponse<T>>;
getStreamyfinPluginConfig(): Promise<AxiosResponse<StreamyfinPluginConfig>>;
}
@@ -19,7 +23,7 @@ declare module "@jellyfin/sdk" {
Api.prototype.get = function <T, D = any>(
url: string,
config: AxiosRequestConfig<D> = {}
config: AxiosRequestConfig<D> = {},
): Promise<AxiosResponse<T>> {
return this.axiosInstance.get<T>(`${this.basePath}${url}`, {
...(config ?? {}),
@@ -30,11 +34,20 @@ Api.prototype.get = function <T, D = any>(
Api.prototype.post = function <T, D = any>(
url: string,
data: D,
config: AxiosRequestConfig<D>
config: AxiosRequestConfig<D>,
): Promise<AxiosResponse<T>> {
return this.axiosInstance.post<T>(`${this.basePath}${url}`, {
return this.axiosInstance.post<T>(`${this.basePath}${url}`, data, {
...(config || {}),
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
});
};
Api.prototype.delete = function <T, D = any>(
url: string,
config: AxiosRequestConfig<D>,
): Promise<AxiosResponse<T>> {
return this.axiosInstance.delete<T>(`${this.basePath}${url}`, {
...(config || {}),
data,
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
});
};

View File

@@ -1,22 +1,21 @@
import {MMKV} from "react-native-mmkv";
import { MMKV } from "react-native-mmkv";
declare module "react-native-mmkv" {
interface MMKV {
get<T>(key: string): T | undefined
setAny(key: string, value: any | undefined): void
get<T>(key: string): T | undefined;
setAny(key: string, value: any | undefined): void;
}
}
MMKV.prototype.get = function <T> (key: string): T | undefined {
MMKV.prototype.get = function <T>(key: string): T | undefined {
const serializedItem = this.getString(key);
return serializedItem ? JSON.parse(serializedItem) : undefined;
}
};
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
if (value === undefined) {
this.delete(key)
}
else {
this.delete(key);
} else {
this.set(key, JSON.stringify(value));
}
}
};

View File

@@ -7,17 +7,17 @@ declare global {
}
}
Number.prototype.bytesToReadable = function (decimals: number = 2) {
Number.prototype.bytesToReadable = function (decimals = 2) {
const bytes = this.valueOf();
if (bytes === 0) return '0 Bytes';
if (bytes === 0) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
};
Number.prototype.secondsToMilliseconds = function () {

View File

@@ -5,12 +5,10 @@ declare global {
}
String.prototype.toTitle = function () {
return this
.replaceAll("_", " ")
.replace(
/\w\S*/g,
text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()
);
}
return this.replaceAll("_", " ").replace(
/\w\S*/g,
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
);
};
export {};
export {};

View File

@@ -1,4 +1,4 @@
module.exports = function (api) {
module.exports = (api) => {
api.cache(true);
return {
presets: ["babel-preset-expo"],

61
biome.json Normal file
View File

@@ -0,0 +1,61 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"organizeImports": {
"enabled": true
},
"files": {
"ignore": [
"node_modules",
"ios",
"android",
"Streamyfin.app",
"utils/jellyseerr",
".expo"
]
},
"linter": {
"enabled": true,
"rules": {
"style": {
"useImportType": "off",
"noNonNullAssertion": "off",
"noParameterAssign": "off",
"useLiteralEnumMembers": "off"
},
"complexity": {
"noForEach": "off"
},
"recommended": true,
"correctness": { "useExhaustiveDependencies": "off" },
"suspicious": {
"noExplicitAny": "off",
"noArrayIndexKey": "off"
}
}
},
"formatter": {
"enabled": true,
"formatWithErrors": true,
"attributePosition": "auto",
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 80
},
"javascript": {
"formatter": {
"arrowParentheses": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"jsxQuoteStyle": "single",
"quoteProperties": "asNeeded",
"semicolons": "always",
"lineWidth": 80
}
},
"json": {
"formatter": {
"trailingCommas": "none"
}
}
}

306
bun.lock
View File

@@ -13,7 +13,6 @@
"@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.4.1",
"@react-native-menu/menu": "^1.2.2",
"@react-navigation/bottom-tabs": "^7.2.0",
@@ -21,9 +20,6 @@
"@react-navigation/native": "^7.0.14",
"@shopify/flash-list": "1.7.3",
"@tanstack/react-query": "^5.66.0",
"@types/lodash": "^4.17.15",
"@types/react-native-vector-icons": "^6.4.18",
"@types/uuid": "^10.0.0",
"add": "^2.0.6",
"axios": "^1.7.9",
"expo": "^52.0.31",
@@ -48,13 +44,13 @@
"expo-router": "~4.0.17",
"expo-screen-orientation": "~8.0.4",
"expo-sensors": "~14.0.2",
"expo-splash-screen": "~0.29.21",
"expo-sharing": "~13.1.0",
"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",
"i18next": "^24.2.2",
"jotai": "^2.11.3",
"lodash": "^4.17.21",
@@ -66,6 +62,7 @@
"react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "0.8.6",
"react-native-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2",
"react-native-compressor": "^1.10.3",
"react-native-country-flag": "^2.0.2",
"react-native-device-info": "^14.0.4",
@@ -102,12 +99,17 @@
},
"devDependencies": {
"@babel/core": "^7.26.8",
"@biomejs/biome": "^1.9.4",
"@react-native-community/cli": "15.1.3",
"@react-native-tvos/config-tv": "^0.1.1",
"@types/jest": "^29.5.14",
"@types/lodash": "^4.17.15",
"@types/react": "~18.3.12",
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "^19.0.0",
"patch-package": "^8.0.0",
"@types/uuid": "^10.0.0",
"husky": "^9.1.7",
"lint-staged": "^15.5.0",
"postinstall-postinstall": "^2.1.0",
"react-test-renderer": "19.0.0",
"typescript": "~5.7.3",
@@ -377,6 +379,24 @@
"@babel/types": ["@babel/types@7.26.9", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw=="],
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
"@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@0.8.6", "", { "dependencies": { "color": "^4.2.3" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-hLlyBAUz4ahaVK2Op2VcJeAkCSpm3KKho4IojkPyXsos4WEHtO44EYWC71TDbVGeOP5HQ9k7FSwAW3IiZs0wHw=="],
"@config-plugins/ffmpeg-kit-react-native": ["@config-plugins/ffmpeg-kit-react-native@9.0.0", "", { "dependencies": { "semver": "^7.3.5" }, "peerDependencies": { "expo": "^52" } }, "sha512-04bXwdq7pmUPoGqYV0YGsrW/8Db+TNicn2Hznb5t+Dl740z9QkNGP4A38y1Mdz7mCU2EW0riASwl/JTH+6rBvw=="],
@@ -387,7 +407,7 @@
"@expo/bunyan": ["@expo/bunyan@4.0.1", "", { "dependencies": { "uuid": "^8.0.0" } }, "sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg=="],
"@expo/cli": ["@expo/cli@0.22.16", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@babel/runtime": "^7.20.0", "@expo/code-signing-certificates": "^0.0.5", "@expo/config": "~10.0.10", "@expo/config-plugins": "~9.0.15", "@expo/devcert": "^1.1.2", "@expo/env": "~0.4.2", "@expo/image-utils": "^0.6.5", "@expo/json-file": "^9.0.2", "@expo/metro-config": "~0.19.10", "@expo/osascript": "^2.1.6", "@expo/package-manager": "^1.7.2", "@expo/plist": "^0.2.2", "@expo/prebuild-config": "^8.0.27", "@expo/rudder-sdk-node": "^1.1.1", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", "@react-native/dev-middleware": "0.76.7", "@urql/core": "^5.0.6", "@urql/exchange-retry": "^1.3.0", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.0.7", "bplist-parser": "^0.3.1", "cacache": "^18.0.2", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", "fast-glob": "^3.3.2", "form-data": "^3.0.1", "freeport-async": "^2.0.0", "fs-extra": "~8.1.0", "getenv": "^1.0.0", "glob": "^10.4.2", "internal-ip": "^4.3.0", "is-docker": "^2.0.0", "is-wsl": "^2.1.1", "lodash.debounce": "^4.0.8", "minimatch": "^3.0.4", "node-forge": "^1.3.1", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^3.0.1", "pretty-bytes": "^5.6.0", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "qrcode-terminal": "0.11.0", "require-from-string": "^2.0.2", "requireg": "^0.2.2", "resolve": "^1.22.2", "resolve-from": "^5.0.0", "resolve.exports": "^2.0.3", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "tar": "^6.2.1", "temp-dir": "^2.0.0", "tempy": "^0.7.1", "terminal-link": "^2.1.1", "undici": "^6.18.2", "unique-string": "~2.0.0", "wrap-ansi": "^7.0.0", "ws": "^8.12.1" }, "bin": { "expo-internal": "build/bin/cli" } }, "sha512-a8Ulbnji9kFatnOtsWGCRs6nMUj9UNC0/WhE74HQdXGDGMn5Pl8eNe3cLMy9G54DdqAmEZmRZpgXmcudT78fEQ=="],
"@expo/cli": ["@expo/cli@0.22.18", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@babel/runtime": "^7.20.0", "@expo/code-signing-certificates": "^0.0.5", "@expo/config": "~10.0.10", "@expo/config-plugins": "~9.0.15", "@expo/devcert": "^1.1.2", "@expo/env": "~0.4.2", "@expo/image-utils": "^0.6.5", "@expo/json-file": "^9.0.2", "@expo/metro-config": "~0.19.11", "@expo/osascript": "^2.1.6", "@expo/package-manager": "^1.7.2", "@expo/plist": "^0.2.2", "@expo/prebuild-config": "^8.0.28", "@expo/rudder-sdk-node": "^1.1.1", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", "@react-native/dev-middleware": "0.76.7", "@urql/core": "^5.0.6", "@urql/exchange-retry": "^1.3.0", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.0.7", "bplist-parser": "^0.3.1", "cacache": "^18.0.2", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", "fast-glob": "^3.3.2", "form-data": "^3.0.1", "freeport-async": "^2.0.0", "fs-extra": "~8.1.0", "getenv": "^1.0.0", "glob": "^10.4.2", "internal-ip": "^4.3.0", "is-docker": "^2.0.0", "is-wsl": "^2.1.1", "lodash.debounce": "^4.0.8", "minimatch": "^3.0.4", "node-forge": "^1.3.1", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^3.0.1", "pretty-bytes": "^5.6.0", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "qrcode-terminal": "0.11.0", "require-from-string": "^2.0.2", "requireg": "^0.2.2", "resolve": "^1.22.2", "resolve-from": "^5.0.0", "resolve.exports": "^2.0.3", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "tar": "^6.2.1", "temp-dir": "^2.0.0", "tempy": "^0.7.1", "terminal-link": "^2.1.1", "undici": "^6.18.2", "unique-string": "~2.0.0", "wrap-ansi": "^7.0.0", "ws": "^8.12.1" }, "bin": { "expo-internal": "build/bin/cli" } }, "sha512-TWGKHWTYU9xE7YETPk2zQzLPl+bldpzZCa0Cqg0QeENpu03ZEnMxUqrgHwrbWGTf7ONTYC1tODBkFCFw/qgPGA=="],
"@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.5", "", { "dependencies": { "node-forge": "^1.2.1", "nullthrows": "^1.1.1" } }, "sha512-BNhXkY1bblxKZpltzAx98G2Egj9g1Q+JRcvR7E99DOj862FTCX+ZPsAUtPTr7aHxwtrL7+fL3r0JSmM9kBm+Bw=="],
@@ -401,13 +421,13 @@
"@expo/env": ["@expo/env@0.4.2", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^1.0.0" } }, "sha512-TgbCgvSk0Kq0e2fLoqHwEBL4M0ztFjnBEz0YCDm5boc1nvkV1VMuIMteVdeBwnTh8Z0oPJTwHCD49vhMEt1I6A=="],
"@expo/fingerprint": ["@expo/fingerprint@0.11.10", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "find-up": "^5.0.0", "getenv": "^1.0.0", "minimatch": "^3.0.4", "p-limit": "^3.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-34ZwPjbnnD7KHSyceaxcLQbClCkYHbEp6wBDe+aqimvQw25m2LnliN1cMCVQnpOHkBFRTcbKlowby0fIxAm2bQ=="],
"@expo/fingerprint": ["@expo/fingerprint@0.11.11", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "find-up": "^5.0.0", "getenv": "^1.0.0", "minimatch": "^3.0.4", "p-limit": "^3.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-gNyn1KnAOpEa8gSNsYqXMTcq0fSwqU/vit6fP5863vLSKxHm/dNt/gm/uZJxrRZxKq71KUJWF6I7d3z8qIfq5g=="],
"@expo/image-utils": ["@expo/image-utils@0.6.5", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "fs-extra": "9.0.0", "getenv": "^1.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0", "temp-dir": "~2.0.0", "unique-string": "~2.0.0" } }, "sha512-RsS/1CwJYzccvlprYktD42KjyfWZECH6PPIEowvoSmXfGLfdViwcUEI4RvBfKX5Jli6P67H+6YmHvPTbGOboew=="],
"@expo/json-file": ["@expo/json-file@9.0.2", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "json5": "^2.2.3", "write-file-atomic": "^2.3.0" } }, "sha512-yAznIUrybOIWp3Uax7yRflB0xsEpvIwIEqIjao9SGi2Gaa+N0OamWfe0fnXBSWF+2zzF4VvqwT4W5zwelchfgw=="],
"@expo/metro-config": ["@expo/metro-config@0.19.10", "", { "dependencies": { "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@babel/parser": "^7.20.0", "@babel/types": "^7.20.0", "@expo/config": "~10.0.9", "@expo/env": "~0.4.1", "@expo/json-file": "~9.0.1", "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "debug": "^4.3.2", "fs-extra": "^9.1.0", "getenv": "^1.0.0", "glob": "^10.4.2", "jsc-safe-url": "^0.2.4", "lightningcss": "~1.27.0", "minimatch": "^3.0.4", "postcss": "~8.4.32", "resolve-from": "^5.0.0" } }, "sha512-/CtsMLhELJRJjAllM4EUnlPUAixn8Q2YhorKBa4uXZ6FvTEZWHJjqsXnQD39gWSEuAIVwLfJ1qgJi8666+dW2w=="],
"@expo/metro-config": ["@expo/metro-config@0.19.11", "", { "dependencies": { "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@babel/parser": "^7.20.0", "@babel/types": "^7.20.0", "@expo/config": "~10.0.10", "@expo/env": "~0.4.2", "@expo/json-file": "~9.0.2", "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "debug": "^4.3.2", "fs-extra": "^9.1.0", "getenv": "^1.0.0", "glob": "^10.4.2", "jsc-safe-url": "^0.2.4", "lightningcss": "~1.27.0", "minimatch": "^3.0.4", "postcss": "~8.4.32", "resolve-from": "^5.0.0" } }, "sha512-XaobHTcsoHQdKEH7PI/DIpr2QiugkQmPYolbfzkpSJMplNWfSh+cTRjrm4//mS2Sb78qohtu0u2CGJnFqFUGag=="],
"@expo/metro-runtime": ["@expo/metro-runtime@4.0.1", "", { "peerDependencies": { "react-native": "*" } }, "sha512-CRpbLvdJ1T42S+lrYa1iZp1KfDeBp4oeZOK3hdpiS5n0vR0nhD6sC1gGF0sTboCTp64tLteikz5Y3j53dvgOIw=="],
@@ -417,7 +437,7 @@
"@expo/plist": ["@expo/plist@0.2.2", "", { "dependencies": { "@xmldom/xmldom": "~0.7.7", "base64-js": "^1.2.3", "xmlbuilder": "^14.0.0" } }, "sha512-ZZGvTO6vEWq02UAPs3LIdja+HRO18+LRI5QuDl6Hs3Ps7KX7xU6Y6kjahWKY37Rx2YjNpX07dGpBFzzC+vKa2g=="],
"@expo/prebuild-config": ["@expo/prebuild-config@8.0.27", "", { "dependencies": { "@expo/config": "~10.0.9", "@expo/config-plugins": "~9.0.15", "@expo/config-types": "^52.0.4", "@expo/image-utils": "^0.6.4", "@expo/json-file": "^9.0.1", "@react-native/normalize-colors": "0.76.7", "debug": "^4.3.1", "fs-extra": "^9.0.0", "resolve-from": "^5.0.0", "semver": "^7.6.0", "xml2js": "0.6.0" } }, "sha512-UFGOx4TfiT2gOde8RylwmXctp/WvqBQ4TN7z1YL0WWXfG9TWfO7HdsUnqQhGMW+CDDc7FOJMEo8q1a6xiikfYA=="],
"@expo/prebuild-config": ["@expo/prebuild-config@8.0.28", "", { "dependencies": { "@expo/config": "~10.0.10", "@expo/config-plugins": "~9.0.15", "@expo/config-types": "^52.0.4", "@expo/image-utils": "^0.6.5", "@expo/json-file": "^9.0.2", "@react-native/normalize-colors": "0.76.7", "debug": "^4.3.1", "fs-extra": "^9.0.0", "resolve-from": "^5.0.0", "semver": "^7.6.0", "xml2js": "0.6.0" } }, "sha512-SDDgCKKS1wFNNm3de2vBP8Q5bnxcabuPDE9Mnk9p7Gb4qBavhwMbAtrLcAyZB+WRb4QM+yan3z3K95vvCfI/+A=="],
"@expo/react-native-action-sheet": ["@expo/react-native-action-sheet@4.1.0", "", { "dependencies": { "@types/hoist-non-react-statics": "^3.3.1", "hoist-non-react-statics": "^3.3.0" }, "peerDependencies": { "react": ">=18.0.0" } }, "sha512-RILoWhREgjMdr1NUSmZa/cHg8onV2YPDAMOy0iIP1c3H7nT9QQZf5dQNHK8ehcLM82sarVxriBJyYSSHAx7j6w=="],
@@ -431,7 +451,7 @@
"@expo/vector-icons": ["@expo/vector-icons@14.0.4", "", { "dependencies": { "prop-types": "^15.8.1" } }, "sha512-+yKshcbpDfbV4zoXOgHxCwh7lkE9VVTT5T03OUlBsqfze1PLy6Hi4jp1vSb1GVbY6eskvMIivGVc9SKzIv0oEQ=="],
"@expo/ws-tunnel": ["@expo/ws-tunnel@1.0.4", "", {}, "sha512-spXCVXxbeKOe8YZ9igd+MDfXZe6LeDvFAdILijeTSG+XcxGrZLmqMWWkFKR0nV8lTWZ+NugUT3CoiXmEuKKQ7w=="],
"@expo/ws-tunnel": ["@expo/ws-tunnel@1.0.5", "", {}, "sha512-Ta9KzslHAIbw2ZoyZ7Ud7/QImucy+K4YvOqo9AhGfUfH76hQzaffQreOySzYusDfW8Y+EXh0ZNWE68dfCumFFw=="],
"@expo/xcpretty": ["@expo/xcpretty@4.3.2", "", { "dependencies": { "@babel/code-frame": "7.10.4", "chalk": "^4.1.0", "find-up": "^5.0.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-ReZxZ8pdnoI3tP/dNnJdnmAk7uLT4FjsKDGW7YeDdvdOMz2XCQSmSCM9IWlrXuWtMF9zeSB6WJtEhCQ41gQOfw=="],
@@ -575,8 +595,6 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.0", "", {}, "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg=="],
"@react-native-async-storage/async-storage": ["@react-native-async-storage/async-storage@1.23.1", "", { "dependencies": { "merge-options": "^3.0.4" }, "peerDependencies": { "react-native": "^0.0.0-0 || >=0.60 <1.0" } }, "sha512-Qd2kQ3yi6Y3+AcUlrHxSLlnBvpdCEMVGFlVBneVOjaFaPU61g1huc38g339ysXspwY1QZA2aNhrk/KlHGO+ewA=="],
"@react-native-community/cli": ["@react-native-community/cli@15.1.3", "", { "dependencies": { "@react-native-community/cli-clean": "15.1.3", "@react-native-community/cli-config": "15.1.3", "@react-native-community/cli-debugger-ui": "15.1.3", "@react-native-community/cli-doctor": "15.1.3", "@react-native-community/cli-server-api": "15.1.3", "@react-native-community/cli-tools": "15.1.3", "@react-native-community/cli-types": "15.1.3", "chalk": "^4.1.2", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-+ih/WYUkJsEV2CMAnOHvVoSIz/Ahg5UJk+sqSIOmY79mWAglQzfLP71o7b0neJCnJWLmWiO6G6/S+kmULefD5g=="],
"@react-native-community/cli-clean": ["@react-native-community/cli-clean@15.1.3", "", { "dependencies": { "@react-native-community/cli-tools": "15.1.3", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-glob": "^3.3.2" } }, "sha512-3s9NGapIkONFoCUN2s77NYI987GPSCdr74rTf0TWyGIDf4vTYgKoWKKR+Ml3VTa1BCj51r4cYuHEKE1pjUSc0w=="],
@@ -679,9 +697,9 @@
"@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="],
"@tanstack/query-core": ["@tanstack/query-core@5.66.0", "", {}, "sha512-J+JeBtthiKxrpzUu7rfIPDzhscXF2p5zE/hVdrqkACBP8Yu0M96mwJ5m/8cPPYQE9aRNvXztXHlNwIh4FEeMZw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.66.4", "", {}, "sha512-skM/gzNX4shPkqmdTCSoHtJAPMTtmIJNS0hE+xwTTUVYwezArCT34NMermABmBVUg5Ls5aiUXEDXfqwR1oVkcA=="],
"@tanstack/react-query": ["@tanstack/react-query@5.66.0", "", { "dependencies": { "@tanstack/query-core": "5.66.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-z3sYixFQJe8hndFnXgWu7C79ctL+pI0KAelYyW+khaNJ1m22lWrhJU2QrsTcRKMuVPtoZvfBYrTStIdKo+x0Xw=="],
"@tanstack/react-query": ["@tanstack/react-query@5.66.9", "", { "dependencies": { "@tanstack/query-core": "5.66.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-NRI02PHJsP5y2gAuWKP+awamTIBFBSKMnO6UVzi03GTclmHHHInH5UzVgzi5tpu4+FmGfsdT7Umqegobtsp23A=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
@@ -743,8 +761,6 @@
"@xmldom/xmldom": ["@xmldom/xmldom@0.7.13", "", {}, "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g=="],
"@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="],
"@zxing/text-encoding": ["@zxing/text-encoding@0.9.0", "", {}, "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
@@ -765,7 +781,7 @@
"anser": ["anser@1.4.10", "", {}, "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww=="],
"ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
"ansi-escapes": ["ansi-escapes@7.0.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw=="],
"ansi-fragments": ["ansi-fragments@0.2.1", "", { "dependencies": { "colorette": "^1.0.7", "slice-ansi": "^2.0.0", "strip-ansi": "^5.0.0" } }, "sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w=="],
@@ -831,7 +847,7 @@
"babel-preset-current-node-syntax": ["babel-preset-current-node-syntax@1.1.0", "", { "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw=="],
"babel-preset-expo": ["babel-preset-expo@12.0.8", "", { "dependencies": { "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-transform-export-namespace-from": "^7.22.11", "@babel/plugin-transform-object-rest-spread": "^7.12.13", "@babel/plugin-transform-parameters": "^7.22.15", "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-preset": "0.76.7", "babel-plugin-react-native-web": "~0.19.13", "react-refresh": "^0.14.2" }, "peerDependencies": { "babel-plugin-react-compiler": "^19.0.0-beta-9ee70a1-20241017", "react-compiler-runtime": "^19.0.0-beta-8a03594-20241020" }, "optionalPeers": ["babel-plugin-react-compiler", "react-compiler-runtime"] }, "sha512-bojAddWZJusLs3NVdF+jN3WweTYVEZXBKIeO0sOhqOg7UPh5w1bnMkx7SDua0FgQMGBxb13qM31Y46yeZnmXjw=="],
"babel-preset-expo": ["babel-preset-expo@12.0.9", "", { "dependencies": { "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-transform-export-namespace-from": "^7.22.11", "@babel/plugin-transform-object-rest-spread": "^7.12.13", "@babel/plugin-transform-parameters": "^7.22.15", "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-preset": "0.76.7", "babel-plugin-react-native-web": "~0.19.13", "react-refresh": "^0.14.2" }, "peerDependencies": { "babel-plugin-react-compiler": "^19.0.0-beta-9ee70a1-20241017", "react-compiler-runtime": "^19.0.0-beta-8a03594-20241020" }, "optionalPeers": ["babel-plugin-react-compiler", "react-compiler-runtime"] }, "sha512-1c+ysrTavT49WgVAj0OX/TEzt1kU2mfPhDaDajstshNHXFKPenMPWSViA/DHrJKVIMwaqr+z3GbUOD9GtKgpdg=="],
"babel-preset-jest": ["babel-preset-jest@29.6.3", "", { "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA=="],
@@ -899,7 +915,7 @@
"camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001699", "", {}, "sha512-b+uH5BakXZ9Do9iK+CkDmctUSEqZl+SP056vc5usa0PL+ev5OHw003rZXcnjNDv3L8P5j6rwT6C0BPKSikW08w=="],
"caniuse-lite": ["caniuse-lite@1.0.30001700", "", {}, "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ=="],
"centra": ["centra@2.7.0", "", { "dependencies": { "follow-redirects": "^1.15.6" } }, "sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg=="],
@@ -923,6 +939,8 @@
"cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
@@ -939,7 +957,7 @@
"color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
@@ -1059,7 +1077,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"electron-to-chromium": ["electron-to-chromium@1.5.100", "", {}, "sha512-u1z9VuzDXV86X2r3vAns0/5ojfXBue9o0+JDUDBKYqGLjxLkSqsSUoPU/6kW0gx76V44frHaf6Zo+QF74TQCMg=="],
"electron-to-chromium": ["electron-to-chromium@1.5.103", "", {}, "sha512-P6+XzIkfndgsrjROJWfSvVEgNHtPgbhVyTkwLjUM2HU/h7pZRORgaTlHqfAikqxKmdJMLW8fftrdGWbd/Ds0FA=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -1075,6 +1093,8 @@
"envinfo": ["envinfo@7.14.0", "", { "bin": { "envinfo": "dist/cli.js" } }, "sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg=="],
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
"eol": ["eol@0.9.1", "", {}, "sha512-Ds/TEoZjwggRoz/Q2O7SE3i4Jm66mqTDfmdHdq/7DKVk3bro9Q8h6WdXKdPqFLMoqxrDK5SVRzHVPOS6uuGtrg=="],
"error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="],
@@ -1089,6 +1109,8 @@
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
@@ -1103,6 +1125,8 @@
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"exec-async": ["exec-async@2.2.0", "", {}, "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw=="],
@@ -1113,11 +1137,11 @@
"expect": ["expect@29.7.0", "", { "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", "jest-matcher-utils": "^29.7.0", "jest-message-util": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="],
"expo": ["expo@52.0.35", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "0.22.16", "@expo/config": "~10.0.10", "@expo/config-plugins": "~9.0.15", "@expo/fingerprint": "0.11.10", "@expo/metro-config": "0.19.10", "@expo/vector-icons": "^14.0.0", "babel-preset-expo": "~12.0.8", "expo-asset": "~11.0.3", "expo-constants": "~17.0.6", "expo-file-system": "~18.0.10", "expo-font": "~13.0.3", "expo-keep-awake": "~14.0.2", "expo-modules-autolinking": "2.0.7", "expo-modules-core": "2.2.2", "fbemitter": "^3.0.0", "web-streams-polyfill": "^3.3.2", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli" } }, "sha512-VagwS6MJbU0Eky18i4amkkSy7FTi0v31B0W+qoEcsU4x5OurA381rxw4qGsQE+8pmSD/Gf3DGb8ygJw+HoAsXw=="],
"expo": ["expo@52.0.37", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "0.22.18", "@expo/config": "~10.0.10", "@expo/config-plugins": "~9.0.15", "@expo/fingerprint": "0.11.11", "@expo/metro-config": "0.19.11", "@expo/vector-icons": "^14.0.0", "babel-preset-expo": "~12.0.9", "expo-asset": "~11.0.4", "expo-constants": "~17.0.7", "expo-file-system": "~18.0.11", "expo-font": "~13.0.4", "expo-keep-awake": "~14.0.3", "expo-modules-autolinking": "2.0.8", "expo-modules-core": "2.2.2", "fbemitter": "^3.0.0", "web-streams-polyfill": "^3.3.2", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli" } }, "sha512-fo37ClqjNLOVInerm7BU27H8lfPfeTC7Pmu72roPzq46DnJfs+KzTxTzE34GcJ0b6hMUx9FRSSGyTQqxzo2TVQ=="],
"expo-application": ["expo-application@6.0.2", "", { "peerDependencies": { "expo": "*" } }, "sha512-qcj6kGq3mc7x5yIb5KxESurFTJCoEKwNEL34RdPEvTB/xhl7SeVZlu05sZBqxB1V4Ryzq/LsCb7NHNfBbb3L7A=="],
"expo-asset": ["expo-asset@11.0.3", "", { "dependencies": { "@expo/image-utils": "^0.6.4", "expo-constants": "~17.0.5", "invariant": "^2.2.4", "md5-file": "^3.2.3" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-vgJnC82IooAVMy5PxbdFIMNJhW4hKAUyxc5VIiAPPf10vFYw6CqHm+hrehu4ST1I4bvg5PV4uKdPxliebcbgLg=="],
"expo-asset": ["expo-asset@11.0.4", "", { "dependencies": { "@expo/image-utils": "^0.6.5", "expo-constants": "~17.0.7", "invariant": "^2.2.4", "md5-file": "^3.2.3" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-CdIywU0HrR3wsW5c3n0cT3jW9hccZdnqGsRqY+EY/RWzJbDXtDfAQVEiFHO3mDK7oveUwrP2jK/6ZRNek41/sg=="],
"expo-background-fetch": ["expo-background-fetch@13.0.5", "", { "dependencies": { "expo-task-manager": "~12.0.5" }, "peerDependencies": { "expo": "*" } }, "sha512-rLRM+rYDRT0fA0Oaet5ibJK3nKVRkfdjXjISHxjUvIE4ktD9pE+UjAPPdjTXZ5CkNb3JyNNhQGJEGpdJC2HLKw=="],
@@ -1127,7 +1151,7 @@
"expo-build-properties": ["expo-build-properties@0.13.2", "", { "dependencies": { "ajv": "^8.11.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-ML2GwBgn0Bo4yPgnSGb7h3XVxCigS/KFdid3xPC2HldEioTP3UewB/2Qa4WBsam9Fb7lAuRyVHAfRoA3swpDzg=="],
"expo-constants": ["expo-constants@17.0.6", "", { "dependencies": { "@expo/config": "~10.0.9", "@expo/env": "~0.4.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-rl3/hBIIkh4XDkCEMzGpmY6kWj2G1TA4Mq2joeyzoFBepJuGjqnGl7phf/71sTTgamQ1hmhKCLRNXMpRqzzqxw=="],
"expo-constants": ["expo-constants@17.0.7", "", { "dependencies": { "@expo/config": "~10.0.10", "@expo/env": "~0.4.2" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-sp5NUiV17I3JblVPIBDgoxgt7JIZS30vcyydCYHxsEoo+aKaeRYXxGYilCvb9lgI6BBwSL24sQ6ZjWsCWoF1VA=="],
"expo-crypto": ["expo-crypto@14.0.2", "", { "dependencies": { "base64-js": "^1.3.0" }, "peerDependencies": { "expo": "*" } }, "sha512-WRc9PBpJraJN29VD5Ef7nCecxJmZNyRKcGkNiDQC1nhY5agppzwhqh7zEzNFarE/GqDgSiaDHS8yd5EgFhP9AQ=="],
@@ -1143,17 +1167,17 @@
"expo-eas-client": ["expo-eas-client@0.13.2", "", {}, "sha512-2RAAGtkO9vseoJZuW4mhJkiNQ6+FfLrX66OTMq4Qj9mRKZV2Uq/ZquxUGIeJyYqBy4vNYeKbuPd2oJtsV9LBGQ=="],
"expo-file-system": ["expo-file-system@18.0.10", "", { "dependencies": { "web-streams-polyfill": "^3.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-+GnxkI+J9tOzUQMx+uIOLBEBsO2meyoYHxd87m9oT9M//BpepYqI1AvYBH8YM4dgr9HaeaeLr7z5XFVqfL8tWg=="],
"expo-file-system": ["expo-file-system@18.0.11", "", { "dependencies": { "web-streams-polyfill": "^3.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-yDwYfEzWgPXsBZHJW2RJ8Q66ceiFN9Wa5D20pp3fjXVkzPBDwxnYwiPWk4pVmCa5g4X5KYMoMne1pUrsL4OEpg=="],
"expo-font": ["expo-font@13.0.3", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-9IdYz+A+b3KvuCYP7DUUXF4VMZjPU+IsvAnLSVJ2TfP6zUD2JjZFx3jeo/cxWRkYk/aLj5+53Te7elTAScNl4Q=="],
"expo-font": ["expo-font@13.0.4", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-eAP5hyBgC8gafFtprsz0HMaB795qZfgJWqTmU0NfbSin1wUuVySFMEPMOrTkTgmazU73v4Cb4x7p86jY1XXYUw=="],
"expo-haptics": ["expo-haptics@14.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-V81FZ7xRUfqM6uSI6FA1KnZ+QpEKnISqafob/xEfcx1ymwhm4V3snuLWWFjmAz+XaZQTqlYa8z3QbqEXz7G63w=="],
"expo-image": ["expo-image@2.0.5", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-FAq7uyaTAfLWER3lN+KVAtep7IfGPZN9ygnVKW4GvgnvR4hKhTtZ5WNxiJ18KKLVb4nUKuHOpQeJNnljy3dtmA=="],
"expo-image": ["expo-image@2.0.7", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-kv40OIJOkItwznhdqFmKxTMC5O8GkpyTf8ng7Py4Hy6IBiH59dkeP6vUZQhzPhJOm5v1kZK4XldbskBosqzOug=="],
"expo-json-utils": ["expo-json-utils@0.14.0", "", {}, "sha512-xjGfK9dL0B1wLnOqNkX0jM9p48Y0I5xEPzHude28LY67UmamUyAACkqhZGaPClyPNfdzczk7Ej6WaRMT3HfXvw=="],
"expo-keep-awake": ["expo-keep-awake@14.0.2", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-71XAMnoWjKZrN8J7Q3+u0l9Ytp4OfhNAYz8BCWF1/9aFUw09J3I7Z5DuI3MUsVMa/KWi+XhG+eDUFP8cVA19Uw=="],
"expo-keep-awake": ["expo-keep-awake@14.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-6Jh94G6NvTZfuLnm2vwIpKe3GdOiVBuISl7FI8GqN0/9UOg9E0WXXp5cDcfAG8bn80RfgLJS8P7EPUGTZyOvhg=="],
"expo-linear-gradient": ["expo-linear-gradient@14.0.2", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-nvac1sPUfFFJ4mY25UkvubpUV/olrBH+uQw5k+beqSvQaVQiUfFtYzfRr+6HhYBNb4AEsOtpsCRkpDww3M2iGQ=="],
@@ -1163,7 +1187,7 @@
"expo-manifests": ["expo-manifests@0.15.6", "", { "dependencies": { "@expo/config": "~10.0.9", "expo-json-utils": "~0.14.0" }, "peerDependencies": { "expo": "*" } }, "sha512-z+TFICrijMaqBvcJkVx8WzgmOsV6ZJGvaPNQKZr4DA6uqugFMtvAQVikDjIq7SEc3n7IgPk0GR4ZN3/KnnkeVA=="],
"expo-modules-autolinking": ["expo-modules-autolinking@2.0.7", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0", "fast-glob": "^3.2.5", "find-up": "^5.0.0", "fs-extra": "^9.1.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-rkGc6a/90AC3q8wSy4V+iIpq6Fd0KXmQICKrvfmSWwrMgJmLfwP4QTrvLYPYOOMjFwNJcTaohcH8vzW/wYKrMg=="],
"expo-modules-autolinking": ["expo-modules-autolinking@2.0.8", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0", "fast-glob": "^3.2.5", "find-up": "^5.0.0", "fs-extra": "^9.1.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-DezgnEYFQYic8hKGhkbztBA3QUmSftjaNDIKNAtS2iGJmzCcNIkatjN2slFDSWjSTNo8gOvPQyMKfyHWFvLpOQ=="],
"expo-modules-core": ["expo-modules-core@2.2.2", "", { "dependencies": { "invariant": "^2.2.4" } }, "sha512-SgjK86UD89gKAscRK3bdpn6Ojfs/KU4GujtuFx1wm4JaBjmXH4aakWkItkPlAV2pjIiHJHWQbENL9xjbw/Qr/g=="],
@@ -1177,6 +1201,8 @@
"expo-sensors": ["expo-sensors@14.0.2", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-nCb1Q3ctb0oVTZ9p6eFmQ2fINa6KoxXXIhagPpdN0qR82p00YosP27IuyxjVB3fnCJFeC4TffNxNjBxwAUk+nA=="],
"expo-sharing": ["expo-sharing@13.1.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-7O29Bdm95v6aBXBhrbKx9FBqL5loQcK0nvCMFSbZHMy1r7Z6vb6sTMsaGbvknfOH+tEzn+LIleTw5TreoxNT9g=="],
"expo-splash-screen": ["expo-splash-screen@0.29.22", "", { "dependencies": { "@expo/prebuild-config": "^8.0.27" }, "peerDependencies": { "expo": "*" } }, "sha512-f+bPpF06bqiuW1Fbrd3nxeaSsmTVTBEKEYe3epYt4IE6y4Ulli3qEUamMLlRQiDGuIXPU6zQlscpy2mdBUI5cA=="],
"expo-status-bar": ["expo-status-bar@2.0.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-AkIPX7jWHRPp83UBZ1iXtVvyr0g+DgBVvIXTtlmPtmUsm8Vq9Bb5IGj86PW8osuFlgoTVAg7HI/+Ok7yEYwiRg=="],
@@ -1187,7 +1213,7 @@
"expo-task-manager": ["expo-task-manager@12.0.5", "", { "dependencies": { "unimodules-app-loader": "~5.0.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-tDHOBYORA6wuO32NWwz/Egrvn+N6aANHAa0DFs+01VK/IJZfU9D05ZN6M5XYIlZv5ll4GSX1wJZyTCY0HZGapw=="],
"expo-updates": ["expo-updates@0.26.18", "", { "dependencies": { "@expo/code-signing-certificates": "0.0.5", "@expo/config": "~10.0.9", "@expo/config-plugins": "~9.0.15", "@expo/spawn-async": "^1.7.2", "arg": "4.1.0", "chalk": "^4.1.2", "expo-eas-client": "~0.13.2", "expo-manifests": "~0.15.5", "expo-structured-headers": "~4.0.0", "expo-updates-interface": "~1.0.0", "fast-glob": "^3.3.2", "fbemitter": "^3.0.0", "ignore": "^5.3.1", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*", "react": "*" }, "bin": { "expo-updates": "bin/cli.js" } }, "sha512-i9on8jMLrDxtr3Jwpmqj14oa4PWxSKYrHhJYK40xATV6qrauTija9R7BkN0hQjD4LpElt5UJW2/YUP30UsTFqA=="],
"expo-updates": ["expo-updates@0.26.19", "", { "dependencies": { "@expo/code-signing-certificates": "0.0.5", "@expo/config": "~10.0.10", "@expo/config-plugins": "~9.0.15", "@expo/spawn-async": "^1.7.2", "arg": "4.1.0", "chalk": "^4.1.2", "expo-eas-client": "~0.13.2", "expo-manifests": "~0.15.6", "expo-structured-headers": "~4.0.0", "expo-updates-interface": "~1.0.0", "fast-glob": "^3.3.2", "fbemitter": "^3.0.0", "ignore": "^5.3.1", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*", "react": "*" }, "bin": { "expo-updates": "bin/cli.js" } }, "sha512-h40UrG0n1nCb2na1ffz+mNQtsnr7/BxxK+EtXJSqCaD9PIGaTGe20tasmo1oVskv3s37zfv0x93+6uTjanieQg=="],
"expo-updates-interface": ["expo-updates-interface@1.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-93oWtvULJOj+Pp+N/lpTcFfuREX1wNeHtp7Lwn8EbzYYmdn37MvZU3TPW2tYYCZuhzmKEXnUblYcruYoDu7IrQ=="],
@@ -1209,7 +1235,7 @@
"fast-uri": ["fast-uri@3.0.6", "", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="],
"fast-xml-parser": ["fast-xml-parser@4.5.1", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-y655CeyUQ+jj7KBbYMc4FG01V8ZQqjN+gDYGJ50RtfsUB8iG9AmwmwoAgeKLJdmueKKMrH1RJ7yXHTSoczdv5w=="],
"fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="],
"fastq": ["fastq@1.19.0", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA=="],
@@ -1223,8 +1249,6 @@
"fetch-retry": ["fetch-retry@4.1.1", "", {}, "sha512-e6eB7zN6UBSwGVwrbWVH+gdLnkW9WwHhmq2YDK1Sh30pzx1onRVGBvogTlUeWxwTa+L86NYdo4hFkh7O8ZjSnA=="],
"ffmpeg-kit-react-native": ["ffmpeg-kit-react-native@6.0.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-r9uSmahq8TeyIb7fXf3ft+uUXyoeWRFa99+khjo0TAzWO9y0z9wU7eGnab9JLw1MmCB9v64o4yojNluJhVm9nQ=="],
"file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -1237,11 +1261,9 @@
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"find-yarn-workspace-root": ["find-yarn-workspace-root@2.0.0", "", { "dependencies": { "micromatch": "^4.0.2" } }, "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ=="],
"flow-enums-runtime": ["flow-enums-runtime@0.0.6", "", {}, "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw=="],
"flow-parser": ["flow-parser@0.261.1", "", {}, "sha512-2l5bBKeVtT+d+1CYSsTLJ+iP2FuoR7zjbDQI/v6dDRiBpx3Lb20Z/tLS37ReX/lcodyGSHC2eA/Nk63hB+mkYg=="],
"flow-parser": ["flow-parser@0.261.2", "", {}, "sha512-RtunoakA3YjtpAxPSOBVW6lmP5NYmETwkpAfNkdr8Ovf86ENkbD3mtPWnswFTIUtRvjwv0i8ZSkHK+AzsUg1JA=="],
"follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="],
@@ -1251,7 +1273,7 @@
"foreground-child": ["foreground-child@3.3.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" } }, "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg=="],
"form-data": ["form-data@4.0.1", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw=="],
"form-data": ["form-data@4.0.2", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.12" } }, "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w=="],
"freeport-async": ["freeport-async@2.0.0", "", {}, "sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ=="],
@@ -1271,6 +1293,8 @@
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="],
"get-intrinsic": ["get-intrinsic@1.2.7", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "function-bind": "^1.1.2", "get-proto": "^1.0.0", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
@@ -1325,6 +1349,8 @@
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
"hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="],
"i18next": ["i18next@24.2.2", "", { "dependencies": { "@babel/runtime": "^7.23.2" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-NE6i86lBCKRYZa5TaUDkU5S4HFgLIEJRLr3Whf2psgaxBleQ2LC1YW1Vc+SCgkAW7VEzndT6al6+CzegSUHcTQ=="],
@@ -1395,8 +1421,6 @@
"is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="],
"is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="],
"is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
@@ -1409,8 +1433,6 @@
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="],
@@ -1451,7 +1473,7 @@
"join-component": ["join-component@1.1.0", "", {}, "sha512-bF7vcQxbODoGK1imE2P9GS9aw4zD0Sd+Hni68IMZLj7zRnquH7dXUmMw9hDI5S/Jzt7q+IyTXN0rSg2GI0IKhQ=="],
"jotai": ["jotai@2.12.0", "", { "peerDependencies": { "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-j5B4NmUw8gbuN7AG4NufWw00rfpm6hexL2CVhKD7juoP2YyD9FEUV5ar921JMvadyrxQhU1NpuKUL3QfsAlVpA=="],
"jotai": ["jotai@2.12.1", "", { "peerDependencies": { "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-VUW0nMPYIru5g89tdxwr9ftiVdc/nGV9jvHISN8Ucx+m1vI9dBeHemfqYzEuw5XSkmYjD/MEyApN9k6yrATsZQ=="],
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
@@ -1473,18 +1495,12 @@
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-stable-stringify": ["json-stable-stringify@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "isarray": "^2.0.5", "jsonify": "^0.0.1", "object-keys": "^1.1.1" } }, "sha512-Lp6HbbBgosLmJbjx0pBLbgvx68FaFU1sdkmBuckmhhJ88kL13OA51CDtR2yJB50eCNMH9wRqtQNNiAqQH4YXnA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"jsonify": ["jsonify@0.0.1", "", {}, "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="],
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
"klaw-sync": ["klaw-sync@6.0.0", "", { "dependencies": { "graceful-fs": "^4.1.11" } }, "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ=="],
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
@@ -1513,10 +1529,14 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/OJLj94Zm/waZShL8nB5jsNj3CfNATLCTyFxZyouilfTmSoLDX7VlVAmhPHoZWVFp4vdmoiEbPEYC8HID3m6yw=="],
"lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"lint-staged": ["lint-staged@15.5.0", "", { "dependencies": { "chalk": "^5.4.1", "commander": "^13.1.0", "debug": "^4.4.0", "execa": "^8.0.1", "lilconfig": "^3.1.3", "listr2": "^8.2.5", "micromatch": "^4.0.8", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.7.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-WyCzSbfYGhK7cU+UuDDkzUiytbfbi0ZdPy2orwtM75P3WTtQBzmG40cCxIa8Ii2+XjfxzLH6Be46tUfWS85Xfg=="],
"listr2": ["listr2@8.2.5", "", { "dependencies": { "cli-truncate": "^4.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ=="],
"load-bmfont": ["load-bmfont@1.4.2", "", { "dependencies": { "buffer-equal": "0.0.1", "mime": "^1.3.4", "parse-bmfont-ascii": "^1.0.3", "parse-bmfont-binary": "^1.0.5", "parse-bmfont-xml": "^1.1.4", "phin": "^3.7.1", "xhr": "^2.0.1", "xtend": "^4.0.0" } }, "sha512-qElWkmjW9Oq1F9EI5Gt7aD9zcdHb9spJCW1L/dmPf7KzCCEJxq8nhHz5eCgI9aMf7vrG/wyaCqdsI+Iy9ZTlog=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
@@ -1529,6 +1549,8 @@
"log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
"logkitty": ["logkitty@0.7.1", "", { "dependencies": { "ansi-fragments": "^0.2.1", "dayjs": "^1.8.15", "yargs": "^15.1.0" }, "bin": { "logkitty": "bin/logkitty.js" } }, "sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
@@ -1551,8 +1573,6 @@
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
"merge-options": ["merge-options@3.0.4", "", { "dependencies": { "is-plain-obj": "^2.1.0" } }, "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
@@ -1595,6 +1615,8 @@
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"min-document": ["min-document@2.19.0", "", { "dependencies": { "dom-walk": "^0.1.0" } }, "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ=="],
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@@ -1681,7 +1703,7 @@
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
"open": ["open@6.4.0", "", { "dependencies": { "is-wsl": "^1.1.0" } }, "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg=="],
"ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="],
@@ -1719,8 +1741,6 @@
"password-prompt": ["password-prompt@1.1.3", "", { "dependencies": { "ansi-escapes": "^4.3.2", "cross-spawn": "^7.0.3" } }, "sha512-HkrjG2aJlvF0t2BMH0e2LB/EHf3Lcq3fNMzy4GYHcQblAvOl+QQji1Lx7WRBMqpVK8p+KR7bCg7oqAMXtdgqyw=="],
"patch-package": ["patch-package@8.0.0", "", { "dependencies": { "@yarnpkg/lockfile": "^1.1.0", "chalk": "^4.1.2", "ci-info": "^3.7.0", "cross-spawn": "^7.0.3", "find-yarn-workspace-root": "^2.0.0", "fs-extra": "^9.0.0", "json-stable-stringify": "^1.0.2", "klaw-sync": "^6.0.0", "minimist": "^1.2.6", "open": "^7.4.2", "rimraf": "^2.6.3", "semver": "^7.5.3", "slash": "^2.0.0", "tmp": "^0.0.33", "yaml": "^2.2.2" }, "bin": { "patch-package": "index.js" } }, "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
@@ -1739,7 +1759,9 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="],
"picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
@@ -1755,7 +1777,7 @@
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.2", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA=="],
"postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
"postcss-calc": ["postcss-calc@8.2.4", "", { "dependencies": { "postcss-selector-parser": "^6.0.9", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.2.2" } }, "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q=="],
@@ -1825,7 +1847,7 @@
"react-helmet-async": ["react-helmet-async@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "invariant": "^2.2.4", "prop-types": "^15.7.2", "react-fast-compare": "^3.2.0", "shallowequal": "^1.1.0" }, "peerDependencies": { "react": "^16.6.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0" } }, "sha512-9jZ57/dAn9t3q6hneQS0wukqC2ENOBgMNVEhb/ZG9ZSxUetzVIw4iAmEU38IaVg3QGYauQPhSeUTuIUtFglWpg=="],
"react-i18next": ["react-i18next@15.4.0", "", { "dependencies": { "@babel/runtime": "^7.25.0", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.2.3", "react": ">= 16.8.0" } }, "sha512-Py6UkX3zV08RTvL6ZANRoBh9sL/ne6rQq79XlkHEdd82cZr2H9usbWpUNVadJntIZP2pu3M2rL1CN+5rQYfYFw=="],
"react-i18next": ["react-i18next@15.4.1", "", { "dependencies": { "@babel/runtime": "^7.25.0", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.2.3", "react": ">= 16.8.0" } }, "sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw=="],
"react-is": ["react-is@19.0.0", "", {}, "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g=="],
@@ -1837,7 +1859,9 @@
"react-native-circular-progress": ["react-native-circular-progress@1.4.1", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=16.0.0", "react-native": ">=0.50.0", "react-native-svg": ">=7.0.0" } }, "sha512-HEzvI0WPuWvsCgWE3Ff2HBTMgAEQB2GvTFw0KHyD/t1STAlDDRiolu0mEGhVvihKR3jJu3v3V4qzvSklY/7XzQ=="],
"react-native-compressor": ["react-native-compressor@1.10.3", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-i51DfTwfLcKorWbTXtnPOcQC4SQDuC+DqKkSl9wF9qAUmNS9PtipYZCXOvWShYFnX0mmcWw5vwEp2b2V73PaDQ=="],
"react-native-collapsible": ["react-native-collapsible@1.6.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MCOBVJWqHNjnDaGkvxX997VONmJeebh6wyJxnHEgg0L1PrlcXU1e/bo6eK+CDVFuMrCafw8Qh4DOv/C4V/+Iew=="],
"react-native-compressor": ["react-native-compressor@1.10.4", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-58gbmJ+8IvsKP8JKK1E8XW5trfQY3dNuH7S0hYw0tSRQc6l0GZ3k8TYtoUbySOc1xcQSrUo51o0Chwe8x7mUTg=="],
"react-native-country-flag": ["react-native-country-flag@2.0.2", "", {}, "sha512-5LMWxS79ZQ0Q9ntYgDYzWp794+HcQGXQmzzZNBR1AT7z5HcJHtX7rlk8RHi7RVzfp5gW6plWSZ4dKjRpu/OafQ=="],
@@ -1911,7 +1935,7 @@
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.3", "", { "dependencies": { "process": "^0.11.10", "readable-stream": "^4.7.0" } }, "sha512-In3boYjBnbGVrLuuRu/Ath/H6h1jgk30nAsk/71tCare1dTVoe1oMBGRn5LGf0n3c1BcHwwAqpraxX4AUAP5KA=="],
"readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
@@ -1957,7 +1981,9 @@
"reusify": ["reusify@1.0.4", "", {}, "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="],
"rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
"rtl-detect": ["rtl-detect@1.1.2", "", {}, "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ=="],
@@ -2023,9 +2049,9 @@
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="],
"slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"slice-ansi": ["slice-ansi@2.1.0", "", { "dependencies": { "ansi-styles": "^3.2.0", "astral-regex": "^1.0.0", "is-fullwidth-code-point": "^2.0.0" } }, "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ=="],
"slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="],
"slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="],
@@ -2049,7 +2075,7 @@
"stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="],
"stacktrace-parser": ["stacktrace-parser@0.1.10", "", { "dependencies": { "type-fest": "^0.7.1" } }, "sha512-KJP1OCML99+8fhOHxwwzyWrlUuVX5GQ0ZpJTd1DFXhdkrvg1szxfHhawXUZ3g9TkXORQd4/WG68jMlQZ2p8wlg=="],
"stacktrace-parser": ["stacktrace-parser@0.1.11", "", { "dependencies": { "type-fest": "^0.7.1" } }, "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg=="],
"statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
@@ -2059,6 +2085,8 @@
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -2075,7 +2103,7 @@
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"strnum": ["strnum@1.0.5", "", {}, "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA=="],
"strnum": ["strnum@1.1.1", "", {}, "sha512-O7aCHfYCamLCctjAiaucmE+fHf2DYHkus2OKCn4Wv03sykfFtgeECn505X6K4mPl8CRNd/qurC9guq+ynoN4pw=="],
"strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="],
@@ -2199,7 +2227,7 @@
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"uuid": ["uuid@11.0.5", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA=="],
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
"validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="],
@@ -2235,7 +2263,7 @@
"wonka": ["wonka@6.3.4", "", {}, "sha512-CjpbqNtBGNAeyNS/9W6q3kSkKE52+FjIj7AkFlLr11s/VWGUu6a2CdYSdGxocIhIVjaW/zchesBQUKPVU69Cqg=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrap-ansi": ["wrap-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -2293,15 +2321,19 @@
"@expo/cli/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"@expo/cli/form-data": ["form-data@3.0.2", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, "sha512-sJe+TQb2vIaIyO783qN6BlMYWMw3WBOHA1Ay2qxsnjuafEOQFJ2JakedOQirT6D5XPRxDvS7AHYyem9fTpb4LQ=="],
"@expo/cli/form-data": ["form-data@3.0.3", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.35" } }, "sha512-q5YBMeWy6E2Un0nMGWMgI65MAKtaylxfNJGJxpGh45YDciZB4epbWpaAfImil6CPAPTYB4sh0URQNDRIZG5F2w=="],
"@expo/cli/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"@expo/cli/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
"@expo/cli/picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="],
"@expo/cli/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"@expo/cli/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"@expo/cli/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"@expo/cli/ws": ["ws@8.18.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w=="],
"@expo/config/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
@@ -2309,8 +2341,6 @@
"@expo/config-plugins/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"@expo/config-plugins/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"@expo/devcert/sudo-prompt": ["sudo-prompt@8.2.5", "", {}, "sha512-rlBo3HU/1zAJUrkY6jNxDOC9eVYliG6nS4JA8u8KAshITd07tafMc/Br7xQwCSseXwJ2iCcHCE8SNWX3q8Z+kw=="],
@@ -2359,8 +2389,6 @@
"@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
"@jest/transform/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"@jest/transform/write-file-atomic": ["write-file-atomic@4.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" } }, "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg=="],
"@npmcli/fs/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
@@ -2393,8 +2421,6 @@
"@react-native-community/cli-server-api/pretty-format": ["pretty-format@26.6.2", "", { "dependencies": { "@jest/types": "^26.6.2", "ansi-regex": "^5.0.0", "ansi-styles": "^4.0.0", "react-is": "^17.0.1" } }, "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg=="],
"@react-native-community/cli-tools/open": ["open@6.4.0", "", { "dependencies": { "is-wsl": "^1.1.0" } }, "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg=="],
"@react-native-community/cli-tools/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"@react-native/babel-plugin-codegen/@react-native/codegen": ["@react-native/codegen@0.76.7", "", { "dependencies": { "@babel/parser": "^7.25.3", "glob": "^7.1.1", "hermes-parser": "0.23.1", "invariant": "^2.2.4", "jscodeshift": "^0.14.0", "mkdirp": "^0.5.1", "nullthrows": "^1.1.1", "yargs": "^17.6.2" }, "peerDependencies": { "@babel/preset-env": "^7.1.6" } }, "sha512-FAn585Ll65YvkSrKDyAcsdjHhhAGiMlSTUpHh0x7J5ntudUns+voYms0xMP+pEPt0XuLdjhD7zLIIlAWP407+g=="],
@@ -2409,6 +2435,8 @@
"@react-native/dev-middleware/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
"@react-native/metro-babel-transformer/@react-native/babel-preset": ["@react-native/babel-preset@0.77.0", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-arrow-functions": "^7.24.7", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-computed-properties": "^7.24.7", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-function-name": "^7.25.1", "@babel/plugin-transform-literals": "^7.25.2", "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-numeric-separator": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.25.2", "@babel/plugin-transform-react-jsx-self": "^7.24.7", "@babel/plugin-transform-react-jsx-source": "^7.24.7", "@babel/plugin-transform-regenerator": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-shorthand-properties": "^7.24.7", "@babel/plugin-transform-spread": "^7.24.7", "@babel/plugin-transform-sticky-regex": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/template": "^7.25.0", "@react-native/babel-plugin-codegen": "0.77.0", "babel-plugin-syntax-hermes-parser": "0.25.1", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.14.0" } }, "sha512-Z4yxE66OvPyQ/iAlaETI1ptRLcDm7Tk6ZLqtCPuUX3AMg+JNgIA86979T4RSk486/JrBUBH5WZe2xjj7eEHXsA=="],
"@react-navigation/core/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
@@ -2417,11 +2445,9 @@
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
"ansi-fragments/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"ansi-fragments/slice-ansi": ["slice-ansi@2.1.0", "", { "dependencies": { "ansi-styles": "^3.2.0", "astral-regex": "^1.0.0", "is-fullwidth-code-point": "^2.0.0" } }, "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ=="],
"better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
@@ -2433,10 +2459,12 @@
"chromium-edge-launcher/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
"chromium-edge-launcher/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
"cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"compressible/mime-db": ["mime-db@1.53.0", "", {}, "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg=="],
"compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -2447,10 +2475,6 @@
"default-gateway/execa": ["execa@1.0.0", "", { "dependencies": { "cross-spawn": "^6.0.0", "get-stream": "^4.0.0", "is-stream": "^1.1.0", "npm-run-path": "^2.0.0", "p-finally": "^1.0.0", "signal-exit": "^3.0.0", "strip-eof": "^1.0.0" } }, "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA=="],
"del/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
"del/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
"expo-build-properties/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
@@ -2479,8 +2503,6 @@
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"globby/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -2489,10 +2511,6 @@
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"jscodeshift/tmp": ["tmp@0.2.3", "", {}, "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w=="],
@@ -2501,10 +2519,22 @@
"lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"lint-staged/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="],
"lint-staged/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="],
"lint-staged/execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="],
"load-bmfont/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"load-bmfont/phin": ["phin@3.7.1", "", { "dependencies": { "centra": "^2.7.0" } }, "sha512-GEazpTWwTZaEQ9RhL7Nyz0WwqilbqgLahDM3D0hxWwmVDI52nXEybHqiN6/elwpkJBhcuj+WbBu+QfT0uhPGfQ=="],
"log-update/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
"log-update/slice-ansi": ["slice-ansi@7.1.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg=="],
"log-update/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"logkitty/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"make-dir/pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="],
@@ -2521,8 +2551,6 @@
"metro-file-map/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
@@ -2539,13 +2567,13 @@
"npm-package-arg/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
"ora/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"parse-bmfont-xml/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"patch-package/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"patch-package/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"password-prompt/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -2557,8 +2585,6 @@
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
"postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
@@ -2595,8 +2621,6 @@
"readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"regjsparser/jsesc": ["jsesc@3.0.2", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g=="],
@@ -2615,9 +2639,9 @@
"simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="],
"slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
"slice-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="],
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -2633,6 +2657,8 @@
"tailwindcss/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"tailwindcss/lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
"tailwindcss/postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"tar/fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="],
@@ -2647,6 +2673,8 @@
"tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="],
"terminal-link/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"test-exclude/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
@@ -2657,7 +2685,11 @@
"whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"wrap-ansi/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -2679,6 +2711,8 @@
"@expo/cli/ora/log-symbols": ["log-symbols@2.2.0", "", { "dependencies": { "chalk": "^2.0.1" } }, "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg=="],
"@expo/cli/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@expo/fingerprint/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"@expo/image-utils/fs-extra/jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="],
@@ -2719,8 +2753,6 @@
"@react-native-community/cli-server-api/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"@react-native-community/cli-tools/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
"@react-native/babel-plugin-codegen/@react-native/codegen/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"@react-native/babel-plugin-codegen/@react-native/codegen/hermes-parser": ["hermes-parser@0.23.1", "", { "dependencies": { "hermes-estree": "0.23.1" } }, "sha512-oxl5h2DkFW83hT4DAUJorpah8ou4yvmweUzLJmmr6YV2cezduCdlil1AvU/a/xSsAFo4WUcNA4GoV5Bvq6JffA=="],
@@ -2731,13 +2763,21 @@
"@react-native/community-cli-plugin/@react-native/dev-middleware/@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.77.0", "", {}, "sha512-glOvSEjCbVXw+KtfiOAmrq21FuLE1VsmBsyT7qud4KWbXP43aUEhzn70mWyFuiIdxnzVPKe2u8iWTQTdJksR1w=="],
"@react-native/community-cli-plugin/@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
"@react-native/community-cli-plugin/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"@react-native/dev-middleware/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"@react-native/metro-babel-transformer/@react-native/babel-preset/@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.77.0", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.77.0" } }, "sha512-5TYPn1k+jdDOZJU4EVb1kZ0p9TCVICXK3uplRev5Gul57oWesAaiWGZOzfRS3lonWeuR4ij8v8PFfIHOaq0vmA=="],
"chromium-edge-launcher/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
"ansi-fragments/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="],
"cli-truncate/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
@@ -2751,8 +2791,6 @@
"default-gateway/execa/npm-run-path": ["npm-run-path@2.0.2", "", { "dependencies": { "path-key": "^2.0.0" } }, "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw=="],
"del/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"expo-modules-autolinking/fs-extra/jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="],
"expo-modules-autolinking/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
@@ -2763,6 +2801,28 @@
"lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"lint-staged/execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="],
"lint-staged/execa/human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
"lint-staged/execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
"lint-staged/execa/npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
"lint-staged/execa/onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
"lint-staged/execa/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"lint-staged/execa/strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
"log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.0.0", "", { "dependencies": { "get-east-asian-width": "^1.0.0" } }, "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA=="],
"log-update/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"logkitty/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"logkitty/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
@@ -2789,9 +2849,7 @@
"parse-bmfont-xml/xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"patch-package/fs-extra/jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="],
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"password-prompt/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
"pkg-dir/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="],
@@ -2805,14 +2863,18 @@
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"tar/fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"temp/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"terminal-link/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
"test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"@babel/highlight/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
@@ -2847,7 +2909,9 @@
"@react-native/codegen/glob/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"chromium-edge-launcher/rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"default-gateway/execa/cross-spawn/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="],
@@ -2859,7 +2923,13 @@
"default-gateway/execa/npm-run-path/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="],
"del/rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"lint-staged/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"lint-staged/execa/onetime/mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
"log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"logkitty/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -2883,8 +2953,6 @@
"rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"slice-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"temp/rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
@@ -2909,12 +2977,10 @@
"@react-native/babel-plugin-codegen/@react-native/codegen/jscodeshift/recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"chromium-edge-launcher/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"ansi-fragments/slice-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"default-gateway/execa/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="],
"del/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],

View File

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

View File

@@ -1,9 +1,9 @@
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
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";
import { Text } from "./common/Text";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
@@ -20,31 +20,31 @@ export const AudioTrackSelector: React.FC<Props> = ({
if (Platform.isTV) return null;
const audioStreams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
[source]
[source],
);
const selectedAudioSteam = useMemo(
() => audioStreams?.find((x) => x.Index === selected),
[audioStreams, selected]
[audioStreams, selected],
);
const { t } = useTranslation();
return (
<View
className="flex shrink"
className='flex shrink'
style={{
minWidth: 50,
}}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">
<View className='flex flex-col' {...props}>
<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}>
<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}
</Text>
</TouchableOpacity>
@@ -52,8 +52,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}

View File

@@ -1,4 +1,4 @@
import { View, ViewProps } from "react-native";
import { View, type ViewProps } from "react-native";
import { Text } from "./common/Text";
interface Props extends ViewProps {
@@ -22,7 +22,7 @@ export const Badge: React.FC<Props> = ({
${variant === "gray" && "bg-neutral-800"}
`}
>
{iconLeft && <View className="mr-1">{iconLeft}</View>}
{iconLeft && <View className='mr-1'>{iconLeft}</View>}
<Text
className={`
text-xs

View File

@@ -1,8 +1,8 @@
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";
import { Text } from "./common/Text";
export type Bitrate = {
key: string;
@@ -40,7 +40,11 @@ export const BITRATES: Bitrate[] = [
key: "250 Kb/s",
value: 250000,
},
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
].sort(
(a, b) =>
(b.value || Number.POSITIVE_INFINITY) -
(a.value || Number.POSITIVE_INFINITY),
);
interface Props extends React.ComponentProps<typeof View> {
onChange: (value: Bitrate) => void;
@@ -58,10 +62,14 @@ export const BitrateSelector: React.FC<Props> = ({
const sorted = useMemo(() => {
if (inverted)
return BITRATES.sort(
(a, b) => (a.value || Infinity) - (b.value || Infinity)
(a, b) =>
(a.value || Number.POSITIVE_INFINITY) -
(b.value || Number.POSITIVE_INFINITY),
);
return BITRATES.sort(
(a, b) => (b.value || Infinity) - (a.value || Infinity)
(a, b) =>
(b.value || Number.POSITIVE_INFINITY) -
(a.value || Number.POSITIVE_INFINITY),
);
}, []);
@@ -69,7 +77,7 @@ export const BitrateSelector: React.FC<Props> = ({
return (
<View
className="flex shrink"
className='flex shrink'
style={{
minWidth: 60,
maxWidth: 200,
@@ -77,12 +85,12 @@ export const BitrateSelector: React.FC<Props> = ({
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">
<View className='flex flex-col' {...props}>
<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}>
<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}
</Text>
</TouchableOpacity>
@@ -90,8 +98,8 @@ export const BitrateSelector: React.FC<Props> = ({
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side="bottom"
align="center"
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}

View File

@@ -1,5 +1,6 @@
import { useHaptic } from "@/hooks/useHaptic";
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
import type React from "react";
import { type PropsWithChildren, type ReactNode, useMemo } from "react";
import { Platform, Text, TouchableOpacity, View } from "react-native";
import { Loader } from "./Loader";
@@ -63,7 +64,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
{...props}
>
{loading ? (
<View className="p-0.5">
<View className='p-0.5'>
<Loader />
</View>
) : (
@@ -72,7 +73,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
flex flex-row items-center justify-between w-full
${justify === "between" ? "justify-between" : "justify-center"}`}
>
{iconLeft ? iconLeft : <View className="w-4"></View>}
{iconLeft ? iconLeft : <View className='w-4' />}
<Text
className={`
text-white font-bold text-base
@@ -84,7 +85,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
>
{children}
</Text>
{iconRight ? iconRight : <View className="w-4"></View>}
{iconRight ? iconRight : <View className='w-4' />}
</View>
)}
</TouchableOpacity>

View File

@@ -1,6 +1,6 @@
import { Feather } from "@expo/vector-icons";
import React, { useCallback, useEffect } from "react";
import { Platform, TouchableOpacity, ViewProps } from "react-native";
import { Platform, TouchableOpacity, type ViewProps } from "react-native";
import GoogleCast, {
CastButton,
CastContext,
@@ -45,18 +45,18 @@ export function Chromecast({
const AndroidCastButton = useCallback(
() =>
Platform.OS === "android" ? (
<CastButton tintColor="transparent" />
<CastButton tintColor='transparent' />
) : (
<></>
),
[Platform.OS]
[Platform.OS],
);
if (background === "transparent")
return (
<RoundButton
size="large"
className="mr-2"
size='large'
className='mr-2'
background={false}
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
@@ -65,13 +65,13 @@ export function Chromecast({
{...props}
>
<AndroidCastButton />
<Feather name="cast" size={22} color={"white"} />
<Feather name='cast' size={22} color={"white"} />
</RoundButton>
);
return (
<RoundButton
size="large"
size='large'
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
@@ -79,7 +79,7 @@ export function Chromecast({
{...props}
>
<AndroidCastButton />
<Feather name="cast" size={22} color={"white"} />
<Feather name='cast' size={22} color={"white"} />
</RoundButton>
);
}

View File

@@ -1,12 +1,12 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtomValue } from "jotai";
import { useMemo } from "react";
import type React from "react";
import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator";
import React from "react";
import { Ionicons } from "@expo/vector-icons";
type ContinueWatchingPosterProps = {
item: BaseItemDto;
@@ -27,33 +27,39 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
* Get horizontal poster for movie and episode, with failover to primary.
*/
const url = useMemo(() => {
if (!api) return;
if (!api) {
return;
}
if (item.Type === "Episode" && useEpisodePoster) {
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}
if (item.Type === "Episode") {
if (item.ParentBackdropItemId && item.ParentThumbImageTag)
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
else
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}
if (item.Type === "Movie") {
if (item.ImageTags?.["Thumb"])
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
else
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}
if (item.Type === "Program") {
if (item.ImageTags?.["Thumb"])
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
else
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}
if (item.ImageTags?.["Thumb"])
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
else
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}, [item]);
const progress = useMemo(() => {
@@ -64,15 +70,12 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
const total = endDate.getTime() - startDate.getTime();
const elapsed = now.getTime() - startDate.getTime();
return (elapsed / total) * 100;
} else {
return item.UserData?.PlayedPercentage || 0;
}
return item.UserData?.PlayedPercentage || 0;
}, [item]);
if (!url)
return (
<View className="aspect-video border border-neutral-800 w-44"></View>
);
return <View className='aspect-video border border-neutral-800 w-44' />;
return (
<View
@@ -81,7 +84,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
${size === "small" ? "w-32" : "w-44"}
`}
>
<View className="w-full h-full flex items-center justify-center">
<View className='w-full h-full flex items-center justify-center'>
<Image
key={item.Id}
id={item.Id}
@@ -89,12 +92,12 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
contentFit='cover'
className='w-full h-full'
/>
{showPlayButton && (
<View className="absolute inset-0 flex items-center justify-center">
<Ionicons name="play-circle" size={40} color="white" />
<View className='absolute inset-0 flex items-center justify-center'>
<Ionicons name='play-circle' size={40} color='white' />
</View>
)}
</View>
@@ -102,14 +105,16 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
{progress > 0 && (
<>
<View
className={`absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full`}
></View>
className={
"absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full"
}
/>
<View
style={{
width: `${progress}%`,
}}
className={`absolute bottom-0 left-0 h-1 bg-purple-600 w-full`}
></View>
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
/>
</>
)}
</View>

View File

@@ -1,8 +1,8 @@
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
//import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
@@ -10,29 +10,30 @@ import download from "@/utils/profiles/download";
import Ionicons from "@expo/vector-icons/Ionicons";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import {
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Href, router, useFocusEffect } from "expo-router";
import { type Href, router, useFocusEffect } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Alert, View, ViewProps } from "react-native";
import type React from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, Platform, View, type ViewProps } from "react-native";
import { toast } from "sonner-native";
import { AudioTrackSelector } from "./AudioTrackSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector";
import { type Bitrate, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button";
import { Text } from "./common/Text";
import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle";
import { RoundButton } from "./RoundButton";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
import { t } from "i18next";
import { Text } from "./common/Text";
interface DownloadProps extends ViewProps {
items: BaseItemDto[];
@@ -58,7 +59,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [settings] = useSettings();
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
const { startRemuxing } = useRemuxHlsToMp4();
//const { startRemuxing } = useRemuxHlsToMp4();
const [selectedMediaSource, setSelectedMediaSource] = useState<
MediaSourceInfo | undefined | null
@@ -66,18 +67,20 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(settings?.defaultBitrate ?? {
key: "Max",
value: undefined,
});
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(
settings?.defaultBitrate ?? {
key: "Max",
value: undefined,
},
);
const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading,
[user]
[user],
);
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === DownloadMethod.Optimized,
[settings]
[settings],
);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -97,7 +100,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const itemsNotDownloaded = useMemo(
() =>
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
[items, downloadedFiles]
[items, downloadedFiles],
);
const allItemsDownloaded = useMemo(() => {
@@ -106,11 +109,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}, [items, itemsNotDownloaded]);
const itemsProcesses = useMemo(
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
[processes, itemIds]
[processes, itemIds],
);
const progress = useMemo(() => {
if (itemIds.length == 1)
if (itemIds.length === 1)
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
return (
((itemIds.length -
@@ -123,7 +126,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const itemsQueued = useMemo(() => {
return (
itemsNotDownloaded.length > 0 &&
itemsNotDownloaded.every((p) => queue.some((q) => p.Id == q.item.Id))
itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id))
);
}, [queue, itemsNotDownloaded]);
const navigateToDownloads = () => router.push("/downloads");
@@ -138,7 +141,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
params: {
episodeSeasonIndex: firstItem.ParentIndexNumber,
},
} as Href)
} as Href),
);
};
@@ -158,11 +161,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
id: item.Id!,
execute: async () => await initiateDownload(item),
item,
}))
})),
);
}
} else {
toast.error(t("home.downloads.toasts.you_are_not_allowed_to_download_files"));
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
);
}
}, [
queue,
@@ -185,7 +190,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
) {
throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item"
"DownloadItem ~ initiateDownload: No api or user or item",
);
}
let mediaSource = selectedMediaSource;
@@ -216,7 +221,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
if (!res) {
Alert.alert(
t("home.downloads.something_went_wrong"),
t("home.downloads.could_not_get_stream_url_from_jellyfin")
t("home.downloads.could_not_get_stream_url_from_jellyfin"),
);
continue;
}
@@ -225,12 +230,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
if (!url || !source) throw new Error("No url");
saveDownloadItemInfoToDiskTmp(item, source, url);
if (usingOptimizedServer) {
saveDownloadItemInfoToDiskTmp(item, source, url);
await startBackgroundDownload(url, item, source);
} else {
await startRemuxing(item, url, source);
//await startRemuxing(item, url, source);
}
}
},
@@ -245,8 +249,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
maxBitrate,
usingOptimizedServer,
startBackgroundDownload,
startRemuxing,
]
//startRemuxing,
],
);
const renderBackdrop = useCallback(
@@ -257,7 +261,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
appearsOnIndex={0}
/>
),
[]
[],
);
useFocusEffect(
useCallback(() => {
@@ -270,31 +274,35 @@ export const DownloadItems: React.FC<DownloadProps> = ({
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [items, itemsNotDownloaded, settings])
}, [items, itemsNotDownloaded, settings]),
);
const renderButtonContent = () => {
if (processes && itemsProcesses.length > 0) {
if (processes.length > 0 && itemsProcesses.length > 0) {
return progress === 0 ? (
<Loader />
) : (
<View className="-rotate-45">
<View className='-rotate-45'>
<ProgressCircle
size={24}
fill={progress}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
tintColor='#9334E9'
backgroundColor='#bdc3c7'
/>
</View>
);
} else if (itemsQueued) {
return <Ionicons name="hourglass" size={24} color="white" />;
} else if (allItemsDownloaded) {
return <DownloadedIconComponent />;
} else {
return <MissingDownloadIconComponent />;
}
if (itemsQueued) {
return <Ionicons name='hourglass' size={24} color='white' />;
}
if (allItemsDownloaded) {
return <DownloadedIconComponent />;
}
return <MissingDownloadIconComponent />;
};
const onButtonPress = () => {
@@ -327,16 +335,19 @@ export const DownloadItems: React.FC<DownloadProps> = ({
backdropComponent={renderBackdrop}
>
<BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View>
<Text className="font-bold text-2xl text-neutral-100">
<Text className='font-bold text-2xl text-neutral-100'>
{title}
</Text>
<Text className="text-neutral-300">
{subtitle || t("item_card.download.download_x_item", {item_count: itemsNotDownloaded.length})}
<Text className='text-neutral-300'>
{subtitle ||
t("item_card.download.download_x_item", {
item_count: itemsNotDownloaded.length,
})}
</Text>
</View>
<View className="flex flex-col space-y-2 w-full items-start">
<View className='flex flex-col space-y-2 w-full items-start'>
<BitrateSelector
inverted
onChange={setMaxBitrate}
@@ -350,7 +361,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
selected={selectedMediaSource}
/>
{selectedMediaSource && (
<View className="flex flex-col space-y-2">
<View className='flex flex-col space-y-2'>
<AudioTrackSelector
source={selectedMediaSource}
onChange={setSelectedAudioStream}
@@ -367,14 +378,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
)}
</View>
<Button
className="mt-auto"
className='mt-auto'
onPress={acceptDownloadOptions}
color="purple"
color='purple'
>
{t("item_card.download.download_button")}
</Button>
<View className="opacity-70 text-center w-full flex items-center">
<Text className="text-xs">
<View className='opacity-70 text-center w-full flex items-center'>
<Text className='text-xs'>
{usingOptimizedServer
? t("item_card.download.using_optimized_server")
: t("item_card.download.using_default_method")}
@@ -391,19 +402,23 @@ export const DownloadSingleItem: React.FC<{
size?: "default" | "large";
item: BaseItemDto;
}> = ({ item, size = "default" }) => {
if (Platform.isTV) return;
return (
<DownloadItems
size={size}
title={item.Type == "Episode"
? t("item_card.download.download_episode")
: t("item_card.download.download_movie")}
title={
item.Type === "Episode"
? t("item_card.download.download_episode")
: t("item_card.download.download_movie")
}
subtitle={item.Name!}
items={[item]}
MissingDownloadIconComponent={() => (
<Ionicons name="cloud-download-outline" size={24} color="white" />
<Ionicons name='cloud-download-outline' size={24} color='white' />
)}
DownloadedIconComponent={() => (
<Ionicons name="cloud-download" size={26} color="#9333ea" />
<Ionicons name='cloud-download' size={26} color='#9333ea' />
)}
/>
);

View File

@@ -1,44 +1,57 @@
// GenreTags.tsx
import React from "react";
import {StyleProp, TextStyle, View, ViewProps} from "react-native";
import type React from "react";
import {
type StyleProp,
type TextStyle,
View,
type ViewProps,
} from "react-native";
import { Text } from "./common/Text";
interface TagProps {
tags?: string[];
textClass?: ViewProps["className"]
textClass?: ViewProps["className"];
}
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"], textStyle?: StyleProp<TextStyle>} & ViewProps> = ({
text,
textClass,
textStyle,
...props
}) => {
export const Tag: React.FC<
{
text: string;
textClass?: ViewProps["className"];
textStyle?: StyleProp<TextStyle>;
} & ViewProps
> = ({ text, textClass, textStyle, ...props }) => {
return (
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
<Text className={textClass} style={textStyle}>{text}</Text>
<View className='bg-neutral-800 rounded-full px-2 py-1' {...props}>
<Text className={textClass} style={textStyle}>
{text}
</Text>
</View>
);
};
export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-xs", ...props }) => {
export const Tags: React.FC<
TagProps & { tagProps?: ViewProps } & ViewProps
> = ({ tags, textClass = "text-xs", tagProps, ...props }) => {
if (!tags || tags.length === 0) return null;
return (
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
<View
className={`flex flex-row flex-wrap gap-1 ${props.className}`}
{...props}
>
{tags.map((tag, idx) => (
<View key={idx}>
<Tag key={idx} textClass={textClass} text={tag}/>
<Tag key={idx} textClass={textClass} text={tag} {...tagProps} />
</View>
))}
</View>
);
};
export const GenreTags: React.FC<{ genres?: string[]}> = ({ genres }) => {
export const GenreTags: React.FC<{ genres?: string[] }> = ({ genres }) => {
return (
<View className="mt-2">
<Tags tags={genres}/>
<View className='mt-2'>
<Tags tags={genres} />
</View>
);
};

View File

@@ -1,8 +1,8 @@
import React from "react";
import { tc } from "@/utils/textTools";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import { View } from "react-native";
import { Text } from "./common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
type ItemCardProps = {
item: BaseItemDto;
@@ -10,13 +10,13 @@ type ItemCardProps = {
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
return (
<View className="mt-2 flex flex-col">
<View className='mt-2 flex flex-col'>
{item.Type === "Episode" ? (
<>
<Text numberOfLines={1} className="">
<Text numberOfLines={1} ellipsizeMode='tail' className=''>
{item.Name}
</Text>
<Text numberOfLines={1} className="text-xs opacity-50">
<Text numberOfLines={1} className='text-xs opacity-50'>
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
{" - "}
{item.SeriesName}
@@ -24,8 +24,10 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
</>
) : (
<>
<Text numberOfLines={2}>{item.Name}</Text>
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
<Text numberOfLines={1} ellipsizeMode='tail'>
{item.Name}
</Text>
<Text className='text-xs opacity-50'>{item.ProductionYear}</Text>
</>
)}
</View>

View File

@@ -1,5 +1,5 @@
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
@@ -15,27 +15,26 @@ import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarous
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import {
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useNavigation } from "expo-router";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useState } from "react";
import { Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
import { AddToFavorites } from "./AddToFavorites";
import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { AddToFavorites } from "./AddToFavorites";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
export type SelectedOptions = {
bitrate: Bitrate;
@@ -87,17 +86,19 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
navigation.setOptions({
headerRight: () =>
item && (
<View className="flex flex-row items-center space-x-2">
<View className='flex flex-row items-center space-x-2'>
<Chromecast.Chromecast
background="blur"
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 className='flex flex-row items-center space-x-2'>
{!Platform.isTV && (
<DownloadSingleItem item={item} size='large' />
)}
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
</View>
)}
</View>
@@ -118,42 +119,11 @@ 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 (
<View
className="flex-1 relative"
className='flex-1 relative'
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
@@ -177,41 +147,38 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
onLoad={() => setLoadingLogo(false)}
onError={() => setLoadingLogo(false)}
/>
) : null}
</>
logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
onLoad={() => setLoadingLogo(false)}
onError={() => setLoadingLogo(false)}
/>
) : null
}
>
<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" />
<View className='flex flex-col bg-transparent shrink'>
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-4' />
{item.Type !== "Program" && !Platform.isTV && (
<View className="flex flex-row items-center justify-start w-full h-16">
<View className='flex flex-row items-center justify-start w-full h-16'>
<BitrateSelector
className="mr-1"
className='mr-1'
onChange={(val) =>
setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val }
(prev) => prev && { ...prev, bitrate: val },
)
}
selected={selectedOptions.bitrate}
/>
<MediaSourceSelector
className="mr-1"
className='mr-1'
item={item}
onChange={(val) =>
setSelectedOptions(
@@ -219,13 +186,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
prev && {
...prev,
mediaSource: val,
}
},
)
}
selected={selectedOptions.mediaSource}
/>
<AudioTrackSelector
className="mr-1"
className='mr-1'
source={selectedOptions.mediaSource}
onChange={(val) => {
setSelectedOptions(
@@ -233,13 +200,12 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
prev && {
...prev,
audioIndex: val,
}
},
);
}}
selected={selectedOptions.audioIndex}
/>
<SubtitleTrackSelector
isTranscoding={isTranscoding}
source={selectedOptions.mediaSource}
onChange={(val) =>
setSelectedOptions(
@@ -247,7 +213,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
prev && {
...prev,
subtitleIndex: val,
}
},
)
}
selected={selectedOptions.subtitleIndex}
@@ -255,13 +221,11 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View>
)}
{/* {!Platform.isTV && ( */}
<PlayButton
className="grow"
className='grow'
selectedOptions={selectedOptions}
item={item}
/>
{/* )} */}
</View>
{item.Type === "Episode" && (
@@ -269,24 +233,24 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
)}
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
<OverviewText text={item.Overview} className="px-4 mb-4" />
<OverviewText text={item.Overview} className='px-4 mb-4' />
{item.Type !== "Program" && (
<>
{item.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" />
<CurrentSeries item={item} className='mb-4' />
)}
<CastAndCrew item={item} className="mb-4" loading={loading} />
<CastAndCrew item={item} className='mb-4' loading={loading} />
{item.People && item.People.length > 0 && (
<View className="mb-4">
<View className='mb-4'>
{item.People.slice(0, 3).map((person, idx) => (
<MoreMoviesWithActor
currentItem={item}
key={idx}
actorId={person.Id!}
className="mb-4"
className='mb-4'
/>
))}
</View>
@@ -299,5 +263,5 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</ParallaxScrollView>
</View>
);
}
},
);

View File

@@ -1,9 +1,9 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import { View, ViewProps } from "react-native";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import { View, type ViewProps } from "react-native";
import { GenreTags } from "./GenreTags";
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
import { Ratings } from "./Ratings";
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
import { ItemActions } from "./series/SeriesActions";
@@ -15,21 +15,21 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
if (!item)
return (
<View
className="flex flex-col space-y-1.5 w-full items-start h-32"
className='flex flex-col space-y-1.5 w-full items-start h-32'
{...props}
>
<View className="w-1/3 h-6 bg-neutral-900 rounded" />
<View className="w-2/3 h-8 bg-neutral-900 rounded" />
<View className="w-2/3 h-4 bg-neutral-900 rounded" />
<View className="w-1/4 h-4 bg-neutral-900 rounded" />
<View className='w-1/3 h-6 bg-neutral-900 rounded' />
<View className='w-2/3 h-8 bg-neutral-900 rounded' />
<View className='w-2/3 h-4 bg-neutral-900 rounded' />
<View className='w-1/4 h-4 bg-neutral-900 rounded' />
</View>
);
return (
<View className="flex flex-col" {...props}>
<View className="flex flex-col" {...props}>
<View className="flex flex-row items-center justify-between">
<Ratings item={item} className="mb-2" />
<View className='flex flex-col' {...props}>
<View className='flex flex-col' {...props}>
<View className='flex flex-row items-center justify-between'>
<Ratings item={item} className='mb-2' />
<ItemActions item={item} />
</View>
{item.Type === "Episode" && (

View File

@@ -1,21 +1,23 @@
import { formatBitrate } from "@/utils/bitrate";
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type {
MediaSourceInfo,
type MediaStream,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client";
import React, { useMemo, useRef } from "react";
import type React from "react";
import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native";
import { Badge } from "./Badge";
import { Text } from "./common/Text";
import {
BottomSheetModal,
BottomSheetBackdropProps,
BottomSheetBackdrop,
BottomSheetView,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { Button } from "./Button";
import { useTranslation } from "react-i18next";
import { Text } from "./common/Text";
interface Props {
source?: MediaSourceInfo;
@@ -26,13 +28,13 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
const { t } = useTranslation();
return (
<View className="px-4 mt-2 mb-4">
<Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
<View className='px-4 mt-2 mb-4'>
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
<View className="flex flex-row space-x-2">
<View className='flex flex-row space-x-2'>
<VideoStreamInfo source={source} />
</View>
<Text className="text-purple-600">{t("item_card.more_details")}</Text>
<Text className='text-purple-600'>{t("item_card.more_details")}</Text>
</TouchableOpacity>
<BottomSheetModal
ref={bottomSheetModalRef}
@@ -52,31 +54,37 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
)}
>
<BottomSheetScrollView>
<View className="flex flex-col space-y-2 p-4 mb-4">
<View className="">
<Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
<View className="flex flex-row space-x-2">
<View className='flex flex-col space-y-2 p-4 mb-4'>
<View className=''>
<Text className='text-lg font-bold mb-4'>
{t("item_card.video")}
</Text>
<View className='flex flex-row space-x-2'>
<VideoStreamInfo source={source} />
</View>
</View>
<View className="">
<Text className="text-lg font-bold mb-2">{t("item_card.audio")}</Text>
<View className=''>
<Text className='text-lg font-bold mb-2'>
{t("item_card.audio")}
</Text>
<AudioStreamInfo
audioStreams={
source?.MediaStreams?.filter(
(stream) => stream.Type === "Audio"
(stream) => stream.Type === "Audio",
) || []
}
/>
</View>
<View className="">
<Text className="text-lg font-bold mb-2">{t("item_card.subtitles")}</Text>
<View className=''>
<Text className='text-lg font-bold mb-2'>
{t("item_card.subtitles")}
</Text>
<SubtitleStreamInfo
subtitleStreams={
source?.MediaStreams?.filter(
(stream) => stream.Type === "Subtitle"
(stream) => stream.Type === "Subtitle",
) || []
}
/>
@@ -94,25 +102,25 @@ const SubtitleStreamInfo = ({
subtitleStreams: MediaStream[];
}) => {
return (
<View className="flex flex-col">
<View className='flex flex-col'>
{subtitleStreams.map((stream, index) => (
<View key={stream.Index} className="flex flex-col">
<Text className="text-xs mb-3 text-neutral-400">
<View key={stream.Index} className='flex flex-col'>
<Text className='text-xs mb-3 text-neutral-400'>
{stream.DisplayTitle}
</Text>
<View className="flex flex-row flex-wrap gap-2">
<View className='flex flex-row flex-wrap gap-2'>
<Badge
variant="gray"
variant='gray'
iconLeft={
<Ionicons name="language-outline" size={16} color="white" />
<Ionicons name='language-outline' size={16} color='white' />
}
text={stream.Language}
/>
<Badge
variant="gray"
variant='gray'
text={stream.Codec}
iconLeft={
<Ionicons name="layers-outline" size={16} color="white" />
<Ionicons name='layers-outline' size={16} color='white' />
}
/>
</View>
@@ -124,40 +132,40 @@ const SubtitleStreamInfo = ({
const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
return (
<View className="flex flex-col">
<View className='flex flex-col'>
{audioStreams.map((audioStreams, index) => (
<View key={index} className="flex flex-col">
<Text className="mb-3 text-neutral-400 text-xs">
<View key={index} className='flex flex-col'>
<Text className='mb-3 text-neutral-400 text-xs'>
{audioStreams.DisplayTitle}
</Text>
<View className="flex-row flex-wrap gap-2">
<View className='flex-row flex-wrap gap-2'>
<Badge
variant="gray"
variant='gray'
iconLeft={
<Ionicons name="language-outline" size={16} color="white" />
<Ionicons name='language-outline' size={16} color='white' />
}
text={audioStreams.Language}
/>
<Badge
variant="gray"
variant='gray'
iconLeft={
<Ionicons
name="musical-notes-outline"
name='musical-notes-outline'
size={16}
color="white"
color='white'
/>
}
text={audioStreams.Codec}
/>
<Badge
variant="gray"
iconLeft={<Ionicons name="mic-outline" size={16} color="white" />}
variant='gray'
iconLeft={<Ionicons name='mic-outline' size={16} color='white' />}
text={audioStreams.ChannelLayout}
/>
<Badge
variant="gray"
variant='gray'
iconLeft={
<Ionicons name="speedometer-outline" size={16} color="white" />
<Ionicons name='speedometer-outline' size={16} color='white' />
}
text={formatBitrate(audioStreams.BitRate)}
/>
@@ -173,48 +181,48 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
const videoStream = useMemo(() => {
return source.MediaStreams?.find(
(stream) => stream.Type === "Video"
(stream) => stream.Type === "Video",
) as MediaStream;
}, [source.MediaStreams]);
if (!videoStream) return null;
return (
<View className="flex-row flex-wrap gap-2">
<View className='flex-row flex-wrap gap-2'>
<Badge
variant="gray"
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
variant='gray'
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
text={formatFileSize(source.Size)}
/>
<Badge
variant="gray"
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
variant='gray'
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
text={`${videoStream.Width}x${videoStream.Height}`}
/>
<Badge
variant="gray"
variant='gray'
iconLeft={
<Ionicons name="color-palette-outline" size={16} color="white" />
<Ionicons name='color-palette-outline' size={16} color='white' />
}
text={videoStream.VideoRange}
/>
<Badge
variant="gray"
variant='gray'
iconLeft={
<Ionicons name="code-working-outline" size={16} color="white" />
<Ionicons name='code-working-outline' size={16} color='white' />
}
text={videoStream.Codec}
/>
<Badge
variant="gray"
variant='gray'
iconLeft={
<Ionicons name="speedometer-outline" size={16} color="white" />
<Ionicons name='speedometer-outline' size={16} color='white' />
}
text={formatBitrate(videoStream.BitRate)}
/>
<Badge
variant="gray"
iconLeft={<Ionicons name="play-outline" size={16} color="white" />}
variant='gray'
iconLeft={<Ionicons name='play-outline' size={16} color='white' />}
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
/>
</View>
@@ -226,15 +234,8 @@ const formatFileSize = (bytes?: number | null) => {
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 Byte";
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
};
const formatBitrate = (bitrate?: number | null) => {
if (!bitrate) return "N/A";
const sizes = ["bps", "Kbps", "Mbps", "Gbps", "Tbps"];
if (bitrate === 0) return "0 bps";
const i = parseInt(Math.floor(Math.log(bitrate) / Math.log(1000)).toString());
return Math.round((bitrate / Math.pow(1000, i)) * 100) / 100 + " " + sizes[i];
const i = Number.parseInt(
Math.floor(Math.log(bytes) / Math.log(1024)).toString(),
);
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
};

View File

@@ -1,10 +1,10 @@
import React from "react";
import { View, Text, TouchableOpacity } from "react-native";
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
import type React from "react";
import { useTranslation } from "react-i18next";
import { Text, TouchableOpacity, View } from "react-native";
import { Button } from "./Button";
import { ListGroup } from "./list/ListGroup";
import { ListItem } from "./list/ListItem";
import { useTranslation } from "react-i18next";
interface Props {
onServerSelect?: (server: { address: string; serverName?: string }) => void;
@@ -15,15 +15,17 @@ const JellyfinServerDiscovery: React.FC<Props> = ({ onServerSelect }) => {
const { t } = useTranslation();
return (
<View className="mt-2">
<Button onPress={startDiscovery} color="black">
<Text className="text-white text-center">
{isSearching ? t("server.searching") : t("server.search_for_local_servers")}
<View className='mt-2'>
<Button onPress={startDiscovery} color='black'>
<Text className='text-white text-center'>
{isSearching
? t("server.searching")
: t("server.search_for_local_servers")}
</Text>
</Button>
{servers.length ? (
<ListGroup title={t("server.servers")} className="mt-4">
<ListGroup title={t("server.servers")} className='mt-4'>
{servers.map((server) => (
<ListItem
key={server.address}

View File

@@ -1,6 +1,6 @@
import {
ActivityIndicator,
ActivityIndicatorProps,
type ActivityIndicatorProps,
Platform,
View,
} from "react-native";

View File

@@ -1,12 +1,12 @@
import {
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
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";
import { Text } from "./common/Text";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
@@ -24,9 +24,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
const selectedName = useMemo(
() =>
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
(x) => x.Type === "Video"
(x) => x.Type === "Video",
)?.DisplayTitle || "",
[item, selected]
[item, selected],
);
const { t } = useTranslation();
@@ -54,26 +54,26 @@ export const MediaSourceSelector: React.FC<Props> = ({
return (
<View
className="flex shrink"
className='flex shrink'
style={{
minWidth: 50,
}}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>
{t("item_card.video")}
</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'>
<Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}

View File

@@ -1,17 +1,17 @@
import React from "react";
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { ItemCardText } from "@/components/ItemCardText";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import MoviePoster from "@/components/posters/MoviePoster";
import { ItemCardText } from "@/components/ItemCardText";
import { useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import type React from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
interface Props extends ViewProps {
actorId: string;
@@ -63,9 +63,8 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
const x = acc.find((item) => item.Id === current.Id);
if (!x) {
return acc.concat([current]);
} else {
return acc;
}
return acc;
}, [] as BaseItemDto[]) || [];
return uniqueItems;
@@ -77,8 +76,8 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
return (
<View {...props}>
<Text className="text-lg font-bold mb-2 px-4">
{t("item_card.more_with", {name: actor?.Name})}
<Text className='text-lg font-bold mb-2 px-4'>
{t("item_card.more_with", { name: actor?.Name })}
</Text>
<HorizontalScroll
data={items}
@@ -88,7 +87,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
<TouchableItemRouter
key={idx}
item={item}
className="flex flex-col w-28"
className='flex flex-col w-28'
>
<View>
<MoviePoster item={item} />

View File

@@ -1,8 +1,8 @@
import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { tc } from "@/utils/textTools";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
interface Props extends ViewProps {
text?: string | null;
@@ -20,20 +20,22 @@ export const OverviewText: React.FC<Props> = ({
if (!text) return null;
return (
<View className="flex flex-col" {...props}>
<Text className="text-lg font-bold mb-2">{t("item_card.overview")}</Text>
<View className='flex flex-col' {...props}>
<Text className='text-lg font-bold mb-2'>{t("item_card.overview")}</Text>
<TouchableOpacity
onPress={() =>
setLimit((prev) =>
prev === characterLimit ? text.length : characterLimit
prev === characterLimit ? text.length : characterLimit,
)
}
>
<View>
<Text>{tc(text, limit)}</Text>
{text.length > characterLimit && (
<Text className="text-purple-600 mt-1">
{limit === characterLimit ? t("item_card.show_more") : t("item_card.show_less")}
<Text className='text-purple-600 mt-1'>
{limit === characterLimit
? t("item_card.show_more")
: t("item_card.show_less")}
</Text>
)}
</View>

View File

@@ -1,6 +1,11 @@
import { LinearGradient } from "expo-linear-gradient";
import { type PropsWithChildren, type ReactElement } from "react";
import {NativeScrollEvent, NativeSyntheticEvent, View, ViewProps} from "react-native";
import type { PropsWithChildren, ReactElement } from "react";
import {
type NativeScrollEvent,
NativeSyntheticEvent,
View,
type ViewProps,
} from "react-native";
import Animated, {
interpolate,
useAnimatedRef,
@@ -35,36 +40,40 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
translateY: interpolate(
scrollOffset.value,
[-headerHeight, 0, headerHeight],
[-headerHeight / 2, 0, headerHeight * 0.75]
[-headerHeight / 2, 0, headerHeight * 0.75],
),
},
{
scale: interpolate(
scrollOffset.value,
[-headerHeight, 0, headerHeight],
[2, 1, 1]
[2, 1, 1],
),
},
],
};
});
function isCloseToBottom({layoutMeasurement, contentOffset, contentSize}: NativeScrollEvent) {
return layoutMeasurement.height + contentOffset.y >= contentSize.height - 20;
function isCloseToBottom({
layoutMeasurement,
contentOffset,
contentSize,
}: NativeScrollEvent) {
return (
layoutMeasurement.height + contentOffset.y >= contentSize.height - 20
);
}
return (
<View className="flex-1" {...props}>
<View className='flex-1' {...props}>
<Animated.ScrollView
style={{
position: "relative",
}}
ref={scrollRef}
scrollEventThrottle={16}
onScroll={e => {
if (isCloseToBottom(e.nativeEvent))
onEndReached?.()
onScroll={(e) => {
if (isCloseToBottom(e.nativeEvent)) onEndReached?.();
}}
>
{logo && (
@@ -73,7 +82,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
top: headerHeight - 200,
height: 130,
}}
className="absolute left-0 w-full z-40 px-4 flex justify-center items-center"
className='absolute left-0 w-full z-40 px-4 flex justify-center items-center'
>
{logo}
</View>
@@ -95,7 +104,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
style={{
top: -50,
}}
className="relative flex-1 bg-transparent pb-24"
className='relative flex-1 bg-transparent pb-24'
>
<LinearGradient
// Background Linear Gradient

View File

@@ -1,6 +1,6 @@
import { BlurView } from "expo-blur";
import React from "react";
import { Platform, View, ViewProps } from "react-native";
import type React from "react";
import { Platform, View, type ViewProps } from "react-native";
interface Props extends ViewProps {
blurAmount?: number;
blurType?: "light" | "dark" | "xlight";

View File

@@ -1,18 +1,22 @@
import { Platform } from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import ios from "@/utils/profiles/ios";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Platform, Pressable } from "react-native";
import { Alert, TouchableOpacity, View } from "react-native";
import CastContext, {
CastButton,
@@ -30,13 +34,8 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent";
const chromecastProfile = !Platform.isTV
? require("@/utils/profiles/chromecast")
: null;
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
import type { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
@@ -72,17 +71,14 @@ export const PlayButton: React.FC<Props> = ({
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => {
if (!bitrateValue) {
router.push(`/player/direct-player?${q}`);
return;
}
router.push(`/player/transcoding-player?${q}`);
(q: string) => {
router.push(`/player/direct-player?${q}`);
},
[router]
[router],
);
const onPress = useCallback(async () => {
console.log("onPress");
if (!item) return;
lightHapticFeedback();
@@ -98,7 +94,7 @@ export const PlayButton: React.FC<Props> = ({
const queryString = queryParams.toString();
if (!client) {
goToPlayer(queryString, selectedOptions.bitrate?.value);
goToPlayer(queryString);
return;
}
@@ -117,16 +113,19 @@ export const PlayButton: React.FC<Props> = ({
switch (selectedIndex) {
case 0:
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:
await CastContext.getPlayServicesState().then(async (state) => {
if (state && state !== PlayServicesState.SUCCESS) {
CastContext.showPlayServicesErrorDialog(state);
} else {
// Check if user wants H265 for Chromecast
const enableH265 = settings.enableH265ForChromecast;
// Get a new URL with the Chromecast device profile
try {
const data = await getStreamUrl({
api,
item,
deviceProfile: chromecastProfile,
deviceProfile: enableH265 ? chromecasth265 : chromecast,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: selectedOptions.audioIndex,
@@ -135,11 +134,13 @@ export const PlayButton: React.FC<Props> = ({
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
console.log("URL: ", data?.url, enableH265);
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast")
t("player.could_not_create_stream_for_chromecast"),
);
return;
}
@@ -169,36 +170,36 @@ export const PlayButton: React.FC<Props> = ({
],
}
: 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,
})!,
},
],
},
? {
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,
})
@@ -209,17 +210,19 @@ export const PlayButton: React.FC<Props> = ({
}
CastContext.showExpandedControls();
});
} catch (e) {
console.log(e);
}
});
}
}
});
break;
case 1:
goToPlayer(queryString, selectedOptions.bitrate?.value);
goToPlayer(queryString);
break;
case cancelButtonIndex:
break;
}
}
},
);
}, [
item,
@@ -236,11 +239,11 @@ export const PlayButton: React.FC<Props> = ({
const derivedTargetWidth = useDerivedValue(() => {
if (!item || !item.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData && userData.PlaybackPositionTicks) {
if (userData?.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0
? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH
MIN_PLAYBACK_WIDTH,
)
: 0;
}
@@ -257,7 +260,7 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
});
},
[item]
[item],
);
useAnimatedReaction(
@@ -270,7 +273,7 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
[colorAtom]
[colorAtom],
);
useEffect(() => {
@@ -291,7 +294,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.primary, endColor.value.primary]
[startColor.value.primary, endColor.value.primary],
),
}));
@@ -299,7 +302,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.primary, endColor.value.primary]
[startColor.value.primary, endColor.value.primary],
),
}));
@@ -307,7 +310,7 @@ export const PlayButton: React.FC<Props> = ({
width: `${interpolate(
widthProgress.value,
[0, 1],
[startWidth.value, targetWidth.value]
[startWidth.value, targetWidth.value],
)}%`,
}));
@@ -315,7 +318,7 @@ export const PlayButton: React.FC<Props> = ({
color: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.text, endColor.value.text]
[startColor.value.text, endColor.value.text],
),
}));
/**
@@ -323,75 +326,62 @@ export const PlayButton: React.FC<Props> = ({
*/
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>
<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={[animatedAverageStyle, { opacity: 0.5 }]}
className="absolute w-full h-full top-0 left-0 rounded-xl"
style={[
animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/>
<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>
</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>
{client && (
<Animated.Text style={animatedTextStyle}>
<Ionicons name="play-circle" size={24} />
<Feather name='cast' size={22} />
<CastButton tintColor='transparent' />
</Animated.Text>
{client && (
<Animated.Text style={animatedTextStyle}>
<Feather name="cast" size={22} />
<CastButton tintColor="transparent" />
</Animated.Text>
)}
{!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name="vlc"
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
)}
{!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name='vlc'
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</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>
</TouchableOpacity>
);
};

View File

@@ -1,14 +1,16 @@
import { Platform } from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
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 type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { Alert, TouchableOpacity, View } from "react-native";
import Animated, {
Easing,
@@ -20,10 +22,8 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent";
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
import type { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
@@ -57,17 +57,14 @@ export const PlayButton: React.FC<Props> = ({
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => {
if (!bitrateValue) {
router.push(`/player/direct-player?${q}`);
return;
}
router.push(`/player/transcoding-player?${q}`);
(q: string) => {
router.push(`/player/direct-player?${q}`);
},
[router]
[router],
);
const onPress = useCallback(async () => {
const onPress = () => {
console.log("onpress");
if (!item) return;
lightHapticFeedback();
@@ -81,26 +78,18 @@ export const PlayButton: React.FC<Props> = ({
});
const queryString = queryParams.toString();
goToPlayer(queryString, selectedOptions.bitrate?.value);
goToPlayer(queryString);
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) {
if (userData?.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0
? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH
MIN_PLAYBACK_WIDTH,
)
: 0;
}
@@ -117,7 +106,7 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
});
},
[item]
[item],
);
useAnimatedReaction(
@@ -130,7 +119,7 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
[colorAtom]
[colorAtom],
);
useEffect(() => {
@@ -151,7 +140,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.primary, endColor.value.primary]
[startColor.value.primary, endColor.value.primary],
),
}));
@@ -159,7 +148,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.primary, endColor.value.primary]
[startColor.value.primary, endColor.value.primary],
),
}));
@@ -167,7 +156,7 @@ export const PlayButton: React.FC<Props> = ({
width: `${interpolate(
widthProgress.value,
[0, 1],
[startWidth.value, targetWidth.value]
[startWidth.value, targetWidth.value],
)}%`,
}));
@@ -175,7 +164,7 @@ export const PlayButton: React.FC<Props> = ({
color: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.text, endColor.value.text]
[startColor.value.text, endColor.value.text],
),
}));
/**
@@ -183,69 +172,55 @@ export const PlayButton: React.FC<Props> = ({
*/
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>
<TouchableOpacity
accessibilityLabel='Play button'
accessibilityHint='Tap to play the media'
onPress={onPress}
className={"relative"}
{...props}
>
<View className='absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden'>
<Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]}
className="absolute w-full h-full top-0 left-0 rounded-xl"
style={[
animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/>
<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>
</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}>
<Ionicons name="play-circle" size={24} />
<MaterialCommunityIcons
name='vlc'
size={18}
color={animatedTextStyle.color}
/>
</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>
</TouchableOpacity>
);
};

View File

@@ -1,8 +1,8 @@
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query";
import React from "react";
import { View, ViewProps } from "react-native";
import type React from "react";
import { View, type ViewProps } from "react-native";
import { RoundButton } from "./RoundButton";
interface Props extends ViewProps {
@@ -18,7 +18,7 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
queryClient.invalidateQueries({
queryKey: ["item", item.Id],
});
})
});
queryClient.invalidateQueries({
queryKey: ["resumeItems"],
});
@@ -51,9 +51,9 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
<RoundButton
fillColor={allPlayed ? "primary" : undefined}
icon={allPlayed ? "checkmark" : "checkmark"}
onPress={async () => {
onPress={async () => {
console.log(allPlayed);
await markAsPlayedStatus(!allPlayed)
await markAsPlayedStatus(!allPlayed);
}}
size={props.size}
/>

View File

@@ -1,9 +1,10 @@
import React, { useMemo } from "react";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useMMKVString } from "react-native-mmkv";
import { ListGroup } from "./list/ListGroup";
import { ListItem } from "./list/ListItem";
import { useTranslation } from "react-i18next";
interface Server {
address: string;
@@ -29,7 +30,7 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
return (
<View>
<ListGroup title={t("server.previous_servers")} className="mt-4">
<ListGroup title={t("server.previous_servers")} className='mt-4'>
{previousServers.map((s) => (
<ListItem
key={s.address}
@@ -43,7 +44,7 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
setPreviousServers("[]");
}}
title={t("server.clear_button")}
textColor="red"
textColor='red'
/>
</ListGroup>
</View>

View File

@@ -1,5 +1,5 @@
import React from "react";
import { View, StyleSheet } from "react-native";
import type React from "react";
import { StyleSheet, View } from "react-native";
import { AnimatedCircularProgress } from "react-native-circular-progress";
type ProgressCircleProps = {

View File

@@ -1,12 +1,18 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { View, ViewProps } from "react-native";
import { Badge } from "./Badge";
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useQuery } from "@tanstack/react-query";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useMemo } from "react";
import { View, type ViewProps } from "react-native";
import { Badge } from "./Badge";
interface Props extends ViewProps {
item?: BaseItemDto | null;
@@ -15,21 +21,21 @@ interface Props extends ViewProps {
export const Ratings: React.FC<Props> = ({ item, ...props }) => {
if (!item) return null;
return (
<View className="flex flex-row items-center mt-2 space-x-2" {...props}>
<View className='flex flex-row items-center mt-2 space-x-2' {...props}>
{item.OfficialRating && (
<Badge text={item.OfficialRating} variant="gray" />
<Badge text={item.OfficialRating} variant='gray' />
)}
{item.CommunityRating && (
<Badge
text={item.CommunityRating.toFixed(1)}
variant="gray"
iconLeft={<Ionicons name="star" size={14} color="gold" />}
variant='gray'
iconLeft={<Ionicons name='star' size={14} color='gold' />}
/>
)}
{item.CriticRating && (
<Badge
text={item.CriticRating}
variant="gray"
variant='gray'
iconLeft={
<Image
source={
@@ -49,14 +55,17 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
);
};
export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult }> = ({
result,
}) => {
const { jellyseerrApi } = useJellyseerr();
export const JellyserrRatings: React.FC<{
result: MovieResult | TvResult | TvDetails | MovieDetails;
}> = ({ result }) => {
const { jellyseerrApi, getMediaType } = useJellyseerr();
const mediaType = useMemo(() => getMediaType(result), [result]);
const { data, isLoading } = useQuery({
queryKey: ["jellyseerr", result.id, result.mediaType, "ratings"],
queryKey: ["jellyseerr", result.id, mediaType, "ratings"],
queryFn: async () => {
return result.mediaType === MediaType.MOVIE
return mediaType === MediaType.MOVIE
? jellyseerrApi?.movieRatings(result.id)
: jellyseerrApi?.tvRatings(result.id);
},
@@ -70,14 +79,14 @@ export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult }> = ({
!!result.voteCount ||
(data?.criticsRating && !!data?.criticsScore) ||
(data?.audienceRating && !!data?.audienceScore)) && (
<View className="flex flex-row flex-wrap space-x-1">
<View className='flex flex-row flex-wrap space-x-1'>
{data?.criticsRating && !!data?.criticsScore && (
<Badge
text={`${data.criticsScore}%`}
variant="gray"
variant='gray'
iconLeft={
<Image
className="mr-1"
className='mr-1'
source={
data?.criticsRating === "Rotten"
? require("@/utils/jellyseerr/src/assets/rt_rotten.svg")
@@ -94,10 +103,10 @@ export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult }> = ({
{data?.audienceRating && !!data?.audienceScore && (
<Badge
text={`${data.audienceScore}%`}
variant="gray"
variant='gray'
iconLeft={
<Image
className="mr-1"
className='mr-1'
source={
data?.audienceRating === "Spilled"
? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg")
@@ -114,10 +123,10 @@ export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult }> = ({
{!!result.voteCount && (
<Badge
text={`${Math.round(result.voteAverage * 10)}%`}
variant="gray"
variant='gray'
iconLeft={
<Image
className="mr-1"
className='mr-1'
source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")}
style={{
width: 14,

View File

@@ -1,12 +1,12 @@
import { useHaptic } from "@/hooks/useHaptic";
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { PropsWithChildren } from "react";
import type { PropsWithChildren } from "react";
import {
Platform,
TouchableOpacity,
TouchableOpacityProps,
type TouchableOpacityProps,
} from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends TouchableOpacityProps {
onPress?: () => void;

View File

@@ -1,18 +1,23 @@
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { ScrollView, TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "./common/Text";
import { useTranslation } from "react-i18next";
import {
ScrollView,
TouchableOpacity,
View,
type ViewProps,
} from "react-native";
import { ItemCardText } from "./ItemCardText";
import { Loader } from "./Loader";
import { HorizontalScroll } from "./common/HorrizontalScroll";
import { Text } from "./common/Text";
import { TouchableItemRouter } from "./common/TouchableItemRouter";
import { useTranslation } from "react-i18next";
interface SimilarItemsProps extends ViewProps {
itemId?: string | null;
@@ -39,17 +44,19 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
return response.data.Items || [];
},
enabled: !!api && !!user?.Id,
staleTime: Infinity,
staleTime: Number.POSITIVE_INFINITY,
});
const movies = useMemo(
() => similarItems?.filter((i) => i.Type === "Movie") || [],
[similarItems]
[similarItems],
);
return (
<View {...props}>
<Text className="px-4 text-lg font-bold mb-2">{t("item_card.similar_items")}</Text>
<Text className='px-4 text-lg font-bold mb-2'>
{t("item_card.similar_items")}
</Text>
<HorizontalScroll
data={movies}
loading={isLoading}
@@ -59,7 +66,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
<TouchableItemRouter
key={idx}
item={item}
className="flex flex-col w-28"
className='flex flex-col w-28'
>
<View>
<MoviePoster item={item} />

View File

@@ -1,49 +1,40 @@
import { tc } from "@/utils/textTools";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useTranslation } from "react-i18next";
import { Text } from "./common/Text";
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, selected]
() => subtitleStreams?.find((x) => x.Index === selected),
[subtitleStreams, selected],
);
if (subtitleStreams.length === 0) return null;
if (subtitleStreams?.length === 0) return null;
const { t } = useTranslation();
return (
<View
className="flex col shrink justify-start place-self-start items-start"
className='flex col shrink justify-start place-self-start items-start'
style={{
minWidth: 60,
maxWidth: 200,
@@ -51,12 +42,12 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col " {...props}>
<Text className="opacity-50 mb-1 text-xs">
<View className='flex flex-col ' {...props}>
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
{t("item_card.subtitles")}
</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text className=" ">
<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
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
: t("item_card.none")}
@@ -66,8 +57,8 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}

View File

@@ -1,4 +1,4 @@
import { Text, type TextProps, StyleSheet } from "react-native";
import { StyleSheet, Text, type TextProps } from "react-native";
export type ThemedTextProps = TextProps & {
lightColor?: string;

View File

@@ -1,5 +1,5 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import { View } from "react-native";
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
@@ -7,7 +7,7 @@ export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
<>
{item.UserData?.Played === false &&
(item.Type === "Movie" || item.Type === "Episode") && (
<View className="bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45"></View>
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
)}
</>
);

View File

@@ -1,10 +1,12 @@
import * as React from 'react';
import renderer from 'react-test-renderer';
import * as React from "react";
import renderer from "react-test-renderer";
import { ThemedText } from '../ThemedText';
import { ThemedText } from "../ThemedText";
it(`renders correctly`, () => {
const tree = renderer.create(<ThemedText>Snapshot test!</ThemedText>).toJSON();
it("renders correctly", () => {
const tree = renderer
.create(<ThemedText>Snapshot test!</ThemedText>)
.toJSON();
expect(tree).toMatchSnapshot();
});

View File

@@ -1,12 +1,12 @@
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { View, type ViewProps } from "react-native";
interface Props extends ViewProps {}
export const TitleHeader: React.FC<Props> = ({ ...props }) => {
return (
<View {...props}>
<Text></Text>
<Text />
</View>
);
};

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { StyleSheet, View, ViewProps } from "react-native";
import { StyleSheet, View, type ViewProps } from "react-native";
const getItemStyle = (index: number, numColumns: number) => {
const alignItems = (() => {
@@ -29,7 +29,7 @@ export const ColumnItem = ({
...rest
}: ColumnItemProps) => {
return (
<View className="flex flex-col mb-2 p-4" style={{ width: "33.3%" }}>
<View className='flex flex-col mb-2 p-4' style={{ width: "33.3%" }}>
<View
className={`
`}

View File

@@ -1,13 +1,13 @@
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Platform, TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import DisabledSetting from "@/components/settings/DisabledSetting";
import React, {
PropsWithChildren,
ReactNode,
type PropsWithChildren,
type ReactNode,
useEffect,
useState,
} from "react";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
interface Props<T> {
data: T[];
@@ -18,10 +18,10 @@ interface Props<T> {
title: string | ReactNode;
label: string;
onSelected: (...item: T[]) => void;
multi?: boolean;
multiple?: boolean;
}
const Dropdown = <T extends unknown>({
const Dropdown = <T,>({
data,
disabled,
placeholderText,
@@ -30,7 +30,7 @@ const Dropdown = <T extends unknown>({
title,
label,
onSelected,
multi = false,
multiple = false,
...props
}: PropsWithChildren<Props<T> & ViewProps>) => {
if (Platform.isTV) return null;
@@ -47,10 +47,10 @@ const Dropdown = <T extends unknown>({
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{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 style={{}} className="" numberOfLines={1}>
<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 style={{}} className='' numberOfLines={1}>
{selected?.length !== undefined
? selected.map(titleExtractor).join(",")
: placeholderText}
@@ -63,8 +63,8 @@ const Dropdown = <T extends unknown>({
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side="bottom"
align="center"
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
@@ -72,27 +72,27 @@ const Dropdown = <T extends unknown>({
>
<DropdownMenu.Label>{label}</DropdownMenu.Label>
{data.map((item, idx) =>
multi ? (
multiple ? (
<DropdownMenu.CheckboxItem
value={
selected?.some((s) => keyExtractor(s) == keyExtractor(item))
selected?.some((s) => keyExtractor(s) === keyExtractor(item))
? "on"
: "off"
}
key={keyExtractor(item)}
onValueChange={(next, previous) =>
onValueChange={(next: "on" | "off", previous: "on" | "off") => {
setSelected((p) => {
const prev = p || [];
if (next == "on") {
if (next === "on") {
return [...prev, item];
}
return [
...prev.filter(
(p) => keyExtractor(p) !== keyExtractor(item)
(p) => keyExtractor(p) !== keyExtractor(item),
),
];
})
}
});
}}
>
<DropdownMenu.ItemTitle>
{titleExtractor(item)}
@@ -107,7 +107,7 @@ const Dropdown = <T extends unknown>({
{titleExtractor(item)}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
)
),
)}
</DropdownMenu.Content>
</DropdownMenu.Root>

View File

@@ -1,14 +1,14 @@
import { Text } from "@/components/common/Text";
import { Ionicons } from "@expo/vector-icons";
import { BlurView, type BlurViewProps } from "expo-blur";
import { useRouter } from "expo-router";
import {
Platform,
TouchableOpacity,
TouchableOpacityProps,
type TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { Text } from "@/components/common/Text";
import { useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { BlurView, BlurViewProps } from "expo-blur";
interface Props extends BlurViewProps {
background?: "blur" | "transparent";
@@ -31,13 +31,13 @@ export const HeaderBackButton: React.FC<Props> = ({
<BlurView
{...props}
intensity={100}
className="overflow-hidden rounded-full p-2"
className='overflow-hidden rounded-full p-2'
>
<Ionicons
className="drop-shadow-2xl"
name="arrow-back"
className='drop-shadow-2xl'
name='arrow-back'
size={24}
color="white"
color='white'
/>
</BlurView>
</TouchableOpacity>
@@ -46,14 +46,14 @@ export const HeaderBackButton: React.FC<Props> = ({
return (
<TouchableOpacity
onPress={() => router.back()}
className=" bg-neutral-800/80 rounded-full p-2"
className=' bg-neutral-800/80 rounded-full p-2'
{...touchableOpacityProps}
>
<Ionicons
className="drop-shadow-2xl"
name="arrow-back"
className='drop-shadow-2xl'
name='arrow-back'
size={24}
color="white"
color='white'
/>
</TouchableOpacity>
);

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