Compare commits

..

380 Commits

Author SHA1 Message Date
Alex Kim
6494049b66 WIP 2025-02-16 06:39:02 +11:00
Alex Kim
adb20f4b10 Merge branch 'develop' into fix/remove-react-native-video 2025-02-16 04:55:40 +11:00
Alex Kim
7dc8132e7a Merge branch 'develop' into fix/remove-react-native-video 2025-02-16 04:53:42 +11:00
herrrta
ecb9b90163 fix: Stop playback when gesture navigating back 2025-02-15 12:52:09 -05:00
Alex Kim
9fb04518b0 WIP 2025-02-16 04:17:40 +11:00
Fredrik Burmester
33a2be24f4 fix: hidden be default ios 2025-02-15 12:00:58 +01:00
Fredrik Burmester
e8b0d52515 feat: change to native searchbar on android 2025-02-15 11:59:17 +01:00
Fredrik Burmester
9faa0de2d6 chore: bump version 2025-02-15 11:48:54 +01:00
Fredrik Burmester
221155d002 fix: deps 2025-02-15 11:34:50 +01:00
Fredrik Burmester
46be3c9465 fix: remove all mentions of transcoding player and the route
only use vlc player
2025-02-14 13:46:01 +01:00
Fredrik Burmester
4a37e17324 chore 2025-02-14 13:28:06 +01:00
Fredrik Burmester
52b2a3418e fix: wrong deps 2025-02-13 10:35:58 +01:00
Fredrik Burmester
2753b243e5 chore: remove yarn lock file 2025-02-13 10:33:56 +01:00
Fredrik Burmester
f22b356b7c feat: turkish translations 2025-02-13 10:33:41 +01:00
Fredrik Burmester
d8ba5af8d9 chore: remove old patch 2025-02-13 10:33:34 +01:00
herrrta
505ef39ee7 ios VLCKit 4.0 & All platform PiP support 2025-02-12 23:21:24 -05:00
Théo FORTIN
e71d5cc176 feat: Add default quality setting (#509) 2025-02-12 08:32:26 +01:00
Fredrik Burmester
74e57bbd88 fix: add contributor avatars to readme (#512) 2025-02-12 08:31:55 +01:00
Fredrik Burmester
76eaeb9820 chore 2025-02-12 08:31:38 +01:00
Fredrik Burmester
9a70f98dd5 chore 2025-02-12 08:25:36 +01:00
herrrta
f28f1d8736 Fix android discover page crash 2025-02-11 10:16:36 -05:00
lostb1t
e0f03ccb93 feat: Allow plugin override defaults (#508) 2025-02-10 17:38:01 +01:00
lostb1t
34d1dbb20e Update README.md 2025-02-10 15:39:14 +01:00
Simon Eklundh
e3e2db659d fix: download player (#506) 2025-02-09 13:40:45 +01:00
Fredrik Burmester
528b4ad7ac fix: orientation in video player and app i general 2025-02-09 11:45:32 +01:00
lostb1t
d29501386b chore: expo 52 (#502)
Co-authored-by: herrrta <73949927+herrrta@users.noreply.github.com>
2025-02-09 10:46:05 +01:00
Simon Eklundh
6688469b6c fix: fixes non-optimized downloads (#500) 2025-02-09 10:43:42 +01:00
lostb1t
ae9c30aa6d fix: fix home and header nav not showing (#499) 2025-02-08 17:48:05 +01:00
Fredrik Burmester
364d2e8a51 fix: typescript errors 2025-02-08 10:51:52 +01:00
herrrta
6cc90b46b3 TV: fix navigation on login (#494) 2025-02-07 21:57:13 -05:00
sarendsen
33adea2819 fix more import for tv 2025-02-07 14:22:54 +01:00
Simon Eklundh
9f41861dcf fix: download provider import usage so we can play again (#491) 2025-02-06 23:12:44 +01:00
lostb1t
2b2d23e574 Update README.md 2025-02-06 17:53:01 +01:00
lostb1t
f6e2bcb120 Update README.md 2025-02-06 17:52:02 +01:00
lostb1t
314cd62bee Update README.md 2025-02-06 17:48:25 +01:00
lostb1t
41e7123d1c Update README.md 2025-02-06 17:48:03 +01:00
lostb1t
2af42b39f5 Update README.md 2025-02-06 17:44:31 +01:00
lostb1t
0a06b336c8 Update network_security_config.xml 2025-02-06 12:37:17 +01:00
lostb1t
028c9159f3 Update eas.json 2025-02-06 09:38:38 +01:00
sarendsen
dee4fa07e3 refactor: playbutton for tv 2025-02-05 15:07:11 +01:00
lostb1t
2764f1736a Update eas.json 2025-02-05 13:58:30 +01:00
Fredrik Burmester
d3d1a7bcde Merge pull request #374 from streamyfin/feature/bigscreen
feat: Initial support for tvOs/AndroidTV
2025-02-05 13:41:19 +01:00
sarendsen
7fcd598fa1 wip 2025-02-05 10:04:50 +01:00
sarendsen
0fc1506b11 merge develop 2025-02-05 09:44:03 +01:00
Adrián
e0aa7ea0df fix: Change phone_usage key to device_usage in Spanish translations (#479) 2025-02-04 15:23:41 +01:00
Mustafa
25f77645f8 fix: phone_usage to device_usage due PR #456 for DE language (#478) 2025-02-02 13:05:58 +01:00
Gauvain
1c81091e8b fix(i18n): fix french translation and wrong keys (#456) 2025-02-02 09:20:08 +01:00
Mustafa
94502b558d feat: Add German Translation DE (#477) 2025-02-02 09:18:15 +01:00
Adrián
a7d7d00eb3 feat: Translate app to Spanish (#457) 2025-02-02 09:17:54 +01:00
Fredrik Burmester
3b5e07c1d2 chore 2025-02-01 10:14:09 +01:00
Fredrik Burmester
db10369fb5 chore 2025-02-01 09:29:05 +01:00
Fredrik Burmester
32da5918c7 chore 2025-01-31 15:57:03 +01:00
Fredrik Burmester
dc542021b5 chore 2025-01-31 15:47:03 +01:00
Fredrik Burmester
bfad157a28 Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-01-31 15:36:52 +01:00
Fredrik Burmester
a71a646743 chore 2025-01-31 15:36:49 +01:00
sarendsen
366bc0137e WIP 2025-01-31 13:22:51 +01:00
Tom Heidenreich
3eb60840e6 fix: Rendered more hooks than during the previous render in NextEpisodeCountDownButton (#475) 2025-01-31 10:14:59 +01:00
sarendsen
65c4a1340d WIP 2025-01-30 11:19:36 +01:00
sarendsen
3e90447dd4 WIP 2025-01-30 10:18:07 +01:00
sarendsen
bd0768797e WIP 2025-01-30 09:20:31 +01:00
Max Ward
730ef4616f feat: Mark entire seasons of a show as played (#445) 2025-01-29 10:54:00 +01:00
lostb1t
c4d4475aa9 Create lint-pr.yaml 2025-01-27 14:04:22 +01:00
Fredrik Burmester
d1eb40f2a9 chore 2025-01-27 13:26:23 +01:00
Fredrik Burmester
77518d774e chore 2025-01-27 13:00:40 +01:00
Fredrik Burmester
a6fb7b956d chore 2025-01-27 13:00:16 +01:00
Tom Heidenreich
034ff3f478 Feat/Show Splashcreen until UI loaded (#437) 2025-01-27 10:28:53 +01:00
Max Ward
98ca4e7a6d Fix mark as played sheet logic being reversed (#443) 2025-01-27 08:27:28 +01:00
herrrta
461a276a20 Merge pull request #461 from streamyfin/fix/460
fix: Requesting some seasons not working [Jellyseerr]
2025-01-25 15:06:08 -05:00
herrrta
3975473da9 fix: Requesting some seasons not working [Jellyseerr] 2025-01-25 15:05:48 -05:00
lostb1t
d34b86297a Update ScrollingCollectionList.tsx 2025-01-24 09:06:30 +01:00
sarendsen
c4a83e283f feat: hide sections when empty by default 2025-01-24 08:59:41 +01:00
sarendsen
dac471f0a6 feat: hide sections when empty by default 2025-01-24 08:55:02 +01:00
Fredrik Burmester
252fc4387b chore 2025-01-23 11:29:35 +01:00
Fredrik Burmester
3e299e2136 fix: early return causing crash 2025-01-23 10:07:21 +01:00
Fredrik Burmester
01cab2277e Merge pull request #451 from RodoMa92/add_self_signed_support
[android] Trust android local CA store for self signed certificates
2025-01-23 10:02:16 +01:00
Fredrik Burmester
e4f4e861e0 Merge pull request #340 from simoncaron/feat/i18n
Implement translation with i18next
2025-01-23 10:01:13 +01:00
Marco Rodolfi
4d665013f0 [android] Trust android local CA store for self signed certificates 2025-01-22 20:08:20 +01:00
sarendsen
9aa4ea4a2e refactor: home section lists 2025-01-22 07:27:08 +01:00
sarendsen
93ae03f55c fix #446 2025-01-20 10:51:39 +01:00
Fredrik Burmester
b311ac98a7 Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-01-17 07:43:23 +01:00
Fredrik Burmester
83d425b2fb chore 2025-01-17 07:43:04 +01:00
Simon Caron
007fbdd0a3 Merge branch 'develop' into feat/i18n 2025-01-16 20:38:00 -05:00
Simon Caron
37df999db5 Merge pull request #1 from Gauvino/fix-typo
fix(i18n): missing typo and comma
2025-01-16 20:35:46 -05:00
sarendsen
72b9675df4 feat: Implement nextup for custom home 2025-01-16 10:36:20 +01:00
lostb1t
7a30a63335 Update README.md 2025-01-15 09:13:03 +01:00
sarendsen
0ff0fab3f4 fix: fix horizontal shows 2025-01-15 00:47:10 +01:00
Fredrik Burmester
d9d9b0ee00 Merge pull request #430 from streamyfin/feat/refreshsettings
feat: Refresh remote settings
2025-01-14 16:29:00 +01:00
Uruk
fdaa69a787 fix(i18n): missing typo and comma 2025-01-14 13:51:43 +01:00
sarendsen
ed5403e597 wip 2025-01-14 10:37:20 +01:00
sarendsen
e6f290b85f wip 2025-01-14 10:35:21 +01:00
sarendsen
aa20d9c701 wip 2025-01-14 10:31:16 +01:00
sarendsen
e7128afb32 wip 2025-01-14 09:48:17 +01:00
sarendsen
a24b126539 wip 2025-01-14 09:24:31 +01:00
sarendsen
e1fe20db86 wip 2025-01-14 07:56:32 +01:00
Simon Caron
cd9f6aa8bd update submodule 2025-01-14 00:06:14 -05:00
Simon Caron
747bd1b416 Merge branch 'develop' into feat/i18n 2025-01-13 22:35:05 -05:00
Simon Caron
364ce46fe5 Screen Orientation Enum + Subtitle Mode 2025-01-13 22:30:57 -05:00
Simon Caron
5703279b46 Merge develop 2025-01-13 21:18:37 -05:00
lostb1t
4022ccb213 feat: Custom homescreen support (#424) 2025-01-13 19:48:19 +01:00
Fredrik Burmester
3a836462f5 Merge pull request #422 from simoncaron/feat/hide-log-page-title
fix: Remove Page Path from Log Page Header
2025-01-13 17:58:39 +01:00
herrrta
8a5f24002f fix: unauthorized plugin access & null default values 2025-01-13 08:30:11 -05:00
retardgerman
c30f9860ee fix: fixed syntax errors 2025-01-13 12:31:23 +01:00
sarendsen
94c170e3d2 chore: some linting 2025-01-13 10:32:03 +01:00
Simon Caron
cd8aba32d8 Jellyseerr 2025-01-13 00:03:41 -05:00
Simon Caron
15f3ddf612 fix: Remove Page Path from Log Page 2025-01-12 23:00:12 -05:00
Simon Caron
90f20f6e46 Shorter messages 2025-01-12 21:34:08 -05:00
Simon Caron
ea1f45bbaf More settings + language component spacing 2025-01-12 21:30:57 -05:00
Simon Caron
7e62c9bc9a Merge branch 'develop' into feat/i18n 2025-01-12 19:49:58 -05:00
herrrta
23f9e9dfae fix: Override default settings with plugin unlocked default settings
- This sets the defaults on login and allows users to still change them
2025-01-12 19:24:01 -05:00
Simon Caron
580e12b605 Alert 2025-01-12 19:04:51 -05:00
Fredrik Burmester
ff4c5f28af chore 2025-01-12 14:11:09 +01:00
Fredrik Burmester
1b931ea348 Merge pull request #419 from streamyfin/fix/remove-music
fix: remove everything related to music
2025-01-12 14:07:59 +01:00
Fredrik Burmester
49c0437f81 fix: change opacity on press 2025-01-12 14:04:12 +01:00
Fredrik Burmester
d81ae94ce8 fix: add version to issue template 2025-01-12 13:41:33 +01:00
Fredrik Burmester
7c77c70024 chore: remove everything related to music 2025-01-12 13:40:01 +01:00
retardgerman
b28c4a56f3 fix: add new Releases to dropdown 2025-01-12 13:39:43 +01:00
Fredrik Burmester
2495a318eb Merge pull request #394 from Ryan0204/enhancement/autohidecontrol
enhancement: auto hide control after 5 seconds
2025-01-12 10:16:55 +01:00
Fredrik Burmester
7832ea4d0a chore: deps 2025-01-12 10:10:18 +01:00
Fredrik Burmester
4a0a51ef1d chore: refactor 2025-01-12 10:07:49 +01:00
Fredrik Burmester
8cc551d906 Merge pull request #416 from streamyfin/feat/server-discovery
feat: server discovery during login
2025-01-12 09:37:33 +01:00
Fredrik Burmester
c8da365a00 fix: issues listed in pr 2025-01-12 09:36:23 +01:00
Fredrik Burmester
74b7cbc530 Merge pull request #417 from whoopsi-daisy/patch-1
Update README.md
2025-01-12 09:33:38 +01:00
𝐂𝐡𝐫𝐢𝐬
a14063a736 Update README.md
Adjusted the Jellyseerr screenshot height to match the others and corrected a typo, along with rephrasing a sentence for clarity
2025-01-12 00:52:17 +08:00
Fredrik Burmester
a3307a90a3 feat: server discovery during login 2025-01-11 11:21:36 +01:00
Fredrik Burmester
a2145fd7e8 chore: update deps 2025-01-11 10:20:20 +01:00
Fredrik Burmester
cab5e4d980 chore: rename var 2025-01-11 10:10:00 +01:00
Fredrik Burmester
ab603e6997 feat: add centralised plugin info 2025-01-11 10:09:53 +01:00
ryan0204
957348fe19 prevent opening control when user swipe on screen 2025-01-11 16:41:41 +08:00
herrrta
444bd040b0 Merge pull request #402 from streamyfin/feat/401
Streamyfin Plugin App Management solution
2025-01-11 00:20:35 -05:00
herrrta
d0ae63235d feat: [StreamyfinPlugin] Library Options settings 2025-01-11 00:16:26 -05:00
herrrta
1727125ea7 feat: [StreamyfinPlugin] Popular Plugin settings 2025-01-11 00:16:25 -05:00
herrrta
dc498d62d8 feat: [StreamyfinPlugin] Other settings 2025-01-11 00:16:25 -05:00
herrrta
455bf08213 feat: [StreamyfinPlugin] Subtitle Toggles settings
- Used stepper & dropdown components to simplify page
2025-01-11 00:16:20 -05:00
herrrta
0f974ef2a3 feat: [StreamyfinPlugin] Audio Toggles settings 2025-01-10 23:26:53 -05:00
herrrta
2d9aaccfe0 feat: [StreamyfinPlugin] Media Toggles settings 2025-01-10 23:26:44 -05:00
herrrta
2c6823eb53 feat: [StreamyfinPlugin] Jellyseerr, Search Engine, & Download settings
- Added DisabledSetting.tsx component
- Added DownloadMethod enum
- cleanup
2025-01-10 23:26:32 -05:00
herrrta
9dfcc01f17 chore 2025-01-10 20:39:32 -05:00
Fredrik Burmester
38aad9610b Merge branch 'feat/401' of https://github.com/streamyfin/streamyfin into feat/401 2025-01-09 15:49:02 +01:00
herrrta
54af64abef api augmentations & added streamyfin plugin id 2025-01-09 09:35:24 -05:00
herrrta
e1720a00da initial changes 2025-01-09 09:33:55 -05:00
herrrta
882d0ea188 api augmentations & added streamyfin plugin id 2025-01-09 08:51:53 -05:00
Fredrik Burmester
f3b539232f Merge pull request #403 from streamyfin/feature/mediasourcenames
Feature: Remove duplicate names from media sources
2025-01-09 11:33:06 +01:00
sarendsen
33ea657a5c Filter out duplicate names in media sources 2025-01-09 10:13:57 +01:00
herrrta
75820adcbc initial changes 2025-01-08 21:52:31 -05:00
herrrta
76cdb2b3f8 fix cast npe 2025-01-08 17:31:39 -05:00
Fredrik Burmester
0a2ea33635 Merge pull request #397 from topiga/master 2025-01-08 18:50:02 +01:00
sarendsen
3cd8e41000 wip 2025-01-08 15:25:06 +01:00
Théo FORTIN
aad6093852 Added 1 Mb/s as bitrate 2025-01-08 15:22:11 +01:00
herrrta
c553cff9d1 Added clean script 2025-01-08 08:05:06 -05:00
herrrta
dcd458bd3d [Jellyseerr] "Currently Streaming On" misaligned text
fixes #392
2025-01-08 08:04:48 -05:00
Fredrik Burmester
05dc61d17d Merge pull request #395 from streamyfin/feat/331
[Jellyseerr] Show media configuration for admins
2025-01-08 11:40:07 +01:00
Fredrik Burmester
e4de11127f chore 2025-01-08 11:39:50 +01:00
herrrta
2dc49735f4 [Jellyseerr] Show media configuration for admins
implements #331
2025-01-07 23:53:10 -05:00
ryan0204
0ebacd4bd3 Auto hide control after 5 seconds 2025-01-08 11:29:49 +08:00
Simon Caron
14c8c1aaed Fix some missing fields 2025-01-07 22:26:09 -05:00
Simon Caron
2da774272d Merge branch 'develop' into feat/i18n 2025-01-07 20:38:59 -05:00
sarendsen
dd08826931 wip 2025-01-07 12:03:35 +01:00
sarendsen
b681025389 wip 2025-01-07 12:01:55 +01:00
Fredrik Burmester
ef42207174 Merge pull request #383 from Ryan0204/master
Change ScreenOrientation to landscape right by default and added toggleSafeArea for all videos
2025-01-07 11:01:43 +01:00
Fredrik Burmester
efa5638b12 fix: remove tab sidebar 2025-01-07 10:59:21 +01:00
sarendsen
65549428bf wip 2025-01-07 10:45:25 +01:00
Fredrik Burmester
c63cea891d chore: remove imports 2025-01-07 10:27:56 +01:00
Fredrik Burmester
4e80f58823 fix: backdrop not filling screen 2025-01-07 10:27:30 +01:00
sarendsen
cda3b64a2b wip 2025-01-07 10:08:07 +01:00
ryan0204
cfe39d504c Rotate ScreenOrientation back on exit player 2025-01-07 14:13:22 +08:00
herrrta
cf43d1a657 cleanup 2025-01-06 20:56:59 -05:00
herrrta
cbe3b18226 fix enter animation 2025-01-06 20:55:50 -05:00
herrrta
b637a0f7d2 use JellyseerrPoster component 2025-01-06 20:55:48 -05:00
Fredrik Burmester
a0ce7cc6d0 chore 2025-01-06 22:58:11 +01:00
Fredrik Burmester
a640df30bc chore 2025-01-06 22:32:27 +01:00
Fredrik Burmester
062e6e6c23 chore 2025-01-06 22:21:27 +01:00
Fredrik Burmester
d709e3b13e Merge pull request #389 from streamyfin/feat/326
[Jellyseerr] Show genre/studio/network discover sliders
2025-01-06 20:57:10 +01:00
herrrta
b232bebd73 [Jellyseerr] Show genre/studio/network discover sliders
implements #326
2025-01-06 14:25:14 -05:00
Fredrik Burmester
90ef8ef6f9 feat: fade in images 2025-01-06 17:38:59 +01:00
Fredrik Burmester
0df6b8e2a0 chore 2025-01-06 17:33:49 +01:00
Fredrik Burmester
f48b26076d feat: loading skeleton for search (including jellyseerr) 2025-01-06 17:33:27 +01:00
ryan0204
c86a8438e5 Bring back toggleSafeArea button for all videos 2025-01-06 23:15:00 +08:00
sarendsen
373d4ca3b1 wip 2025-01-06 15:10:59 +01:00
sarendsen
8bc360d554 wip 2025-01-06 15:04:07 +01:00
sarendsen
3fae21d559 wip 2025-01-06 14:45:42 +01:00
sarendsen
74ce9d7eea wip 2025-01-06 14:28:24 +01:00
sarendsen
5055a700c9 wip 2025-01-06 13:59:56 +01:00
Ryan
faa2baae68 Merge branch 'streamyfin:master' into master 2025-01-06 20:28:12 +08:00
ryan0204
ed42371353 Change ScreenOrientation to landscape right by default 2025-01-06 20:27:34 +08:00
sarendsen
ab33693dd9 wip 2025-01-06 13:25:49 +01:00
Fredrik Burmester
24277135a8 Merge branch 'master' into develop 2025-01-06 10:14:27 +01:00
Fredrik Burmester
23d9cd36d1 chore 2025-01-06 10:14:17 +01:00
Fredrik Burmester
b243524a7d chore 2025-01-06 10:13:40 +01:00
Fredrik Burmester
8288682e68 feat: intro screen 2025-01-05 23:28:24 +01:00
Fredrik Burmester
58ec915699 feat: intro page 2025-01-05 22:17:02 +01:00
Simon Caron
480abb216d fixes 2025-01-05 16:07:55 -05:00
Simon Caron
249109a94e livetv 2025-01-05 16:03:19 -05:00
Simon Caron
eb7fa93f9b remove dupe 2025-01-05 15:26:48 -05:00
Simon Caron
e8fd322d30 Merge branch 'master' into feat/i18n 2025-01-05 15:06:44 -05:00
Fredrik Burmester
cad03a3566 fix: chromecast 2025-01-05 20:59:45 +01:00
Fredrik Burmester
9baa4063bd chore 2025-01-05 16:22:52 +01:00
Fredrik Burmester
41db34ed8e fix: hide libraries from home sections as well 2025-01-05 16:22:32 +01:00
retardgerman
5aba66ce05 fix: add jellyseerr screenshot 2025-01-05 16:09:06 +01:00
retardgerman
79407ccd70 Add files via upload 2025-01-05 16:06:21 +01:00
retardgerman
9a93b3b3bb Delete jellyseerr.PNG 2025-01-05 16:05:51 +01:00
retardgerman
2b846a1aca Add files via upload 2025-01-05 16:01:02 +01:00
Fredrik Burmester
55d61172f4 Merge branch 'master' into develop 2025-01-05 15:53:06 +01:00
Fredrik Burmester
57173a62dc Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-01-05 15:51:52 +01:00
Fredrik Burmester
78f65be09d Merge branch 'master' into develop 2025-01-05 15:51:33 +01:00
Fredrik Burmester
293a9517a5 Merge pull request #377 from anubhavvs/feature/haptic-feedback-toggle
feat: haptic feedback settings and custom hook
2025-01-05 15:50:57 +01:00
Fredrik Burmester
38b6215046 Merge pull request #376 from eltociear/patch-1
chore: update useDefaultPlaySettings.ts
2025-01-05 15:48:32 +01:00
Fredrik Burmester
fc4a11d916 Merge pull request #378 from streamyfin/feat/hide-libraries
feat: hide libraries from libraries tab
2025-01-05 15:47:29 +01:00
Fredrik Burmester
cf2beb8299 feat: hide libraries 2025-01-05 15:46:44 +01:00
Fredrik Burmester
49d157a95a fix: overlapping buttons with navbar fixes #373 2025-01-05 15:29:39 +01:00
Anubhav Saha
9692c173ae feat: haptic feedback settings and custom hook 2025-01-05 15:22:52 +01:00
Ikko Eltociear Ashimine
a297ac4843 chore: update useDefaultPlaySettings.ts
intial -> initial
2025-01-05 23:08:44 +09:00
Fredrik Burmester
6a4621c377 Merge branch 'develop' into feature/bigscreen 2025-01-05 13:43:23 +01:00
Fredrik Burmester
a061f9f480 fix: padding 2025-01-05 12:04:11 +01:00
Fredrik Burmester
0fb6f2fb30 fix: padding 2025-01-05 12:04:07 +01:00
Fredrik Burmester
0773f773ba fix: padding 2025-01-05 12:04:02 +01:00
Fredrik Burmester
39bb3a9370 fix: padding 2025-01-05 12:03:56 +01:00
Fredrik Burmester
b79e534692 Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-01-05 11:56:53 +01:00
Fredrik Burmester
e9336e9a67 feat: new useQuery specifically for react navigation to invalidate !enabled queries on screen re-mount 2025-01-05 11:56:46 +01:00
Fredrik Burmester
adfde1a7cd fix: update request status in poster on search scree 2025-01-05 11:56:16 +01:00
Fredrik Burmester
cab6257fb2 feat: hook for request permissions 2025-01-05 11:55:52 +01:00
Fredrik Burmester
3f0f0090af fix: refactor permissions 2025-01-05 11:55:41 +01:00
Fredrik Burmester
b278632581 fix: height on loading button 2025-01-05 11:55:29 +01:00
Fredrik Burmester
5ee1a9cabb fix: change keys 2025-01-05 11:55:22 +01:00
Fredrik Burmester
2169bea031 fix: add loading and refactor permissions 2025-01-05 11:55:05 +01:00
sarendsen
2fb19f601b remove reload 2025-01-05 10:54:14 +01:00
sarendsen
a602c35a8f refactor: Add support for tvos 2025-01-05 10:43:10 +01:00
retardgerman
46ac4a2cc7 fix: auto add feature requests to roadmap 2025-01-05 10:42:08 +01:00
retardgerman
962f65874e fix: removed assignees and modified link to roadmap 2025-01-05 10:42:08 +01:00
Fredrik Burmester
95cf252349 Delete svenska_kyrkan.sql 2025-01-05 10:29:59 +01:00
Fredrik Burmester
8470cbe8d5 Delete svenska_kyrkan.sql 2025-01-05 10:29:12 +01:00
Fredrik Burmester
636a27246f chore: deps 2025-01-05 09:51:35 +01:00
Fredrik Burmester
a488c68633 fix: sizing 2025-01-05 09:49:57 +01:00
Fredrik Burmester
7342b7eb92 chore: deps 2025-01-05 09:46:51 +01:00
Fredrik Burmester
8370519758 chore: formatting 2025-01-05 09:46:48 +01:00
Fredrik Burmester
85e21edbf1 fix: padding in person view appearences grid 2025-01-05 09:46:30 +01:00
Fredrik Burmester
8d4115f5a0 fix: app crash on internal yt trailer, link out instead 2025-01-05 09:24:54 +01:00
herrrta
c5d7a6729b Merge pull request #372 from herrrta/feat/327
[Jellyseerr] Add cast/crew results
2025-01-05 02:55:41 -05:00
herrrta
db4046267f [Jellyseerr] Add cast/crew results
implements #327
2025-01-05 02:53:41 -05:00
retardgerman
1e869a2c2f fix: auto add feature requests to roadmap 2025-01-05 08:28:32 +01:00
retardgerman
b6502c042a fix: removed assignees and modified link to roadmap 2025-01-05 08:16:04 +01:00
herrrta
b506871c46 Merge pull request #371 from herrrta/feat/328
[Jellyseerr] Add external links to trailers
2025-01-04 21:29:10 -05:00
herrrta
734678b1d5 [Jellyseerr] Add external links to trailers
implements #328
2025-01-04 21:28:45 -05:00
herrrta
68e98bbb94 Merge pull request #370 from herrrta/feat/325
[Jellyseerr] Show facts about media result
2025-01-04 21:06:32 -05:00
herrrta
d84ed558f3 [Jellyseerr] Show facts about media result
implements #325
2025-01-04 21:04:12 -05:00
herrrta
ad39e8e10a Merge pull request #369 from herrrta/fix/lock-ios-util-version
Fix/lock ios util version
2025-01-04 17:25:09 -05:00
herrrta
29bba04fdd react-native-ios-utilities major having issues
locked to 4.5.1
2025-01-04 17:24:52 -05:00
herrrta
5a24957e88 Merge pull request #368 from herrrta/fix/332
Add badge to differentiate a movie/series
2025-01-04 17:24:30 -05:00
herrrta
39f2735756 Add badge to differentiate a movie/series
fixes #332
2025-01-04 17:11:01 -05:00
Simon Caron
53ea1cc899 More Translations 2025-01-04 16:41:54 -05:00
Fredrik Burmester
5dc86d4765 Merge pull request #365 from herrrta/fix/363
Requesting using purple request button doesnt refresh page
2025-01-04 22:00:13 +01:00
herrrta
d13731c28f Requesting using purple request button doesnt refresh page
fixes #363
2025-01-04 15:51:58 -05:00
Simon Caron
459ca3245b Rename card field 2025-01-04 15:39:04 -05:00
Simon Caron
0d1fb87284 Fix Language Selector Setting Component 2025-01-04 15:26:24 -05:00
Fredrik Burmester
7f0446b85f Update build-ios.yaml 2025-01-04 21:13:57 +01:00
Fredrik Burmester
11fbe19f80 Update build-ios.yaml 2025-01-04 20:57:57 +01:00
Simon Caron
495742c52c Merge branch 'master' into feat/i18n 2025-01-04 14:57:45 -05:00
Fredrik Burmester
5c97b85492 Update build-ios.yaml 2025-01-04 20:50:09 +01:00
Simon Caron
894305e126 Item Card Fields 2025-01-04 14:49:56 -05:00
Fredrik Burmester
e60cec69f8 Update build-ios.yaml 2025-01-04 20:49:12 +01:00
Fredrik Burmester
7bc1c22770 Merge pull request #355 from Stetsed/master
Implement CI/CD pipeline for iOS building
2025-01-04 20:30:12 +01:00
Stetsed
e86dab5613 Update build-ios.yaml 2025-01-04 19:15:09 +01:00
Stetsed
eeb803223c Allow manual build trigger 2025-01-04 19:04:29 +01:00
Stetsed
1a43f7ef1b Create build-ios.yaml 2025-01-04 19:03:12 +01:00
retardgerman
f4624bdc25 fix: typo 2025-01-04 16:32:46 +01:00
Fredrik Burmester
3c5f2b4079 fix: controls gray overlay 2025-01-04 13:04:23 +01:00
Fredrik Burmester
955190a9cc chore: version 2025-01-04 13:04:15 +01:00
Fredrik Burmester
e1e4f4833c chore: versions 2025-01-04 13:04:02 +01:00
Fredrik Burmester
3b987646a6 fix: adjust to working version 2025-01-03 23:27:52 +01:00
Simon Caron
ed993d07ce Types 2025-01-03 16:33:51 -05:00
Fredrik Burmester
0e574ea18d fix: alert prompt not working on android 2025-01-03 21:43:07 +01:00
Simon Caron
dc9008f31c Merge branch 'master' into feat/i18n 2025-01-03 15:23:17 -05:00
Fredrik Burmester
1a5fcdcb10 chore 2025-01-03 21:15:33 +01:00
Fredrik Burmester
62b00837ec fix: ? 2025-01-03 10:26:12 +01:00
Fredrik Burmester
0fc48497d0 fix: unclear phrasing 2025-01-03 10:24:16 +01:00
Fredrik Burmester
7e12136211 fix: missing text 2025-01-03 10:23:05 +01:00
Fredrik Burmester
7639de153b chore 2025-01-03 10:12:43 +01:00
Fredrik Burmester
ea3cc18b3c fix: item count 2025-01-03 10:12:40 +01:00
Fredrik Burmester
c9fb52086e fix: key warning 2025-01-03 09:55:48 +01:00
Fredrik Burmester
878edc6909 fix: spelling 2025-01-02 22:15:09 +01:00
Fredrik Burmester
74f0aca517 fix: text 2025-01-02 22:08:47 +01:00
Fredrik Burmester
60bb3b905d feat: save previous servers 2025-01-02 21:59:05 +01:00
Fredrik Burmester
fdde5fb56c Merge pull request #350 from herrrta/fix/334
Refresh jellyseerr page when media is requested
2025-01-02 20:49:04 +01:00
Fredrik Burmester
49ae9c6f57 chore 2025-01-02 20:48:44 +01:00
Fredrik Burmester
2254adb8d6 Merge branch 'pr/350' 2025-01-02 20:47:55 +01:00
herrrta
d4c722aeac Refresh jellyseerr page when media is requested
- refetch details when media request is successful
- fix key error on tags
2025-01-02 14:47:36 -05:00
Fredrik Burmester
eefcfb8be5 fix: plugins design stuff 2025-01-02 20:46:34 +01:00
herrrta
4af2712cc0 Refresh jellyseerr page when media is requested
- refetch details when media request is successful
- fix key error on tags
- chore: update bun.lockb
2025-01-02 14:08:15 -05:00
Fredrik Burmester
958b870bf0 Merge pull request #349 from streamyfin/refactor/settings-2
feat: refactor settings
2025-01-02 17:33:42 +01:00
Fredrik Burmester
ce7e1b255f fix: design 2025-01-02 17:32:50 +01:00
Fredrik Burmester
acae4b4544 fix: update deps 2025-01-02 17:32:45 +01:00
Fredrik Burmester
f7bbb20c38 fix: popular lists info 2025-01-02 17:32:29 +01:00
Fredrik Burmester
2c655b9482 chore 2025-01-02 17:25:03 +01:00
Fredrik Burmester
b8dbce6bf2 fix: popular lists plugin 2025-01-02 17:24:43 +01:00
Fredrik Burmester
730823c520 fix: marlin search settings 2025-01-02 17:18:01 +01:00
Fredrik Burmester
77f14a7d5b fix: spelling 2025-01-02 16:50:58 +01:00
Fredrik Burmester
07c7cb7ab5 feat: refactor settings 2025-01-02 16:49:04 +01:00
Fredrik Burmester
5333d53d61 fix: chromecast on android 2025-01-02 12:24:25 +01:00
Fredrik Burmester
82e50b9ba3 fix: crash if item not properly analuzed serverside 2025-01-02 12:24:17 +01:00
Fredrik Burmester
663605b9e8 chore 2025-01-02 12:23:58 +01:00
Fredrik Burmester
00847c8d3d fix: correct routing for actors after favorite tab implementation 2025-01-02 11:45:17 +01:00
retardgerman
f20ad67186 fix: updated links 2025-01-02 08:06:30 +01:00
Simon Caron
e23387a384 Library headers, filters and favorites 2025-01-01 21:57:46 -05:00
Simon Caron
bb141cad57 Merge branch 'master' into feat/i18n 2025-01-01 21:32:24 -05:00
Simon Caron
e833b4bc68 Alert and Toasts 2025-01-01 21:31:04 -05:00
Simon Caron
34fc26ed18 Quick connect alerts 2025-01-01 20:29:39 -05:00
Fredrik Burmester
91527b83dd Merge pull request #347 from herrrta/fix/333
Make report issue bottom sheet full screen
2025-01-01 20:18:52 +01:00
herrrta
14138151a3 Make report issue bottom sheet full screen
- Used BottomSheetTextInput for keyboard focus + blur behaviors
2025-01-01 14:04:50 -05:00
Fredrik Burmester
6c2bfe2a45 fix: spacing 2025-01-01 18:33:03 +01:00
Fredrik Burmester
996cd36a9e fix: invalidate cache on favorite 2025-01-01 18:30:16 +01:00
Fredrik Burmester
6aa2e00d93 fix: wrong format 2025-01-01 18:26:53 +01:00
Fredrik Burmester
344e0932dc fix: add favorite to series 2025-01-01 18:23:42 +01:00
Fredrik Burmester
eaffffb2f0 Merge branch 'master' of https://github.com/streamyfin/streamyfin 2025-01-01 18:11:20 +01:00
Fredrik Burmester
f6c0513d2d feat: favorite button toggle for movies/episodes 2025-01-01 18:11:18 +01:00
Fredrik Burmester
013f064280 Merge pull request #346 from sldless/master
Github Workflow
2025-01-01 17:54:26 +01:00
sldless
cd2c3f359e Revert "feat: add favorites functionality with FavoriteButton component and layout"
This reverts commit 468f58e531.
2025-01-01 11:47:43 -05:00
sldless
123c6bba05 Revert "Delete bun.lockb"
This reverts commit 347f196a6a.
2025-01-01 11:47:37 -05:00
sldless
a1ea926342 feat: add Discord notification workflow for new pull requests 2025-01-01 11:28:36 -05:00
Fredrik Burmester
6a17ac02af fix: horrizontal padding issue 2025-01-01 17:12:38 +01:00
Fredrik Burmester
815be2a175 Merge pull request #345 from streamyfin/feat/favorites-tab
feat: favorites tab
2025-01-01 16:23:31 +01:00
Fredrik Burmester
ece3bc001f fix: id collision 2025-01-01 16:11:04 +01:00
Fredrik Burmester
27609e7789 feat: favorites tab 2025-01-01 15:46:22 +01:00
Fredrik Burmester
40b8410390 feat: enable manually setting language in settings 2025-01-01 11:25:02 +01:00
sldless
347f196a6a Delete bun.lockb 2024-12-31 22:35:00 -05:00
sldless
468f58e531 feat: add favorites functionality with FavoriteButton component and layout 2024-12-31 22:29:09 -05:00
Fredrik Burmester
a994868be4 chore 2024-12-31 23:13:58 +01:00
Simon Caron
723233381c Settings Fields V 2024-12-31 16:09:12 -05:00
Fredrik Burmester
ee6d43e3e8 Merge branch 'master' of https://github.com/streamyfin/streamyfin 2024-12-31 21:47:46 +01:00
Fredrik Burmester
f8d22bb7d6 fix: trailers for movies 2024-12-31 21:47:43 +01:00
Simon Caron
602de34824 Settings fields 2024-12-31 15:31:36 -05:00
Simon Caron
9b1f2a98e5 Update translation key casing to snake_case 2024-12-31 14:43:40 -05:00
Simon Caron
946de97580 Remove LanguageSwitcher 2024-12-31 14:39:04 -05:00
Simon Caron
f2eadabf6a bump libs versions 2024-12-31 13:52:58 -05:00
Simon Caron
373d83a0d5 Basic downloads stack translation 2024-12-31 13:34:32 -05:00
Simon Caron
2c0ba18b49 Clean up const declarations 2024-12-31 13:10:46 -05:00
Simon Caron
3e8e8e1163 Merge branch 'master' into feat/i18n 2024-12-31 12:24:28 -05:00
Fredrik Burmester
a0391b484d Merge pull request #338 from streamyfin/retardgerman-patch-1
feat: explaining new way to enter Beta in README
2024-12-31 17:25:05 +01:00
retardgerman
681aadb121 feat: explaining new way to enter Beta in README 2024-12-31 17:24:09 +01:00
Fredrik Burmester
479a1f037e fix: padding 2024-12-31 16:07:16 +01:00
Fredrik Burmester
ae5b88ab56 feat: series info and trailer 2024-12-31 16:01:30 +01:00
Fredrik Burmester
9091b9b66a fix: poster not clickable 2024-12-31 16:01:19 +01:00
Fredrik Burmester
cccb26c9cc chore 2024-12-31 15:00:30 +01:00
Fredrik Burmester
28568cbb9c fix: incorrect logic 2024-12-31 14:56:41 +01:00
Fredrik Burmester
8344d4025b fix: not possible to select seasons without index 2024-12-31 14:54:31 +01:00
Fredrik Burmester
0f69448081 fix: design 2024-12-31 13:40:23 +01:00
Fredrik Burmester
a936916da4 fix: design 2024-12-31 13:35:59 +01:00
Fredrik Burmester
c753e33f38 chore 2024-12-31 13:32:51 +01:00
Fredrik Burmester
48422fa93e fix: design 2024-12-31 13:32:43 +01:00
Fredrik Burmester
5adf943fd9 fix: finally fix not rotten tomatoes score 2024-12-31 13:05:23 +01:00
Fredrik Burmester
9174a8104d fix: color 2024-12-31 10:55:23 +01:00
Fredrik Burmester
56f1bd489c fix: improve readme 2024-12-31 10:35:16 +01:00
Fredrik Burmester
5e79b5a581 fix: improve readme 2024-12-31 10:33:59 +01:00
Fredrik Burmester
36a689f59d fix: improved login flow for jellyseerr 2024-12-31 10:29:50 +01:00
Simon Caron
fe9c73a8f0 Library Translation 2024-12-30 21:52:34 -05:00
Simon Caron
4f62391027 Add fr, search translation, fix login title 2024-12-30 21:38:42 -05:00
Simon Caron
53b5fdda87 fix import 2024-12-30 21:13:52 -05:00
Simon Caron
c0b71eb73d Revert login message 2024-12-30 21:03:02 -05:00
Simon Caron
9b4590c876 Update Current Translated Messages with UI Changes 2024-12-30 20:06:56 -05:00
herrrta
47211ba009 Merge pull request #323 from herrrta/fix/movie-request-crash
Fix movie request crashing
2024-12-30 16:53:03 -05:00
herrrta
e86a2af9a9 Fix movie request crashing 2024-12-30 16:52:44 -05:00
Simon Caron
4b18bad3bc Merge branch 'master' into feat/i18n 2024-12-30 16:45:41 -05:00
herrrta
c46b4cc34d Merge pull request #322 from herrrta/fix/discover-spacing
Fix spacing between slides on discover page
2024-12-30 16:34:40 -05:00
herrrta
ec0d9d7788 Fix spacing between slides on discover page 2024-12-30 16:34:15 -05:00
Fredrik Burmester
d2eda1365c Merge pull request #321 from herrrta/fix/jellyseerr-pass-prompt
Better jellyseerr password input with loading indicator
2024-12-30 22:02:09 +01:00
herrrta
b58fa86a6b Better jellyseerr password input with loading indicator 2024-12-30 16:00:04 -05:00
Fredrik Burmester
400dfe3679 Merge pull request #320 from herrrta/fix/jellyseerr-cookies
Some jellyseerr clients dont set cookies
2024-12-30 15:17:50 +01:00
herrrta
cf58a5e749 Some jellyseerr clients dont set cookies 2024-12-30 09:12:00 -05:00
Fredrik Burmester
001eba02b4 Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-12-30 13:45:15 +01:00
Fredrik Burmester
67e767f298 chore: versions 2024-12-30 13:44:28 +01:00
Fredrik Burmester
5f1c5f7b34 Merge pull request #317 from herrrta/feat/jellyseerr-discover
Basic Jellyseerr discover page
2024-12-30 11:42:29 +01:00
Fredrik Burmester
e54cac1e09 Merge pull request #315 from herrrta/fix/more-jellyseerr-logs
More logs on failed jellyseerr status test
2024-12-30 11:41:38 +01:00
herrrta
cbce83e109 Basic Jellyseerr discover page 2024-12-29 13:50:02 -05:00
herrrta
c6b58c5c28 More logs on failed jellyseerr status test 2024-12-28 12:20:49 -05:00
Fredrik Burmester
0468756317 Merge pull request #309 from herrrta/jellyseer-integration
Jellyseerr Integration
2024-12-28 13:37:20 +01:00
herrrta
9f12ee027f Jellyseerr Integration
## Note this is early stages of said integration. Things will change!
series and season working

- added jellyseerr git submodule
- augmentations
- working jellyseerr search integration
- working jellyseerr requests & updated interceptors to persist cookies from every response
2024-12-27 22:20:33 -05:00
Fredrik Burmester
78b7425c6b Merge pull request #311 from lostb1t/fix/mergedversions
Fix merged versions showing as separate media in listings.
2024-12-26 11:44:03 +01:00
Fredrik Burmester
c38c1d06ad Merge pull request #307 from Alexk2309/hotfix/dropdown-spacing
Added padding and spacing for dropdown button
2024-12-26 11:43:44 +01:00
sarendsen
5af735065a Remove console.log 2024-12-26 11:31:37 +01:00
sarendsen
600276cb69 Use recursive param so that merged versions show up as a single entity 2024-12-26 11:27:26 +01:00
Alex Kim
ba3104f87e Added padding and spacing for dropdown button 2024-12-26 11:07:33 +11:00
Fredrik Burmester
752cb1cdc6 wip 2024-08-18 17:10:31 +02:00
237 changed files with 16430 additions and 6698 deletions

View File

@@ -1,13 +1,11 @@
name: Bug report name: Bug report
description: Create a report to help us improve description: Create a report to help us improve
title: '[Bug]: ' title: "[Bug]: "
labels: labels:
- ['❌ bug'] - ["❌ bug"]
projects: projects:
- ['fredrikburmester/5'] - ["streamyfin/3"]
assignees:
- fredrikburmester
body: body:
- type: textarea - type: textarea
id: what-happened id: what-happened
@@ -43,8 +41,13 @@ body:
id: version id: version
attributes: attributes:
label: Version label: Version
description: What version of Streamyfin are you running? description: What version of Streamyfin are you running?
options: options:
- 0.26.1
- 0.26.0
- 0.25.0
- 0.24.0
- 0.23.0
- 0.22.0 - 0.22.0
- 0.21.0 - 0.21.0
- older - older
@@ -54,6 +57,5 @@ body:
- type: textarea - type: textarea
id: screenshots id: screenshots
attributes: attributes:
label: label: If applicable, please add screenshots to help explain your problem.
If applicable, please add screenshots to help explain your problem.
You can drag and drop images here or paste them directly into the comment box. You can drag and drop images here or paste them directly into the comment box.

View File

@@ -4,7 +4,8 @@ about: Suggest an idea for this project
title: '' title: ''
labels: '✨ enhancement' labels: '✨ enhancement'
assignees: '' assignees: ''
projects:
- streamyfin/3
--- ---
**Describe the solution you'd like** **Describe the solution you'd like**

49
.github/workflows/build-ios.yaml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: Automatic Build and Deploy
on:
workflow_dispatch:
push:
branches:
- main
jobs:
build:
runs-on: macos-15
name: Build IOS
steps:
- uses: actions/checkout@v2
name: Check out repository
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: |
bun i && bun run submodule-reload
npx expo prebuild
- uses: sparkfabrik/ios-build-action@v2.3.0
with:
upload-to-testflight: false
increment-build-number: false
build-pods: true
pods-path: "ios/Podfile"
configuration: Release
# Change later to app-store if wanted
export-method: appstore
#export-method: ad-hoc
workspace-path: "ios/Streamyfin.xcodeproj/project.xcworkspace/"
project-path: "ios/Streamyfin.xcodeproj"
scheme: Streamyfin
apple-key-id: ${{ secrets.APPLE_KEY_ID }}
apple-key-issuer-id: ${{ secrets.APPLE_KEY_ISSUER_ID }}
apple-key-content: ${{ secrets.APPLE_KEY_CONTENT }}
team-id: ${{ secrets.TEAM_ID }}
team-name: ${{ secrets.TEAM_NAME }}
#match-password: ${{ secrets.MATCH_PASSWORD }}
#match-git-url: ${{ secrets.MATCH_GIT_URL }}
#match-git-basic-authorization: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
#match-build-type: "appstore"
#browserstack-upload: true
#browserstack-username: ${{ secrets.BROWSERSTACK_USERNAME }}
#browserstack-access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
#fastlane-env: stage
ios-app-id: com.stetsed.teststreamyfin
output-path: build-${{ github.sha }}.ipa

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

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

18
.github/workflows/notification.yaml vendored Normal file
View File

@@ -0,0 +1,18 @@
name: Discord Pull Request Notification
on:
pull_request:
types: [opened, reopened]
jobs:
notify:
runs-on: ubuntu-latest
steps:
- uses: joelwmale/webhook-action@master
with:
url: ${{ secrets.DISCORD_WEBHOOK_URL }}
body: |
{
"content": "New Pull Request: ${{ github.event.pull_request.title }}\nBy: ${{ github.event.pull_request.user.login }}\n\n${{ github.event.pull_request.html_url }}",
"avatar_url": "https://avatars.githubusercontent.com/u/193271640"
}

8
.gitignore vendored
View File

@@ -26,6 +26,10 @@ package-lock.json
/ios /ios
/android /android
/iostv
/iosmobile
/androidmobile
/androidtv
modules/player/android modules/player/android
@@ -35,4 +39,6 @@ credentials.json
*.ipa *.ipa
.continuerc.json .continuerc.json
.vscode/ .vscode/
.idea/
.ruby-lsp

4
.gitmodules vendored Normal file
View File

@@ -0,0 +1,4 @@
[submodule "utils/jellyseerr"]
path = utils/jellyseerr
url = https://github.com/herrrta/jellyseerr
branch = models

3
.idea/.gitignore generated vendored
View File

@@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml

View File

@@ -1,329 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceStreaming">
<option name="deviceSelectionList">
<list>
<PersistentDeviceSelectionData>
<option name="api" value="27" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="F01L" />
<option name="id" value="F01L" />
<option name="manufacturer" value="FUJITSU" />
<option name="name" value="F-01L" />
<option name="screenDensity" value="360" />
<option name="screenX" value="720" />
<option name="screenY" value="1280" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="28" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="SH-01L" />
<option name="id" value="SH-01L" />
<option name="manufacturer" value="SHARP" />
<option name="name" value="AQUOS sense2 SH-01L" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2160" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="Lenovo" />
<option name="codename" value="TB370FU" />
<option name="id" value="TB370FU" />
<option name="manufacturer" value="Lenovo" />
<option name="name" value="Tab P12" />
<option name="screenDensity" value="340" />
<option name="screenX" value="1840" />
<option name="screenY" value="2944" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="samsung" />
<option name="codename" value="a51" />
<option name="id" value="a51" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy A51" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="akita" />
<option name="id" value="akita" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="b0q" />
<option name="id" value="b0q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S22 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3088" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="32" />
<option name="brand" value="google" />
<option name="codename" value="bluejay" />
<option name="id" value="bluejay" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 6a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="caiman" />
<option name="id" value="caiman" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro" />
<option name="screenDensity" value="360" />
<option name="screenX" value="960" />
<option name="screenY" value="2142" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="comet" />
<option name="id" value="comet" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro Fold" />
<option name="screenDensity" value="390" />
<option name="screenX" value="2076" />
<option name="screenY" value="2152" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="29" />
<option name="brand" value="samsung" />
<option name="codename" value="crownqlteue" />
<option name="id" value="crownqlteue" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Note9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2220" />
<option name="screenY" value="1080" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="dm3q" />
<option name="id" value="dm3q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S23 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3088" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="e1q" />
<option name="id" value="e1q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S24" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="felix" />
<option name="id" value="felix" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="felix" />
<option name="id" value="felix" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="felix_camera" />
<option name="id" value="felix_camera" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold (Camera-enabled)" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="gts8uwifi" />
<option name="id" value="gts8uwifi" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Tab S8 Ultra" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1848" />
<option name="screenY" value="2960" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="husky" />
<option name="id" value="husky" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8 Pro" />
<option name="screenDensity" value="390" />
<option name="screenX" value="1008" />
<option name="screenY" value="2244" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="motorola" />
<option name="codename" value="java" />
<option name="id" value="java" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="G20" />
<option name="screenDensity" value="280" />
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="komodo" />
<option name="id" value="komodo" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro XL" />
<option name="screenDensity" value="360" />
<option name="screenX" value="1008" />
<option name="screenY" value="2244" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="lynx" />
<option name="id" value="lynx" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 7a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="google" />
<option name="codename" value="oriole" />
<option name="id" value="oriole" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 6" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="panther" />
<option name="id" value="panther" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 7" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="q5q" />
<option name="id" value="q5q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Z Fold5" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1812" />
<option name="screenY" value="2176" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="q6q" />
<option name="id" value="q6q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Z Fold6" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1856" />
<option name="screenY" value="2160" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />
<option name="codename" value="r11" />
<option name="id" value="r11" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Watch" />
<option name="screenDensity" value="320" />
<option name="screenX" value="384" />
<option name="screenY" value="384" />
<option name="type" value="WEAR_OS" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />
<option name="codename" value="redfin" />
<option name="id" value="redfin" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 5" />
<option name="screenDensity" value="440" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="shiba" />
<option name="id" value="shiba" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="tangorpro" />
<option name="id" value="tangorpro" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Tablet" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="tokay" />
<option name="id" value="tokay" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2424" />
</PersistentDeviceSelectionData>
</list>
</option>
</component>
</project>

6
.idea/misc.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/streamyfin.iml" filepath="$PROJECT_DIR$/.idea/streamyfin.iml" />
</modules>
</component>
</project>

9
.idea/streamyfin.iml generated
View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

159
README.md
View File

@@ -8,17 +8,18 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
<img width=150 src="./assets/images/screenshots/screenshot1.png" /> <img width=150 src="./assets/images/screenshots/screenshot1.png" />
<img width=150 src="./assets/images/screenshots/screenshot3.png" /> <img width=150 src="./assets/images/screenshots/screenshot3.png" />
<img width=150 src="./assets/images/screenshots/screenshot2.png" /> <img width=150 src="./assets/images/screenshots/screenshot2.png" />
<img width=159 src="./assets/images/jellyseerr.PNG"/>
</div> </div>
## 🌟 Features ## 🌟 Features
- 🚀 **Skp intro / credits support** - 🚀 **Skip Intro / Credits Support**
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking. - 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
- 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone.
- 🔊 **Background audio**: Stream music in the background, even when locking the phone. - 🔊 **Background audio**: Stream music in the background, even when locking the phone.
- 📥 **Download media** (Experimental): Save your media locally and watch it offline. - 📥 **Download media** (Experimental): Save your media locally and watch it offline.
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device. - 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
- 🤖 **Jellyseerr integration**: Request media directly in the app.
## 🧪 Experimental Features ## 🧪 Experimental Features
@@ -32,22 +33,17 @@ Downloading works by using ffmpeg to convert an HLS stream into a video file on
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features. Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features.
## Plugins ### Streamyfin Plugin
In Streamyfin we have built-in support for a few plugins. These plugins are not required to use Streamyfin, but they add some extra functionality. The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that hold all settings for the client Streamyfin. This allows you to syncronize settings accross all your users, like:
### Collection rows - Auto log in to Jellyseerr without the user having to do anythin
- Choose the default languages
- Set download method and search provider
- Customize homescreen
- And more...
Jellyfin collections can be shown as rows or carousel on the home screen. [Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
The following tags can be added to a collection to provide this functionality.
Available tags:
- sf_promoted: will make the collection a row at home
- sf_carousel: will make the collection a carousel on home.
A plugin exists to create collections based on external sources like mdblist. This make the automatic process of managing collections such as trending, most watched, etc.
See [Collection Import Plugin](https://github.com/lostb1t/jellyfin-plugin-collection-import) for more info.
### Jellysearch ### Jellysearch
@@ -66,15 +62,13 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a> <a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
</div> </div>
Or download the APKs [here on GitHub](https://github.com/fredrikburmester/streamyfin/releases) for Android. Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
### Beta testing ### Beta testing
Get the latest updates by using the TestFlight version of the app. To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the ⁠🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
<a href="https://testflight.apple.com/join/CWBaAAK2"> **Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
</a>
## 🚀 Getting Started ## 🚀 Getting Started
@@ -89,36 +83,16 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
### Development info ### Development info
1. Use node `20` 1. Use node `>20`
2. Install dependencies `bun i` 2. Install dependencies `bun i && bun run submodule-reload`
3. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. 3. Make sure you have xcode and/or android studio installed.
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.
## Extended chromecast controls For the TV version suffix the npm commands with `:tv`.
Add this to AppDelegate.mm: `npm run prebuild:tv`
`npm run ios:tv or npm run android:tv`
```
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
// @generated begin react-native-google-cast-didFinishLaunchingWithOptions - expo prebuild (DO NOT MODIFY) sync-8901be60b982d2ae9c658b1e8c50634d61bb5091
#if __has_include(<GoogleCast/GoogleCast.h>)
...
[GCKCastContext sharedInstance].useDefaultExpandedMediaControls = true;`
#endif
```
Add this to Info.plist:
```
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>${PRODUCT_NAME} uses the local network to discover Cast-enabled devices on your WiFi network.</string>
```
## 📄 License ## 📄 License
@@ -136,7 +110,7 @@ Key points of the MPL-2.0:
## 🌐 Connect with Us ## 🌐 Connect with Us
Join our Discord: [https://discord.gg/BuGG9ZNhaE](https://discord.gg/BuGG9ZNhaE) Join our Discord: [https://discord.gg/aJvAYeycyY](https://discord.gg/aJvAYeycyY)
If you have questions or need support, feel free to reach out: If you have questions or need support, feel free to reach out:
@@ -145,16 +119,95 @@ If you have questions or need support, feel free to reach out:
## 📝 Credits ## 📝 Credits
Streamyfin is developed by Fredrik Burmester and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries. Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
## ✨ Acknowledgements ## ✨ Acknowledgements
I'd like to thank the following people and projects for their contributions to Streamyfin: ### Core Developers
Thanks to the following contributors for their significant contributions:
<table>
<tr
style="
display: flex;
justify-content: space-around;
align-items: center;
flex-wrap: wrap;
"
>
<td align="center">
<a href="https://github.com/Alexk2309">
<img src="https://github.com/Alexk2309.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Alexk2309</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/herrrta">
<img src="https://github.com/herrrta.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@herrrta</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/lostb1t">
<img src="https://github.com/lostb1t.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@lostb1t</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Simon-Eklundh">
<img src="https://github.com/Simon-Eklundh.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Simon-Eklundh</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/topiga">
<img src="https://github.com/topiga.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@topiga</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/simoncaron">
<img src="https://github.com/simoncaron.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@simoncaron</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/jakequade">
<img src="https://github.com/jakequade.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@jakequade</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Ryan0204">
<img src="https://github.com/Ryan0204.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Ryan0204</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/retardgerman">
<img src="https://github.com/retardgerman.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@retardgerman</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/whoopsi-daisy">
<img src="https://github.com/whoopsi-daisy.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@whoopsi-daisy</b></sub>
</a>
</td>
</tr>
</table>
And all other developers who have contributed to Streamyfin, thank you for your contributions.
I'd also like to thank the following people and projects for their contributions to Streamyfin:
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API. - [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK. - [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
- The Jellyfin devs for always being helpful in the Discord. - The Jellyfin devs for always being helpful in the Discord.
## Star History ## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=fredrikburmester/streamyfin&type=Date)](https://star-history.com/#fredrikburmester/streamyfin&Date) [![Star History Chart](https://api.star-history.com/svg?repos=streamyfin/streamyfin&type=Date)](https://star-history.com/#streamyfin/streamyfin&Date)

11
app.config.js Normal file
View File

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

View File

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

View File

@@ -1,7 +1,9 @@
import {Stack} from "expo-router"; import {Stack} from "expo-router";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
export default function CustomMenuLayout() { export default function CustomMenuLayout() {
const { t } = useTranslation();
return ( return (
<Stack> <Stack>
<Stack.Screen <Stack.Screen
@@ -9,7 +11,7 @@ export default function CustomMenuLayout() {
options={{ options={{
headerShown: true, headerShown: true,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: "Custom Links", headerTitle: t("tabs.custom_links"),
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,

View File

@@ -1,27 +1,33 @@
import {FlatList, TouchableOpacity, View} from "react-native"; import { Platform } from "react-native";
import {useSafeAreaInsets} from "react-native-safe-area-context"; import { FlatList, TouchableOpacity, View } from "react-native";
import React, {useCallback, useEffect, useState} from "react"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import {useAtom} from "jotai/index"; import React, { useCallback, useEffect, useState } from "react";
import {apiAtom} from "@/providers/JellyfinProvider"; import { useAtom } from "jotai/index";
import {ListItem} from "@/components/ListItem"; import { apiAtom } from "@/providers/JellyfinProvider";
import * as WebBrowser from 'expo-web-browser'; import { ListItem } from "@/components/list/ListItem";
import Ionicons from '@expo/vector-icons/Ionicons'; import Ionicons from "@expo/vector-icons/Ionicons";
import {Text} from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useTranslation } from "react-i18next";
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
export interface MenuLink { export interface MenuLink {
name: string, name: string;
url: string, url: string;
icon: string icon: string;
} }
export default function menuLinks() { export default function menuLinks() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets();
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]) const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);
const { t } = useTranslation();
const getMenuLinks = useCallback(async () => { const getMenuLinks = useCallback(async () => {
try { try {
const response = await api?.axiosInstance.get(api?.basePath + "/web/config.json") const response = await api?.axiosInstance.get(
api?.basePath + "/web/config.json"
);
const config = response?.data; const config = response?.data;
if (!config && !config.hasOwnProperty("menuLinks")) { if (!config && !config.hasOwnProperty("menuLinks")) {
@@ -29,15 +35,15 @@ export default function menuLinks() {
return; return;
} }
setMenuLinks(config?.menuLinks as MenuLink[]) setMenuLinks(config?.menuLinks as MenuLink[]);
} catch (error) { } catch (error) {
console.error("Failed to retrieve config:", error); console.error("Failed to retrieve config:", error);
} }
}, }, [api]);
[api]
)
useEffect(() => { getMenuLinks() }, []); useEffect(() => {
getMenuLinks();
}, []);
return ( return (
<FlatList <FlatList
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"
@@ -47,27 +53,33 @@ export default function menuLinks() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
data={menuLinks} data={menuLinks}
renderItem={({item}) => ( renderItem={({ item }) => (
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url) }> <TouchableOpacity
onPress={() => {
if (!Platform.isTV) {
WebBrowser.openBrowserAsync(item.url);
}
}}
>
<ListItem <ListItem
title={item.name} title={item.name}
iconAfter={<Ionicons name="link" size={24} color="white"/>} iconAfter={<Ionicons name="link" size={24} color="white" />}
/> />
</TouchableOpacity> </TouchableOpacity>
) )}
}
ItemSeparatorComponent={() => ( ItemSeparatorComponent={() => (
<View <View
style={{ style={{
width: 10, width: 10,
height: 10, height: 10,
}}/> }}
)} />
)}
ListEmptyComponent={ ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full"> <View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No links</Text> <Text className="font-bold text-xl text-neutral-500">{t("custom_links.no_links")}</Text>
</View> </View>
} }
/> />
); );
} }

View File

@@ -0,0 +1,29 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
export default function SearchLayout() {
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: t("tabs.favorites"),
headerLargeStyle: {
backgroundColor: "black",
},
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
</Stack>
);
}

View File

@@ -0,0 +1,36 @@
import { Favorites } from "@/components/home/Favorites";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import React, { useCallback, useState } from "react";
import { RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function favorites() {
const invalidateCache = useInvalidatePlaybackProgressCache();
const [loading, setLoading] = useState(false);
const refetch = useCallback(async () => {
setLoading(true);
await invalidateCache();
setLoading(false);
}, []);
const insets = useSafeAreaInsets();
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<View className="my-4">
<Favorites />
</View>
</ScrollView>
);
}

View File

@@ -1,11 +1,13 @@
import { Chromecast } from "@/components/Chromecast";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { useTranslation } from "react-i18next";
const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null;
export default function IndexLayout() { export default function IndexLayout() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation();
return ( return (
<Stack> <Stack>
<Stack.Screen <Stack.Screen
@@ -13,20 +15,27 @@ export default function IndexLayout() {
options={{ options={{
headerShown: true, headerShown: true,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: "Home", headerTitle: t("tabs.home"),
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerLargeStyle: {
backgroundColor: "black",
},
headerTransparent: Platform.OS === "ios" ? true : false, headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false, headerShadowVisible: false,
headerRight: () => ( headerRight: () => (
<View className="flex flex-row items-center space-x-2"> <View className="flex flex-row items-center space-x-2">
<Chromecast /> {!Platform.isTV && (
<TouchableOpacity <>
onPress={() => { <Chromecast.Chromecast />
router.push("/(auth)/settings"); <TouchableOpacity
}} onPress={() => {
> router.push("/(auth)/settings");
<Feather name="settings" color={"white"} size={22} /> }}
</TouchableOpacity> >
<Feather name="settings" color={"white"} size={22} />
</TouchableOpacity>
</>
)}
</View> </View>
), ),
}} }}
@@ -34,19 +43,57 @@ export default function IndexLayout() {
<Stack.Screen <Stack.Screen
name="downloads/index" name="downloads/index"
options={{ options={{
title: "Downloads", title: t("home.downloads.downloads_title"),
}} }}
/> />
<Stack.Screen <Stack.Screen
name="downloads/[seriesId]" name="downloads/[seriesId]"
options={{ options={{
title: "TV-Series", title: t("home.downloads.tvseries"),
}} }}
/> />
<Stack.Screen <Stack.Screen
name="settings" name="settings"
options={{ options={{
title: "Settings", title: t("home.settings.settings_title"),
}}
/>
<Stack.Screen
name="settings/optimized-server/page"
options={{
title: "",
}}
/>
<Stack.Screen
name="settings/marlin-search/page"
options={{
title: "",
}}
/>
<Stack.Screen
name="settings/jellyseerr/page"
options={{
title: "",
}}
/>
<Stack.Screen
name="settings/hide-libraries/page"
options={{
title: "",
}}
/>
<Stack.Screen
name="settings/logs/page"
options={{
title: "",
}}
/>
<Stack.Screen
name="intro/page"
options={{
headerShown: false,
title: "",
presentation: "modal",
}} }}
/> />
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (

View File

@@ -4,21 +4,29 @@ import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard"; import { SeriesCard } from "@/components/downloads/SeriesCard";
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider"; import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue"; import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings"; import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import {useNavigation, useRouter} from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, {useEffect, useMemo, useRef} from "react"; import React, { useEffect, useMemo, useRef } from "react";
import {Alert, ScrollView, TouchableOpacity, View} from "react-native"; import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import {DownloadSize} from "@/components/downloads/DownloadSize"; import { useTranslation } from "react-i18next";
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet"; import { t } from 'i18next';
import {toast} from "sonner-native"; import { DownloadSize } from "@/components/downloads/DownloadSize";
import {writeToLog} from "@/utils/log"; import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { toast } from "sonner-native";
import { writeToLog } from "@/utils/log";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation();
const [queue, setQueue] = useAtom(queueAtom); const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload(); const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
const router = useRouter(); const router = useRouter();
@@ -56,28 +64,29 @@ export default function page() {
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
<TouchableOpacity <TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
onPress={bottomSheetModalRef.current?.present} <DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
>
<DownloadSize items={downloadedFiles?.map(f => f.item) || []}/>
</TouchableOpacity> </TouchableOpacity>
) ),
}) });
}, [downloadedFiles]); }, [downloadedFiles]);
const deleteMovies = () => deleteFileByType("Movie") const deleteMovies = () =>
.then(() => toast.success("Deleted all movies successfully!")) deleteFileByType("Movie")
.catch((reason) => { .then(() => toast.success(t("home.downloads.toasts.deleted_all_movies_successfully")))
writeToLog("ERROR", reason); .catch((reason) => {
toast.error("Failed to delete all movies"); writeToLog("ERROR", reason);
}); toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
const deleteShows = () => deleteFileByType("Episode") });
.then(() => toast.success("Deleted all TV-Series successfully!")) const deleteShows = () =>
.catch((reason) => { deleteFileByType("Episode")
writeToLog("ERROR", reason); .then(() => toast.success(t("home.downloads.toasts.deleted_all_tvseries_successfully")))
toast.error("Failed to delete all TV-Series"); .catch((reason) => {
}); writeToLog("ERROR", reason);
const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()]) toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
});
const deleteAllMedia = async () =>
await Promise.all([deleteMovies(), deleteShows()]);
return ( return (
<> <>
@@ -90,11 +99,11 @@ export default function page() {
> >
<View className="py-4"> <View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4"> <View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === "remux" && ( {settings?.downloadMethod === DownloadMethod.Remux && (
<View className="bg-neutral-900 p-4 rounded-2xl"> <View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Queue</Text> <Text className="text-lg font-bold">{t("home.downloads.queue")}</Text>
<Text className="text-xs opacity-70 text-red-600"> <Text className="text-xs opacity-70 text-red-600">
Queue and downloads will be lost on app restart {t("home.downloads.queue_hint")}
</Text> </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) => ( {queue.map((q, index) => (
@@ -107,7 +116,9 @@ export default function page() {
> >
<View> <View>
<Text className="font-semibold">{q.item.Name}</Text> <Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">{q.item.Type}</Text> <Text className="text-xs opacity-50">
{q.item.Type}
</Text>
</View> </View>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
@@ -118,25 +129,25 @@ export default function page() {
}); });
}} }}
> >
<Ionicons name="close" size={24} color="red"/> <Ionicons name="close" size={24} color="red" />
</TouchableOpacity> </TouchableOpacity>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </View>
{queue.length === 0 && ( {queue.length === 0 && (
<Text className="opacity-50">No items in queue</Text> <Text className="opacity-50">{t("home.downloads.no_items_in_queue")}</Text>
)} )}
</View> </View>
)} )}
<ActiveDownloads/> <ActiveDownloads />
</View> </View>
{movies.length > 0 && ( {movies.length > 0 && (
<View className="mb-4"> <View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4"> <View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">Movies</Text> <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"> <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> <Text className="text-xs font-bold">{movies?.length}</Text>
</View> </View>
@@ -145,7 +156,7 @@ export default function page() {
<View className="px-4 flex flex-row"> <View className="px-4 flex flex-row">
{movies?.map((item) => ( {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}/> <MovieCard item={item.item} />
</View> </View>
))} ))}
</View> </View>
@@ -155,15 +166,20 @@ export default function page() {
{groupedBySeries.length > 0 && ( {groupedBySeries.length > 0 && (
<View className="mb-4"> <View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4"> <View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">TV-Series</Text> <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"> <View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{groupedBySeries?.length}</Text> <Text className="text-xs font-bold">
{groupedBySeries?.length}
</Text>
</View> </View>
</View> </View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row"> <View className="px-4 flex flex-row">
{groupedBySeries?.map((items) => ( {groupedBySeries?.map((items) => (
<View className="mb-2 last:mb-0" key={items[0].item.SeriesId}> <View
className="mb-2 last:mb-0"
key={items[0].item.SeriesId}
>
<SeriesCard <SeriesCard
items={items.map((i) => i.item)} items={items.map((i) => i.item)}
key={items[0].item.SeriesId} key={items[0].item.SeriesId}
@@ -176,7 +192,7 @@ export default function page() {
)} )}
{downloadedFiles?.length === 0 && ( {downloadedFiles?.length === 0 && (
<View className="flex px-4"> <View className="flex px-4">
<Text className="opacity-50">No downloaded items</Text> <Text className="opacity-50">{t("home.downloads.no_downloaded_items")}</Text>
</View> </View>
)} )}
</View> </View>
@@ -200,9 +216,15 @@ export default function page() {
> >
<BottomSheetView> <BottomSheetView>
<View className="p-4 space-y-4 mb-4"> <View className="p-4 space-y-4 mb-4">
<Button color="purple" onPress={deleteMovies}>Delete all Movies</Button> <Button color="purple" onPress={deleteMovies}>
<Button color="purple" onPress={deleteShows}>Delete all TV-Series</Button> {t("home.downloads.delete_all_movies_button")}
<Button color="red" onPress={deleteAllMedia}>Delete all</Button> </Button>
<Button color="purple" onPress={deleteShows}>
{t("home.downloads.delete_all_tvseries_button")}
</Button>
<Button color="red" onPress={deleteAllMedia}>
{t("home.downloads.delete_all_button")}
</Button>
</View> </View>
</BottomSheetView> </BottomSheetView>
</BottomSheetModal> </BottomSheetModal>
@@ -214,15 +236,15 @@ function migration_20241124() {
const router = useRouter(); const router = useRouter();
const { deleteAllFiles } = useDownload(); const { deleteAllFiles } = useDownload();
Alert.alert( Alert.alert(
"New app version requires re-download", t("home.downloads.new_app_version_requires_re_download"),
"The new update reqires content to be downloaded again. Please remove all downloaded content and try again.", t("home.downloads.new_app_version_requires_re_download_description"),
[ [
{ {
text: "Back", text: t("home.downloads.back"),
onPress: () => router.back(), onPress: () => router.back(),
}, },
{ {
text: "Delete", text: t("home.downloads.delete"),
style: "destructive", style: "destructive",
onPress: async () => await deleteAllFiles(), onPress: async () => await deleteAllFiles(),
}, },

View File

@@ -23,10 +23,12 @@ import {
getUserViewsApi, getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo"; import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query"; import { QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
RefreshControl, RefreshControl,
@@ -35,6 +37,10 @@ import {
View, View,
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
useSplashScreenLoading,
useSplashScreenVisible,
} from "@/providers/SplashScreenProvider";
type ScrollingCollectionListSection = { type ScrollingCollectionListSection = {
type: "ScrollingCollectionList"; type: "ScrollingCollectionList";
@@ -55,39 +61,55 @@ type Section = ScrollingCollectionListSection | MediaListSection;
export default function index() { export default function index() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [settings, _] = useSettings(); const [
settings,
updateSettings,
pluginSettings,
setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings();
const [isConnected, setIsConnected] = useState<boolean | null>(null); const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false); const [loadingRetry, setLoadingRetry] = useState(false);
const { downloadedFiles, cleanCacheDirectory } = useDownload();
const navigation = useNavigation(); const navigation = useNavigation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
useEffect(() => { if (!Platform.isTV) {
const hasDownloads = downloadedFiles && downloadedFiles.length > 0; const { downloadedFiles, cleanCacheDirectory } = useDownload();
navigation.setOptions({ useEffect(() => {
headerLeft: () => ( const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
<TouchableOpacity navigation.setOptions({
onPress={() => { headerLeft: () => (
router.push("/(auth)/downloads"); <TouchableOpacity
}} onPress={() => {
className="p-2" router.push("/(auth)/downloads");
> }}
<Feather className="p-2"
name="download" >
color={hasDownloads ? Colors.primary : "white"} <Feather
size={22} name="download"
/> color={hasDownloads ? Colors.primary : "white"}
</TouchableOpacity> size={22}
), />
}); </TouchableOpacity>
}, [downloadedFiles, navigation, router]); ),
});
}, [downloadedFiles, navigation, router]);
useEffect(() => {
cleanCacheDirectory().catch((e) =>
console.error("Something went wrong cleaning cache directory")
);
}, []);
}
const checkConnection = useCallback(async () => { const checkConnection = useCallback(async () => {
setLoadingRetry(true); setLoadingRetry(true);
@@ -107,16 +129,17 @@ export default function index() {
setIsConnected(state.isConnected); setIsConnected(state.isConnected);
}); });
cleanCacheDirectory() // cleanCacheDirectory().catch((e) =>
.then(r => console.log("Cache directory cleaned")) // console.error("Something went wrong cleaning cache directory")
.catch(e => console.error("Something went wrong cleaning cache directory")) // );
return () => { return () => {
unsubscribe(); unsubscribe();
}; };
}, []); }, []);
const { const {
data: userViews, data,
isError: e1, isError: e1,
isLoading: l1, isLoading: l1,
} = useQuery({ } = useQuery({
@@ -136,28 +159,14 @@ export default function index() {
staleTime: 60 * 1000, staleTime: 60 * 1000,
}); });
const { // show splash screen until query loaded
data: mediaListCollections, useSplashScreenLoading(l1);
isError: e2, const splashScreenVisible = useSplashScreenVisible();
isLoading: l2,
} = useQuery({
queryKey: ["home", "sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({ const userViews = useMemo(
userId: user.Id, () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
tags: ["sf_promoted"], [data, settings?.hiddenLibraries]
recursive: true, );
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 60 * 1000,
});
const collections = useMemo(() => { const collections = useMemo(() => {
const allow = ["movies", "tvshows"]; const allow = ["movies", "tvshows"];
@@ -172,6 +181,7 @@ export default function index() {
const refetch = useCallback(async () => { const refetch = useCallback(async () => {
setLoading(true); setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache(); await invalidateCache();
setLoading(false); setLoading(false);
}, []); }, []);
@@ -206,114 +216,161 @@ export default function index() {
[api, user?.Id] [api, user?.Id]
); );
const sections = useMemo(() => { let sections: Section[] = [];
if (!api || !user?.Id) return []; if (!settings?.home || !settings?.home?.sections) {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => { const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] = const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"]; c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = "Recently Added in " + c.Name; const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey = [ const queryKey = [
"home", "home",
"recentlyAddedIn" + c.CollectionType, "recentlyAddedIn" + c.CollectionType,
user?.Id!, user?.Id!,
c.Id!, c.Id!,
]; ];
return createCollectionConfig( return createCollectionConfig(
title || "", title || "",
queryKey, queryKey,
includeItemTypes, includeItemTypes,
c.Id c.Id
); );
}); });
const ss: Section[] = [ const ss: Section[] = [
{ {
title: "Continue Watching", title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"], queryKey: ["home", "resumeItems"],
queryFn: async () => queryFn: async () =>
( (
await getItemsApi(api).getResumeItems({ await getItemsApi(api).getResumeItems({
userId: user.Id, userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"], enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"], includeItemTypes: ["Movie", "Series", "Episode"],
}) })
).data.Items || [], ).data.Items || [],
type: "ScrollingCollectionList", type: "ScrollingCollectionList",
orientation: "horizontal", orientation: "horizontal",
},
{
title: "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: "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: "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", title: t("home.next_up"),
}, queryKey: ["home", "nextUp-all"],
]; queryFn: async () =>
return ss; (
}, [api, user?.Id, collections, mediaListCollections]); 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) { if (isConnected === false) {
return ( return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8"> <View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">No Internet</Text> <Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
<Text className="text-center opacity-70"> <Text className="text-center opacity-70">
No worries, you can still watch{"\n"}downloaded content. {t("home.no_internet_message")}
</Text> </Text>
<View className="mt-4"> <View className="mt-4">
<Button <Button
@@ -324,7 +381,7 @@ export default function index() {
<Ionicons name="arrow-forward" size={20} color="white" /> <Ionicons name="arrow-forward" size={20} color="white" />
} }
> >
Go to downloads {t("home.go_to_downloads")}
</Button> </Button>
<Button <Button
color="black" color="black"
@@ -350,17 +407,19 @@ export default function index() {
); );
} }
if (e1 || e2) if (e1)
return ( return (
<View className="flex flex-col items-center justify-center h-full -mt-6"> <View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">Oops!</Text> <Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
<Text className="text-center opacity-70"> <Text className="text-center opacity-70">
Something went wrong.{"\n"}Please log out and in again. {t("home.error_message")}
</Text> </Text>
</View> </View>
); );
if (l1 || l2) // this spinner should only show up, when user navigates here
// on launch the splash screen is used for loading
if (l1 && !splashScreenVisible)
return ( return (
<View className="justify-center items-center h-full"> <View className="justify-center items-center h-full">
<Loader /> <Loader />
@@ -392,6 +451,7 @@ export default function index() {
queryKey={section.queryKey} queryKey={section.queryKey}
queryFn={section.queryFn} queryFn={section.queryFn}
orientation={section.orientation} orientation={section.orientation}
hideIfEmpty
/> />
); );
} else if (section.type === "MediaListSection") { } else if (section.type === "MediaListSection") {

View File

@@ -0,0 +1,141 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { storage } from "@/utils/mmkv";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { useFocusEffect, useRouter } from "expo-router";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Linking, TouchableOpacity, View } from "react-native";
export default function page() {
const router = useRouter();
const { t } = useTranslation();
useFocusEffect(
useCallback(() => {
storage.set("hasShownIntro", true);
}, [])
);
return (
<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">
{t("home.intro.welcome_to_streamyfin")}
</Text>
<Text className="text-center">
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
</Text>
</View>
<View>
<Text className="text-lg font-bold">
{t("home.intro.features_title")}
</Text>
<Text className="text-xs">{t("home.intro.features_description")}</Text>
<View className="flex flex-row items-center mt-4">
<Image
source={require("@/assets/icons/jellyseerr-logo.svg")}
style={{
width: 50,
height: 50,
}}
/>
<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
style={{
width: 50,
height: 50,
}}
className="flex items-center justify-center"
>
<Ionicons name="cloud-download-outline" size={32} color="white" />
</View>
<View className="shrink ml-2">
<Text className="font-bold mb-1">
{t("home.intro.downloads_feature_title")}
</Text>
<Text className="shrink text-xs">
{t("home.intro.downloads_feature_description")}
</Text>
</View>
</View>
<View className="flex flex-row items-center mt-4">
<View
style={{
width: 50,
height: 50,
}}
className="flex items-center justify-center"
>
<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">
{t("home.intro.chromecast_feature_description")}
</Text>
</View>
</View>
<View className="flex flex-row items-center mt-4">
<View
style={{
width: 50,
height: 50,
}}
className="flex items-center justify-center"
>
<Feather name="settings" size={28} color={"white"} />
</View>
<View className="shrink ml-2">
<Text className="font-bold mb-1">
{t("home.intro.centralised_settings_plugin_title")}
</Text>
<Text className="shrink text-xs">
{t("home.intro.centralised_settings_plugin_description")}{" "}
<Text
className="text-purple-600"
onPress={() => {
Linking.openURL(
"https://github.com/streamyfin/jellyfin-plugin-streamyfin"
);
}}
>
{t("home.intro.read_more")}
</Text>
</Text>
</View>
</View>
</View>
<View>
<Button
onPress={() => {
router.back();
}}
className="mt-4"
>
{t("home.intro.done_button")}
</Button>
<TouchableOpacity
onPress={() => {
router.back();
router.push("/settings");
}}
className="mt-4"
>
<Text className="text-purple-600 text-center">
{t("home.intro.go_to_settings_button")}
</Text>
</TouchableOpacity>
</View>
</View>
);
}

View File

@@ -1,176 +1,115 @@
import { Button } from "@/components/Button"; import { Platform } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/ListItem"; import { ListGroup } from "@/components/list/ListGroup";
import { SettingToggles } from "@/components/settings/SettingToggles"; import { ListItem } from "@/components/list/ListItem";
import { bytesToReadable, useDownload } from "@/providers/DownloadProvider"; import { AudioToggles } from "@/components/settings/AudioToggles";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { MediaProvider } from "@/components/settings/MediaContext";
import { clearLogs, useLog } from "@/utils/log"; import { MediaToggles } from "@/components/settings/MediaToggles";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; import { OtherSettings } from "@/components/settings/OtherSettings";
import { useQuery } from "@tanstack/react-query"; import { PluginSettings } from "@/components/settings/PluginSettings";
import * as FileSystem from "expo-file-system"; import { QuickConnect } from "@/components/settings/QuickConnect";
import * as Haptics from "expo-haptics"; import { StorageSettings } from "@/components/settings/StorageSettings";
import { useAtom } from "jotai"; import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { Alert, ScrollView, View } from "react-native"; import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import * as Progress from "react-native-progress"; import { UserInfo } from "@/components/settings/UserInfo";
import { useJellyfin } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
import { useHaptic } from "@/hooks/useHaptic";
import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import React, { lazy, useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { storage } from "@/utils/mmkv";
const DownloadSettings = lazy(
() => import("@/components/settings/DownloadSettings")
);
export default function settings() { export default function settings() {
const { logout } = useJellyfin(); const router = useRouter();
const { deleteAllFiles, appSizeUsage } = useDownload();
const { logs } = useLog();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { logout } = useJellyfin();
const { data: size, isLoading: appSizeLoading } = useQuery({ const successHapticFeedback = useHaptic("success");
queryKey: ["appSize", appSizeUsage],
queryFn: async () => {
const app = await appSizeUsage;
const remaining = await FileSystem.getFreeDiskStorageAsync();
const total = await FileSystem.getTotalDiskCapacityAsync();
return { app, remaining, total, used: (total - remaining) / total };
},
});
const openQuickConnectAuthCodeInput = () => {
Alert.prompt(
"Quick connect",
"Enter the quick connect code",
async (text) => {
if (text) {
try {
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
code: text,
userId: user?.Id,
});
if (res.status === 200) {
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
Alert.alert("Success", "Quick connect authorized");
} else {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert("Error", "Invalid code");
}
} catch (e) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert("Error", "Invalid code");
}
}
}
);
};
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} catch (e) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
toast.error("Error deleting files");
}
};
const onClearLogsClicked = async () => { const onClearLogsClicked = async () => {
clearLogs(); clearLogs();
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); successHapticFeedback();
}; };
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
onPress={() => {
logout();
}}
>
<Text className="text-red-600">
{t("home.settings.log_out_button")}
</Text>
</TouchableOpacity>
),
});
}, []);
return ( return (
<ScrollView <ScrollView
contentContainerStyle={{ contentContainerStyle={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
paddingBottom: 100,
}} }}
> >
<View className="p-4 flex flex-col gap-y-4"> <View className="p-4 flex flex-col gap-y-4">
{/* <Button <UserInfo />
onPress={() => { <QuickConnect className="mb-4" />
registerBackgroundFetchAsync();
}}
>
registerBackgroundFetchAsync
</Button> */}
<View>
<Text className="font-bold text-lg mb-2">User Info</Text>
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 "> <MediaProvider>
<ListItem title="User" subTitle={user?.Name} /> <MediaToggles className="mb-4" />
<ListItem title="Server" subTitle={api?.basePath} /> <AudioToggles className="mb-4" />
<ListItem title="Token" subTitle={api?.accessToken} /> <SubtitleToggles className="mb-4" />
</View> </MediaProvider>
<Button className="my-2.5" color="black" onPress={logout}>
Log out
</Button>
</View>
<View> <OtherSettings />
<Text className="font-bold text-lg mb-2">Quick connect</Text>
<Button onPress={openQuickConnectAuthCodeInput} color="black">
Authorize
</Button>
</View>
<SettingToggles /> {!Platform.isTV && <DownloadSettings />}
<View className="flex flex-col space-y-2"> <PluginSettings />
<Text className="font-bold text-lg mb-2">Storage</Text>
<View className="mb-4 space-y-2"> <AppLanguageSelector />
{size && <Text>App usage: {bytesToReadable(size.app)}</Text>}
<Progress.Bar <ListGroup title={"Intro"}>
className="bg-gray-100/10" <ListItem
indeterminate={appSizeLoading} onPress={() => {
color="#9333ea" router.push("/intro/page");
width={null} }}
height={10} title={t("home.settings.intro.show_intro")}
borderRadius={6} />
borderWidth={0} <ListItem
progress={size?.used} textColor="red"
onPress={() => {
storage.set("hasShownIntro", false);
}}
title={t("home.settings.intro.reset_intro")}
/>
</ListGroup>
<View className="mb-4">
<ListGroup title={t("home.settings.logs.logs_title")}>
<ListItem
onPress={() => router.push("/settings/logs/page")}
showArrow
title={t("home.settings.logs.logs_title")}
/> />
{size && ( <ListItem
<Text> textColor="red"
Available: {bytesToReadable(size.remaining)}, Total:{" "} onPress={onClearLogsClicked}
{bytesToReadable(size.total)} title={t("home.settings.logs.delete_all_logs")}
</Text> />
)} </ListGroup>
</View>
<Button color="red" onPress={onDeleteClicked}>
Delete all downloaded files
</Button>
<Button color="red" onPress={onClearLogsClicked}>
Delete all logs
</Button>
</View>
<View>
<Text className="font-bold text-lg mb-2">Logs</Text>
<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">No logs available</Text>
)}
</View>
</View> </View>
<StorageSettings />
</View> </View>
</ScrollView> </ScrollView>
); );

View File

@@ -0,0 +1,67 @@
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { Loader } from "@/components/Loader";
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 { 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();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const { t } = useTranslation();
const { data, isLoading: isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({
userId: user?.Id,
});
return response.data.Items || null;
},
});
if (!settings) return null;
if (isLoading)
return (
<View className="mt-4">
<Loader />
</View>
);
return (
<DisabledSetting
disabled={pluginSettings?.hiddenLibraries?.locked === true}
className="px-4"
>
<ListGroup>
{data?.map((view) => (
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
<Switch
value={settings.hiddenLibraries?.includes(view.Id!) || false}
onValueChange={(value) => {
updateSettings({
hiddenLibraries: value
? [...(settings.hiddenLibraries || []), view.Id!]
: settings.hiddenLibraries?.filter((id) => id !== view.Id),
});
}}
/>
</ListItem>
))}
</ListGroup>
<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

@@ -0,0 +1,16 @@
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();
return (
<DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className="p-4"
>
<JellyseerrSettings />
</DisabledSetting>
);
}

View File

@@ -0,0 +1,35 @@
import { Text } from "@/components/common/Text";
import { useLog } from "@/utils/log";
import { ScrollView, View } from "react-native";
import { useTranslation } from "react-i18next";
export default function page() {
const { logs } = useLog();
const { t } = useTranslation();
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>
</ScrollView>
);
}

View File

@@ -0,0 +1,117 @@
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { useSettings } from "@/utils/atoms/settings";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useTranslation } from "react-i18next";
import React, {useEffect, useMemo, useState} from "react";
import {
Linking,
Switch,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { toast } from "sonner-native";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const navigation = useNavigation();
const { t } = useTranslation();
const [settings, updateSettings, pluginSettings] = useSettings();
const queryClient = useQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
const onSave = (val: string) => {
updateSettings({
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
});
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
};
const handleOpenLink = () => {
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
};
const disabled = useMemo(() => {
return pluginSettings?.searchEngine?.locked === true && pluginSettings?.marlinServerUrl?.locked === true
}, [pluginSettings]);
useEffect(() => {
if (!pluginSettings?.marlinServerUrl?.locked) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)}>
<Text className="text-blue-500">{t("home.settings.plugins.marlin_search.save_button")}</Text>
</TouchableOpacity>
),
});
}
}, [navigation, value]);
if (!settings) return null;
return (
<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")}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<Switch
value={settings.searchEngine === "Marlin"}
onValueChange={(value) => {
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
/>
</ListItem>
</DisabledSetting>
</ListGroup>
<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"
>
<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")}
value={value}
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">
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
<Text className="text-blue-500" onPress={handleOpenLink}>
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
</Text>
</Text>
</DisabledSetting>
);
}

View File

@@ -0,0 +1,89 @@
import { Text } from "@/components/common/Text";
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import { getStatistics } from "@/utils/optimize-server";
import { useMutation } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { 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();
const { t } = useTranslation();
const [api] = useAtom(apiAtom);
const [settings, updateSettings, pluginSettings] = useSettings();
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
useState<string>(settings?.optimizedVersionsServerUrl || "");
const saveMutation = useMutation({
mutationFn: async (newVal: string) => {
if (newVal.length === 0 || !newVal.startsWith("http")) {
toast.error(t("home.settings.toasts.invalid_url"));
return;
}
const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/";
updateSettings({
optimizedVersionsServerUrl: updatedUrl,
});
return await getStatistics({
url: settings?.optimizedVersionsServerUrl,
authHeader: api?.accessToken,
deviceId: getOrSetDeviceId(),
});
},
onSuccess: (data) => {
if (data) {
toast.success(t("home.settings.toasts.connected"));
} else {
toast.error(t("home.settings.toasts.could_not_connect"));
}
},
onError: () => {
toast.error(t("home.settings.toasts.could_not_connect"));
},
});
const onSave = (newVal: string) => {
saveMutation.mutate(newVal);
};
useEffect(() => {
if (!pluginSettings?.optimizedVersionsServerUrl?.locked) {
navigation.setOptions({
title: t("home.settings.downloads.optimized_server"),
headerRight: () =>
saveMutation.isPending ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
<Text className="text-blue-500">{t("home.settings.downloads.save_button")}</Text>
</TouchableOpacity>
),
});
}
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
return (
<DisabledSetting
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
className="p-4"
>
<OptimizedServerForm
value={optimizedVersionsServerUrl}
onChangeValue={setOptimizedVersionsServerUrl}
/>
</DisabledSetting>
);
}

View File

@@ -1,128 +0,0 @@
import { Chromecast } from "@/components/Chromecast";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { SongsList } from "@/components/music/SongsList";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import ArtistPoster from "@/components/posters/ArtistPoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function page() {
const searchParams = useLocalSearchParams();
const { collectionId, artistId, albumId } = searchParams as {
collectionId: string;
artistId: string;
albumId: string;
};
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<View className="">
<Chromecast />
</View>
),
});
});
const { data: album } = useQuery({
queryKey: ["album", albumId, artistId],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [albumId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!albumId,
staleTime: 0,
});
const {
data: songs,
isLoading,
isError,
} = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["songs", artistId, albumId],
queryFn: async () => {
if (!api)
return {
Items: [],
TotalRecordCount: 0,
};
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: albumId,
fields: [
"ItemCounts",
"PrimaryImageAspectRatio",
"CanDelete",
"MediaSourceCount",
],
sortBy: ["ParentIndexNumber", "IndexNumber", "SortName"],
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
enabled: !!api && !!user?.Id,
});
const insets = useSafeAreaInsets();
if (!album) return null;
return (
<ParallaxScrollView
headerHeight={400}
headerImage={
<ItemImage
variant={"Primary"}
item={album}
style={{
width: "100%",
height: "100%",
}}
/>
}
>
<View className="px-4 mb-8">
<Text className="font-bold text-2xl mb-2">{album?.Name}</Text>
<Text className="text-neutral-500">
{songs?.TotalRecordCount} songs
</Text>
</View>
<View className="px-4">
<SongsList
albumId={albumId}
songs={songs?.Items}
collectionId={collectionId}
artistId={artistId}
/>
</View>
</ParallaxScrollView>
);
}

View File

@@ -1,130 +0,0 @@
import ArtistPoster from "@/components/posters/ArtistPoster";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ItemImage } from "@/components/common/ItemImage";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
export default function page() {
const searchParams = useLocalSearchParams();
const { artistId } = searchParams as {
artistId: string;
};
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const [startIndex, setStartIndex] = useState<number>(0);
const { data: artist } = useQuery({
queryKey: ["album", artistId],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [artistId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!artistId,
staleTime: 0,
});
const {
data: albums,
isLoading,
isError,
} = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["albums", artistId, startIndex],
queryFn: async () => {
if (!api)
return {
Items: [],
TotalRecordCount: 0,
};
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: artistId,
sortOrder: ["Descending", "Descending", "Ascending"],
includeItemTypes: ["MusicAlbum"],
recursive: true,
fields: [
"ParentId",
"PrimaryImageAspectRatio",
"ParentId",
"PrimaryImageAspectRatio",
],
collapseBoxSetItems: false,
albumArtistIds: [artistId],
startIndex,
limit: 100,
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
enabled: !!api && !!user?.Id,
});
const insets = useSafeAreaInsets();
if (!artist || !albums) return null;
return (
<ParallaxScrollView
headerHeight={400}
headerImage={
<ItemImage
variant={"Primary"}
item={artist}
style={{
width: "100%",
height: "100%",
}}
/>
}
>
<View className="px-4 mb-8">
<Text className="font-bold text-2xl mb-2">{artist?.Name}</Text>
<Text className="text-neutral-500">
{albums.TotalRecordCount} albums
</Text>
</View>
<View className="flex flex-row flex-wrap justify-between px-4">
{albums.Items.map((item, idx) => (
<TouchableItemRouter
item={item}
style={{ width: "30%", marginBottom: 20 }}
key={idx}
>
<View className="flex flex-col gap-y-2">
<ArtistPoster item={item} />
<Text numberOfLines={2}>{item.Name}</Text>
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
</View>
</TouchableItemRouter>
))}
</View>
</ParallaxScrollView>
);
}

View File

@@ -1,117 +0,0 @@
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ArtistPoster from "@/components/posters/ArtistPoster";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getArtistsApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { FlatList, TouchableOpacity, View } from "react-native";
export default function page() {
const searchParams = useLocalSearchParams();
const { collectionId } = searchParams as { collectionId: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: collection } = useQuery({
queryKey: ["collection", collectionId],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [collectionId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!collectionId,
staleTime: 0,
});
const [startIndex, setStartIndex] = useState<number>(0);
const { data, isLoading, isError } = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["collection-items", collection?.Id, startIndex],
queryFn: async () => {
if (!api || !collectionId)
return {
Items: [],
TotalRecordCount: 0,
};
const response = await getArtistsApi(api).getArtists({
sortBy: ["SortName"],
sortOrder: ["Ascending"],
fields: ["PrimaryImageAspectRatio", "SortName"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
parentId: collectionId,
userId: user?.Id,
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
enabled: !!collection?.Id && !!api && !!user?.Id,
});
const totalItems = useMemo(() => {
return data?.TotalRecordCount;
}, [data]);
if (!data) return null;
return (
<FlatList
contentContainerStyle={{
padding: 16,
paddingBottom: 140,
}}
ListHeaderComponent={
<View className="mb-4">
<Text className="font-bold text-3xl mb-2">Artists</Text>
</View>
}
nestedScrollEnabled
data={data.Items}
numColumns={3}
columnWrapperStyle={{
justifyContent: "space-between",
}}
renderItem={({ item, index }) => (
<TouchableItemRouter
style={{
maxWidth: "30%",
width: "100%",
}}
key={index}
item={item}
>
<View className="flex flex-col gap-y-2">
{collection?.CollectionType === "movies" && (
<MoviePoster item={item} />
)}
{collection?.CollectionType === "music" && (
<ArtistPoster item={item} />
)}
<Text>{item.Name}</Text>
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
</View>
</TouchableItemRouter>
)}
keyExtractor={(item) => item.Id || ""}
/>
);
}

View File

@@ -18,10 +18,12 @@ import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { useTranslation } from "react-i18next";
const page: React.FC = () => { const page: React.FC = () => {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { actorId } = local as { actorId: string }; const { actorId } = local as { actorId: string };
const { t } = useTranslation();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
@@ -110,7 +112,7 @@ const page: React.FC = () => {
</View> </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">
Appeared In {t("item_card.appeared_in")}
</Text> </Text>
<InfiniteHorizontalScroll <InfiniteHorizontalScroll
height={247} height={247}

View File

@@ -29,10 +29,11 @@ import {
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { FlatList, View } from "react-native"; import { FlatList, View } from "react-native";
import { useTranslation } from "react-i18next";
const page: React.FC = () => { const page: React.FC = () => {
const searchParams = useLocalSearchParams(); const searchParams = useLocalSearchParams();
@@ -45,6 +46,8 @@ const page: React.FC = () => {
ScreenOrientation.Orientation.PORTRAIT_UP ScreenOrientation.Orientation.PORTRAIT_UP
); );
const { t } = useTranslation();
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom); const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom); const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
@@ -104,9 +107,12 @@ const page: React.FC = () => {
"CanDelete", "CanDelete",
"MediaSourceCount", "MediaSourceCount",
], ],
// true is needed for merged versions
recursive: true,
genres: selectedGenres, genres: selectedGenres,
tags: selectedTags, tags: selectedTags,
years: selectedYears.map((year) => parseInt(year)), years: selectedYears.map((year) => parseInt(year)),
includeItemTypes: ["Movie", "Series"],
}); });
return response.data || null; return response.data || null;
@@ -241,7 +247,7 @@ const page: React.FC = () => {
}} }}
set={setSelectedGenres} set={setSelectedGenres}
values={selectedGenres} values={selectedGenres}
title="Genres" title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase()) item.toLowerCase().includes(search.toLowerCase())
@@ -268,7 +274,7 @@ const page: React.FC = () => {
}} }}
set={setSelectedYears} set={setSelectedYears}
values={selectedYears} values={selectedYears}
title="Years" title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)} searchFilter={(item, search) => item.includes(search)}
/> />
@@ -293,7 +299,7 @@ const page: React.FC = () => {
}} }}
set={setSelectedTags} set={setSelectedTags}
values={selectedTags} values={selectedTags}
title="Tags" title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase()) item.toLowerCase().includes(search.toLowerCase())
@@ -311,7 +317,7 @@ const page: React.FC = () => {
queryFn={async () => sortOptions.map((s) => s.key)} queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy} set={setSortBy}
values={sortBy} values={sortBy}
title="Sort By" title={t("library.filters.sort_by")}
renderItemLabel={(item) => renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || "" sortOptions.find((i) => i.key === item)?.value || ""
} }
@@ -331,7 +337,7 @@ const page: React.FC = () => {
queryFn={async () => sortOrderOptions.map((s) => s.key)} queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder} set={setSortOrder}
values={sortOrder} values={sortOrder}
title="Sort Order" title={t("library.filters.sort_order")}
renderItemLabel={(item) => renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || "" sortOrderOptions.find((i) => i.key === item)?.value || ""
} }
@@ -371,7 +377,7 @@ const page: React.FC = () => {
<FlashList <FlashList
ListEmptyComponent={ ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full"> <View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No results</Text> <Text className="font-bold text-xl text-neutral-500">{t("search.no_results")}</Text>
</View> </View>
} }
extraData={[ extraData={[

View File

@@ -13,11 +13,13 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useTranslation } from "react-i18next";
const Page: React.FC = () => { const Page: React.FC = () => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { id } = useLocalSearchParams() as { id: string }; const { id } = useLocalSearchParams() as { id: string };
const { t } = useTranslation();
const { data: item, isError } = useQuery({ const { data: item, isError } = useQuery({
queryKey: ["item", id], queryKey: ["item", id],
@@ -74,7 +76,7 @@ const Page: React.FC = () => {
if (isError) if (isError)
return ( 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>Could not load item</Text> <Text>{t("item_card.could_not_load_item")}</Text>
</View> </View>
); );

View File

@@ -0,0 +1,95 @@
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";
export default function page() {
const local = useLocalSearchParams();
const {jellyseerrApi} = useJellyseerr();
const {companyId, name, image, type} = local as unknown as {
companyId: string,
name: string,
image: string,
type: DiscoverSliderType
};
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, companyId],
queryFn: async ({pageParam}) => {
let params: any = {
page: Number(pageParam),
};
return jellyseerrApi?.discover(
(
type == DiscoverSliderType.NETWORKS
? Endpoints.DISCOVER_TV_NETWORK
: Endpoints.DISCOVER_MOVIES_STUDIO
) + `/${companyId}`,
params
)
},
enabled: !!jellyseerrApi && !!companyId,
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
1,
staleTime: 0,
});
const flatData = useMemo(
() => 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]
);
return (
<ParallaxSlideShow
data={flatData}
images={backdrops}
listHeader=""
keyExtractor={(item) => item.id.toString()}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage()
}
}}
logo={
<Image
id={companyId}
key={companyId}
className="bottom-1 w-1/2"
source={{
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
}}
cachePolicy={"memory-disk"}
contentFit="contain"
style={{
aspectRatio: "4/3",
}}
/>
}
renderItem={(item, index) =>
<JellyseerrPoster item={item as MovieResult | TvResult} />
}
/>
);
}

View File

@@ -0,0 +1,87 @@
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 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 JellyseerrPoster from "@/components/posters/JellyseerrPoster";
export default function page() {
const local = useLocalSearchParams();
const {jellyseerrApi} = useJellyseerr();
const {genreId, name, type} = local as unknown as {
genreId: string,
name: string,
type: DiscoverSliderType
};
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, genreId],
queryFn: async ({pageParam}) => {
let params: any = {
page: Number(pageParam),
genre: genreId
};
return jellyseerrApi?.discover(
type == DiscoverSliderType.MOVIE_GENRES
? Endpoints.DISCOVER_MOVIES
: Endpoints.DISCOVER_TV,
params
)
},
enabled: !!jellyseerrApi && !!genreId,
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
1,
staleTime: 0,
});
const flatData = useMemo(
() => 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]
);
return (
<ParallaxSlideShow
data={flatData}
images={backdrops}
listHeader=""
keyExtractor={(item) => item.id.toString()}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage()
}
}}
logo={
<Text
className="text-4xl font-bold text-center bottom-1"
style={{
...textShadowStyle.shadow,
shadowRadius: 10
}}>
{name}
</Text>
}
renderItem={(item, index) =>
<JellyseerrPoster item={item as MovieResult | TvResult} />
}
/>
);
}

View File

@@ -0,0 +1,371 @@
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 JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {
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 { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
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 { 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";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const { t } = useTranslation();
const { mediaTitle, releaseYear, posterSrc, ...result } =
params as unknown as {
mediaTitle: string;
releaseYear: number;
canRequest: string;
posterSrc: string;
} & Partial<MovieResult | TvResult>;
const navigation = useNavigation();
const { jellyseerrApi, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>();
const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const {
data: details,
isFetching,
isLoading,
refetch,
} = useQuery({
enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retryOnMount: true,
refetchInterval: 0,
queryFn: async () => {
return result.mediaType === MediaType.MOVIE
? jellyseerrApi?.movieDetails(result.id!!)
: jellyseerrApi?.tvDetails(result.id!!);
},
});
const [canRequest, hasAdvancedRequestPermission] =
useJellyseerrCanRequest(details);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[]
);
const submitIssue = useCallback(() => {
if (result.id && issueType && issueMessage && details) {
jellyseerrApi
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
.then(() => {
setIssueType(undefined);
setIssueMessage(undefined);
bottomSheetModalRef?.current?.close();
});
}
}, [jellyseerrApi, details, result, issueType, issueMessage]);
const request = useCallback(async () => {
const body: MediaRequestBody = {
mediaId: Number(result.id!!),
mediaType: result.mediaType!!,
tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
};
if (hasAdvancedRequestPermission) {
advancedReqModalRef?.current?.present?.(body);
return;
}
requestMedia(mediaTitle, body, refetch);
}, [details, result, requestMedia, hasAdvancedRequestPermission]);
const isAnime = useMemo(
() =>
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
result.mediaType === MediaType.TV,
[details]
);
useEffect(() => {
if (details) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity className="rounded-full p-2 bg-neutral-800/80">
<ItemActions item={details} />
</TouchableOpacity>
),
});
}
}, [details]);
return (
<View
className="flex-1 relative"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<ParallaxScrollView
className="flex-1 opacity-100"
headerHeight={300}
headerImage={
<View>
{result.backdropPath ? (
<Image
cachePolicy={"memory-disk"}
transition={300}
style={{
width: "100%",
height: "100%",
}}
source={{
uri: jellyseerrApi?.imageProxy(
result.backdropPath,
"w1920_and_h800_multi_faces"
),
}}
/>
) : (
<View
style={{
width: "100%",
height: "100%",
}}
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
>
<Ionicons
name="image-outline"
size={24}
color="white"
style={{ opacity: 0.4 }}
/>
</View>
)}
</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} />
<Text
uiTextView
selectable
className="font-bold text-2xl mb-1"
>
{mediaTitle}
</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"
cachePolicy={"memory-disk"}
transition={300}
source={{
uri: posterSrc,
}}
/>
</View>
<View className="mb-4">
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
</View>
{isLoading || isFetching ? (
<Button loading={true} disabled={true} color="purple"></Button>
) : canRequest ? (
<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"
onPress={() => bottomSheetModalRef?.current?.present()}
iconLeft={
<Ionicons name="warning-outline" size={24} color="white" />
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
{t("jellyseerr.report_issue_button")}
</Button>
)}
<OverviewText text={result.overview} className="mt-4" />
</View>
{result.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)
}
/>
)}
<DetailFacts
className="p-2 border border-neutral-800 bg-neutral-900 rounded-xl"
details={details}
/>
<Cast details={details} />
</View>
</View>
</ParallaxScrollView>
<RequestModal
ref={advancedReqModalRef}
title={mediaTitle}
id={result.id!!}
type={result.mediaType as MediaType}
isAnime={isAnime}
onRequested={() => {
advancedReqModalRef?.current?.close();
refetch();
}}
/>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
>
<BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View>
<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">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<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}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side="bottom"
align="center"
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>
{t("jellyseerr.types")}
</DropdownMenu.Label>
{Object.entries(IssueTypeName)
.reverse()
.map(([key, value], idx) => (
<DropdownMenu.Item
key={value}
onSelect={() =>
setIssueType(key as unknown as IssueType)
}
>
<DropdownMenu.ItemTitle>
{value}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
<BottomSheetTextInput
multiline
maxLength={254}
style={{ color: "white" }}
clearButtonMode="always"
placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor="#9CA3AF"
// Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668
defaultValue={issueMessage}
onChangeText={setIssueMessage}
/>
</View>
</View>
<Button className="mt-auto" onPress={submitIssue} color="purple">
{t("jellyseerr.submit_button")}
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</View>
);
};
export default Page;

View File

@@ -0,0 +1,110 @@
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 ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
import { useTranslation } from "react-i18next";
export default function page() {
const local = useLocalSearchParams();
const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser } = useJellyseerr();
const { personId } = local as { personId: string };
const { data, isLoading, isFetching } = useQuery({
queryKey: ["jellyseerr", "person", personId],
queryFn: async () => ({
details: await jellyseerrApi?.personDetails(personId),
combinedCredits: await jellyseerrApi?.personCombinedCredits(personId),
}),
enabled: !!jellyseerrApi && !!personId,
});
const locale = useMemo(() => {
return jellyseerrUser?.settings?.locale || "en";
}, [jellyseerrUser]);
const region = useMemo(
() => jellyseerrUser?.settings?.region || "US",
[jellyseerrUser]
);
const castedRoles: PersonCreditCast[] = useMemo(
() =>
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]
);
return (
<ParallaxSlideShow
data={castedRoles}
images={backdrops}
listHeader={t("jellyseerr.appearances")}
keyExtractor={(item) => item.id.toString()}
logo={
<Image
key={data?.details?.id}
id={data?.details?.id.toString()}
className="rounded-full bottom-1"
source={{
uri: jellyseerrApi?.imageProxy(
data?.details?.profilePath,
"w600_and_h600_bestv2"
),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
width: 125,
height: 125,
}}
/>
}
HeaderContent={() => (
<>
<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(
`${locale}-${region}`,
{
year: "numeric",
month: "long",
day: "numeric",
}
)}{" "}
| {data?.details?.placeOfBirth}
</Text>
</>
)}
MainContent={() => (
<OverviewText text={data?.details?.biography} className="mt-4" />
)}
renderItem={(item, index) => <JellyseerrPoster item={item as MovieResult | TvResult} />}
/>
);
}

View File

@@ -17,6 +17,7 @@ import {
View, View,
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
const HOUR_HEIGHT = 30; const HOUR_HEIGHT = 30;
const ITEMS_PER_PAGE = 20; const ITEMS_PER_PAGE = 20;
@@ -177,6 +178,7 @@ const PageButtons: React.FC<PageButtonsProps> = ({
onNextPage, onNextPage,
isNextDisabled, isNextDisabled,
}) => { }) => {
const { t } = useTranslation();
return ( 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 <TouchableOpacity
@@ -194,7 +196,7 @@ const PageButtons: React.FC<PageButtonsProps> = ({
currentPage === 1 ? "text-gray-500" : "text-white" currentPage === 1 ? "text-gray-500" : "text-white"
}`} }`}
> >
Previous {t("live_tv.previous")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<Text className="text-white">Page {currentPage}</Text> <Text className="text-white">Page {currentPage}</Text>
@@ -206,7 +208,7 @@ const PageButtons: React.FC<PageButtonsProps> = ({
<Text <Text
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`} className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
> >
Next {t("live_tv.next")}
</Text> </Text>
<Ionicons <Ionicons
name="chevron-forward" name="chevron-forward"

View File

@@ -7,12 +7,15 @@ import { useAtom } from "jotai";
import React from "react"; import React from "react";
import { ScrollView, View } from "react-native"; import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
export default function page() { export default function page() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useTranslation();
return ( return (
<ScrollView <ScrollView
nestedScrollEnabled nestedScrollEnabled
@@ -28,7 +31,7 @@ export default function page() {
<View className="flex flex-col space-y-2"> <View className="flex flex-col space-y-2">
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "recommended"]} queryKey={["livetv", "recommended"]}
title={"On now"} title={t("live_tv.on_now")}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getRecommendedPrograms({ const res = await getLiveTvApi(api).getRecommendedPrograms({
@@ -46,7 +49,7 @@ export default function page() {
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "shows"]} queryKey={["livetv", "shows"]}
title={"Shows"} title={t("live_tv.shows")}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({ const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -68,7 +71,7 @@ export default function page() {
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "movies"]} queryKey={["livetv", "movies"]}
title={"Movies"} title={t("live_tv.movies")}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({ const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -86,7 +89,7 @@ export default function page() {
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "sports"]} queryKey={["livetv", "sports"]}
title={"Sports"} title={t("live_tv.sports")}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({ const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -104,7 +107,7 @@ export default function page() {
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "kids"]} queryKey={["livetv", "kids"]}
title={"For Kids"} title={t("live_tv.for_kids")}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({ const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -122,7 +125,7 @@ export default function page() {
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "news"]} queryKey={["livetv", "news"]}
title={"News"} title={t("live_tv.news")}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({ const res = await getLiveTvApi(api).getLiveTvPrograms({

View File

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

View File

@@ -1,8 +1,9 @@
import { Text } from "@/components/common/Text"; import { AddToFavorites } from "@/components/AddToFavorites";
import { DownloadItems } from "@/components/DownloadItem"; import { DownloadItems } from "@/components/DownloadItem";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
import { NextUp } from "@/components/series/NextUp"; import { NextUp } from "@/components/series/NextUp";
import { SeasonPicker } from "@/components/series/SeasonPicker"; import { SeasonPicker } from "@/components/series/SeasonPicker";
import { SeriesHeader } from "@/components/series/SeriesHeader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
@@ -15,9 +16,11 @@ import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useEffect, useMemo } from "react"; import React, { useEffect, useMemo } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { useTranslation } from "react-i18next";
const page: React.FC = () => { const page: React.FC = () => {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as { const { id: seriesId, seasonIndex } = params as {
id: string; id: string;
@@ -35,7 +38,6 @@ const page: React.FC = () => {
userId: user?.Id, userId: user?.Id,
itemId: seriesId, itemId: seriesId,
}), }),
enabled: !!seriesId && !!api,
staleTime: 60 * 1000, staleTime: 60 * 1000,
}); });
@@ -70,6 +72,7 @@ const page: React.FC = () => {
}); });
return res?.data.Items || []; return res?.data.Items || [];
}, },
staleTime: 60,
enabled: !!api && !!user?.Id && !!item?.Id, enabled: !!api && !!user?.Id && !!item?.Id,
}); });
@@ -77,11 +80,14 @@ const page: React.FC = () => {
navigation.setOptions({ navigation.setOptions({
headerRight: () => headerRight: () =>
!isLoading && !isLoading &&
item &&
allEpisodes && allEpisodes &&
allEpisodes.length > 0 && ( allEpisodes.length > 0 && (
<View className="flex flex-row items-center space-x-2"> <View className="flex flex-row items-center space-x-2">
<AddToFavorites item={item} type="series" />
<DownloadItems <DownloadItems
title="Download Series" size="large"
title={t("item_card.download.download_series")}
items={allEpisodes || []} items={allEpisodes || []}
MissingDownloadIconComponent={() => ( MissingDownloadIconComponent={() => (
<Ionicons name="download" size={22} color="white" /> <Ionicons name="download" size={22} color="white" />
@@ -97,7 +103,7 @@ const page: React.FC = () => {
</View> </View>
), ),
}); });
}, [allEpisodes, isLoading]); }, [allEpisodes, isLoading, item]);
if (!item || !backdropUrl) return null; if (!item || !backdropUrl) return null;
@@ -133,10 +139,7 @@ const page: React.FC = () => {
} }
> >
<View className="flex flex-col pt-4"> <View className="flex flex-col pt-4">
<View className="px-4 py-4"> <SeriesHeader item={item} />
<Text className="text-3xl font-bold">{item?.Name}</Text>
<Text className="">{item?.Overview}</Text>
</View>
<View className="mb-4"> <View className="mb-4">
<NextUp seriesId={seriesId} /> <NextUp seriesId={seriesId} />
</View> </View>

View File

@@ -1,6 +1,6 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react"; import React, { useCallback, useEffect, useMemo } from "react";
import { FlatList, useWindowDimensions, View } from "react-native"; import { FlatList, useWindowDimensions, View } from "react-native";
@@ -41,7 +41,7 @@ import {
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { colletionTypeToItemType } from "@/utils/collectionTypeToItemType"; import { useTranslation } from "react-i18next";
const Page = () => { const Page = () => {
const searchParams = useLocalSearchParams(); const searchParams = useLocalSearchParams();
@@ -63,6 +63,8 @@ const Page = () => {
const { orientation } = useOrientation(); const { orientation } = useOrientation();
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference); const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sop) { if (sop) {
@@ -141,8 +143,6 @@ const Page = () => {
}): Promise<BaseItemDtoQueryResult | null> => { }): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !library) return null; if (!api || !library) return null;
console.log("[libraryId] ~", library);
let itemType: BaseItemKind | undefined; let itemType: BaseItemKind | undefined;
// This fix makes sure to only return 1 type of items, if defined. // This fix makes sure to only return 1 type of items, if defined.
@@ -151,6 +151,8 @@ const Page = () => {
itemType = "Movie"; itemType = "Movie";
} else if (library.CollectionType === "tvshows") { } else if (library.CollectionType === "tvshows") {
itemType = "Series"; itemType = "Series";
} else if (library.CollectionType === "boxsets") {
itemType = "BoxSet";
} }
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({
@@ -161,7 +163,8 @@ const Page = () => {
sortBy: [sortBy[0], "SortName", "ProductionYear"], sortBy: [sortBy[0], "SortName", "ProductionYear"],
sortOrder: [sortOrder[0]], sortOrder: [sortOrder[0]],
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"], enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
recursive: false, // true is needed for merged versions
recursive: true,
imageTypeLimit: 1, imageTypeLimit: 1,
fields: ["PrimaryImageAspectRatio", "SortName"], fields: ["PrimaryImageAspectRatio", "SortName"],
genres: selectedGenres, genres: selectedGenres,
@@ -298,7 +301,7 @@ const Page = () => {
}} }}
set={setSelectedGenres} set={setSelectedGenres}
values={selectedGenres} values={selectedGenres}
title="Genres" title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase()) item.toLowerCase().includes(search.toLowerCase())
@@ -325,7 +328,7 @@ const Page = () => {
}} }}
set={setSelectedYears} set={setSelectedYears}
values={selectedYears} values={selectedYears}
title="Years" title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)} searchFilter={(item, search) => item.includes(search)}
/> />
@@ -350,7 +353,7 @@ const Page = () => {
}} }}
set={setSelectedTags} set={setSelectedTags}
values={selectedTags} values={selectedTags}
title="Tags" title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase()) item.toLowerCase().includes(search.toLowerCase())
@@ -368,7 +371,7 @@ const Page = () => {
queryFn={async () => sortOptions.map((s) => s.key)} queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy} set={setSortBy}
values={sortBy} values={sortBy}
title="Sort By" title={t("library.filters.sort_by")}
renderItemLabel={(item) => renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || "" sortOptions.find((i) => i.key === item)?.value || ""
} }
@@ -388,7 +391,7 @@ const Page = () => {
queryFn={async () => sortOrderOptions.map((s) => s.key)} queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder} set={setSortOrder}
values={sortOrder} values={sortOrder}
title="Sort Order" title={t("library.filters.sort_order")}
renderItemLabel={(item) => renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || "" sortOrderOptions.find((i) => i.key === item)?.value || ""
} }
@@ -434,7 +437,7 @@ const Page = () => {
if (flatData.length === 0) if (flatData.length === 0)
return ( return (
<View className="h-full w-full flex justify-center items-center"> <View className="h-full w-full flex justify-center items-center">
<Text className="text-lg text-neutral-500">No items found</Text> <Text className="text-lg text-neutral-500">{t("library.no_items_found")}</Text>
</View> </View>
); );
@@ -443,7 +446,7 @@ const Page = () => {
key={orientation} key={orientation}
ListEmptyComponent={ ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full"> <View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No results</Text> <Text className="font-bold text-xl text-neutral-500">{t("library.no_results")}</Text>
</View> </View>
} }
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"

View File

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

View File

@@ -10,9 +10,10 @@ import {
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { StyleSheet, View } from "react-native"; import { StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
export default function index() { export default function index() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
@@ -20,23 +21,29 @@ export default function index() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [settings] = useSettings(); const [settings] = useSettings();
const { t } = useTranslation();
const { data, isLoading: isLoading } = useQuery({ const { data, isLoading: isLoading } = useQuery({
queryKey: ["user-views", user?.Id], queryKey: ["user-views", user?.Id],
queryFn: async () => { queryFn: async () => {
if (!api || !user?.Id) { const response = await getUserViewsApi(api!).getUserViews({
return null; userId: user?.Id,
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
}); });
return response.data.Items || null; return response.data.Items || null;
}, },
enabled: !!api && !!user?.Id, staleTime: 60,
staleTime: 60 * 1000 * 60,
}); });
const libraries = useMemo(
() =>
data
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
.filter((l) => l.CollectionType !== "music")
.filter((l) => l.CollectionType !== "books") || [],
[data, settings?.hiddenLibraries]
);
useEffect(() => { useEffect(() => {
for (const item of data || []) { for (const item of data || []) {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
@@ -63,10 +70,10 @@ export default function index() {
</View> </View>
); );
if (!data) if (!libraries)
return ( return (
<View className="h-full w-full flex justify-center items-center"> <View className="h-full w-full flex justify-center items-center">
<Text className="text-lg text-neutral-500">No libraries found</Text> <Text className="text-lg text-neutral-500">{t("library.no_libraries_found")}</Text>
</View> </View>
); );
@@ -81,7 +88,7 @@ export default function index() {
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
}} }}
data={data} data={libraries}
renderItem={({ item }) => <LibraryItemCard library={item} />} renderItem={({ item }) => <LibraryItemCard library={item} />}
keyExtractor={(item) => item.Id || ""} keyExtractor={(item) => item.Id || ""}
ItemSeparatorComponent={() => ItemSeparatorComponent={() =>

View File

@@ -1,8 +1,13 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import {
commonScreenOptions,
nestedTabPageScreenOptions,
} from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
export default function SearchLayout() { export default function SearchLayout() {
const { t } = useTranslation();
return ( return (
<Stack> <Stack>
<Stack.Screen <Stack.Screen
@@ -10,7 +15,10 @@ export default function SearchLayout() {
options={{ options={{
headerShown: true, headerShown: true,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: "Search", headerTitle: t("tabs.search"),
headerLargeStyle: {
backgroundColor: "black",
},
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false, headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false, headerShadowVisible: false,
@@ -29,6 +37,19 @@ export default function SearchLayout() {
headerShadowVisible: false, headerShadowVisible: false,
}} }}
/> />
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
<Stack.Screen
name="jellyseerr/person/[personId]"
options={commonScreenOptions}
/>
<Stack.Screen
name="jellyseerr/company/[companyId]"
options={commonScreenOptions}
/>
<Stack.Screen
name="jellyseerr/genre/[genreId]"
options={commonScreenOptions}
/>
</Stack> </Stack>
); );
} }

View File

@@ -2,14 +2,16 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Tag } from "@/components/GenreTags";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader"; import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage";
import AlbumCover from "@/components/posters/AlbumCover";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster"; import SeriesPoster from "@/components/posters/SeriesPoster";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { import {
BaseItemDto, BaseItemDto,
BaseItemKind, BaseItemKind,
@@ -29,6 +31,9 @@ import React, {
import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { useTranslation } from "react-i18next";
type SearchType = "Library" | "Discover";
const exampleSearches = [ const exampleSearches = [
"Lord of the rings", "Lord of the rings",
@@ -43,8 +48,11 @@ export default function search() {
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { q, prev } = params as { q: string; prev: Href<string> }; const { t } = useTranslation();
const { q } = params as { q: string };
const [searchType, setSearchType] = useState<SearchType>("Library");
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
const [debouncedSearch] = useDebounce(search, 500); const [debouncedSearch] = useDebounce(search, 500);
@@ -53,6 +61,7 @@ export default function search() {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [settings] = useSettings(); const [settings] = useSettings();
const { jellyseerrApi } = useJellyseerr();
const searchEngine = useMemo(() => { const searchEngine = useMemo(() => {
return settings?.searchEngine || "Jellyfin"; return settings?.searchEngine || "Jellyfin";
@@ -83,6 +92,7 @@ export default function search() {
return (searchApi.data.SearchHints as BaseItemDto[]) || []; return (searchApi.data.SearchHints as BaseItemDto[]) || [];
} else { } else {
if (!settings?.marlinServerUrl) return []; if (!settings?.marlinServerUrl) return [];
const url = `${ const url = `${
settings.marlinServerUrl settings.marlinServerUrl
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types }/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
@@ -90,6 +100,7 @@ export default function search() {
.join("&includeItemTypes=")}`; .join("&includeItemTypes=")}`;
const response1 = await axios.get(url); const response1 = await axios.get(url);
const ids = response1.data.ids; const ids = response1.data.ids;
if (!ids || !ids.length) return []; if (!ids || !ids.length) return [];
@@ -111,18 +122,17 @@ export default function search() {
const navigation = useNavigation(); const navigation = useNavigation();
useLayoutEffect(() => { useLayoutEffect(() => {
if (Platform.OS === "ios") navigation.setOptions({
navigation.setOptions({ headerSearchBarOptions: {
headerSearchBarOptions: { placeholder: t("search.search"),
placeholder: "Search...", onChangeText: (e: any) => {
onChangeText: (e: any) => { router.setParams({ q: "" });
router.setParams({ q: "" }); setSearch(e.nativeEvent.text);
setSearch(e.nativeEvent.text);
},
hideWhenScrolling: false,
autoFocus: true,
}, },
}); hideWhenScrolling: false,
autoFocus: true,
},
});
}, [navigation]); }, [navigation]);
const { data: movies, isFetching: l1 } = useQuery({ const { data: movies, isFetching: l1 } = useQuery({
@@ -132,7 +142,7 @@ export default function search() {
query: debouncedSearch, query: debouncedSearch,
types: ["Movie"], types: ["Movie"],
}), }),
enabled: debouncedSearch.length > 0, enabled: searchType === "Library" && debouncedSearch.length > 0,
}); });
const { data: series, isFetching: l2 } = useQuery({ const { data: series, isFetching: l2 } = useQuery({
@@ -142,7 +152,7 @@ export default function search() {
query: debouncedSearch, query: debouncedSearch,
types: ["Series"], types: ["Series"],
}), }),
enabled: debouncedSearch.length > 0, enabled: searchType === "Library" && debouncedSearch.length > 0,
}); });
const { data: episodes, isFetching: l3 } = useQuery({ const { data: episodes, isFetching: l3 } = useQuery({
@@ -152,7 +162,7 @@ export default function search() {
query: debouncedSearch, query: debouncedSearch,
types: ["Episode"], types: ["Episode"],
}), }),
enabled: debouncedSearch.length > 0, enabled: searchType === "Library" && debouncedSearch.length > 0,
}); });
const { data: collections, isFetching: l7 } = useQuery({ const { data: collections, isFetching: l7 } = useQuery({
@@ -162,7 +172,7 @@ export default function search() {
query: debouncedSearch, query: debouncedSearch,
types: ["BoxSet"], types: ["BoxSet"],
}), }),
enabled: debouncedSearch.length > 0, enabled: searchType === "Library" && debouncedSearch.length > 0,
}); });
const { data: actors, isFetching: l8 } = useQuery({ const { data: actors, isFetching: l8 } = useQuery({
@@ -172,55 +182,22 @@ export default function search() {
query: debouncedSearch, query: debouncedSearch,
types: ["Person"], types: ["Person"],
}), }),
enabled: debouncedSearch.length > 0, enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: artists, isFetching: l4 } = useQuery({
queryKey: ["search", "artists", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["MusicArtist"],
}),
enabled: debouncedSearch.length > 0,
});
const { data: albums, isFetching: l5 } = useQuery({
queryKey: ["search", "albums", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["MusicAlbum"],
}),
enabled: debouncedSearch.length > 0,
});
const { data: songs, isFetching: l6 } = useQuery({
queryKey: ["search", "songs", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["Audio"],
}),
enabled: debouncedSearch.length > 0,
}); });
const noResults = useMemo(() => { const noResults = useMemo(() => {
return !( return !(
artists?.length ||
albums?.length ||
songs?.length ||
movies?.length || movies?.length ||
episodes?.length || episodes?.length ||
series?.length || series?.length ||
collections?.length || collections?.length ||
actors?.length actors?.length
); );
}, [artists, episodes, albums, songs, movies, series, collections, actors]); }, [episodes, movies, series, collections, actors]);
const loading = useMemo(() => { const loading = useMemo(() => {
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8; return l1 || l2 || l3 || l7 || l8;
}, [l1, l2, l3, l4, l5, l6, l7, l8]); }, [l1, l2, l3, l7, l8]);
return ( return (
<> <>
@@ -232,230 +209,151 @@ export default function search() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<View className="flex flex-col pt-2"> <View className="flex flex-col">
{Platform.OS === "android" && ( {jellyseerrApi && (
<View className="mb-4 px-4"> <View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
<Input <TouchableOpacity onPress={() => setSearchType("Library")}>
autoCorrect={false} <Tag
returnKeyType="done" text={t("search.library")}
keyboardType="web-search" textClass="p-1"
placeholder="Search here..." className={
value={search} searchType === "Library" ? "bg-purple-600" : undefined
onChangeText={(text) => setSearch(text)} }
/>
</TouchableOpacity>
<TouchableOpacity onPress={() => setSearchType("Discover")}>
<Tag
text={t("search.discover")}
textClass="p-1"
className={
searchType === "Discover" ? "bg-purple-600" : undefined
}
/>
</TouchableOpacity>
</View>
)}
<View className="mt-2">
<LoadingSkeleton isLoading={loading} />
</View>
{searchType === "Library" ? (
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
<SearchItemWrapper
header={t("search.movies")}
ids={movies?.map((m) => m.Id!)}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
className="flex flex-col w-28 mr-2"
item={item}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={series?.map((m) => m.Id!)}
header={t("search.series")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28 mr-2"
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={episodes?.map((m) => m.Id!)}
header={t("search.episodes")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-44 mr-2"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={collections?.map((m) => m.Id!)}
header={t("search.collections")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28 mr-2"
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={actors?.map((m) => m.Id!)}
header={t("search.actors")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/> />
</View> </View>
) : (
<JellyserrIndexPage searchQuery={debouncedSearch} />
)} )}
{!!q && (
<View className="px-4 flex flex-col space-y-2"> {searchType === "Library" && (
<Text className="text-neutral-500 "> <>
Results for <Text className="text-purple-600">{q}</Text> {!loading && noResults && debouncedSearch.length > 0 ? (
</Text> <View>
</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}
</>
)} )}
<SearchItemWrapper
header="Movies"
ids={movies?.map((m) => m.Id!)}
renderItem={(item) => (
<TouchableItemRouter
key={item.Id}
className="flex flex-col w-28 mr-2"
item={item}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={series?.map((m) => m.Id!)}
header="Series"
renderItem={(item) => (
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28 mr-2"
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={episodes?.map((m) => m.Id!)}
header="Episodes"
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-44 mr-2"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={collections?.map((m) => m.Id!)}
header="Collections"
renderItem={(item) => (
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28 mr-2"
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={actors?.map((m) => m.Id!)}
header="Actors"
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={artists?.map((m) => m.Id!)}
header="Artists"
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={albums?.map((m) => m.Id!)}
header="Albums"
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={songs?.map((m) => m.Id!)}
header="Songs"
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.AlbumId} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
{loading ? (
<View className="mt-4 flex justify-center items-center">
<Loader />
</View>
) : noResults && debouncedSearch.length > 0 ? (
<View>
<Text className="text-center text-lg font-bold mt-4">
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> </View>
</ScrollView> </ScrollView>
</> </>
); );
} }
type Props = {
ids?: string[] | null;
renderItem: (item: BaseItemDto) => React.ReactNode;
header?: string;
};
const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data, isLoading: l1 } = useQuery({
queryKey: ["items", ids],
queryFn: async () => {
if (!user?.Id || !api || !ids || ids.length === 0) {
return [];
}
const itemPromises = ids.map((id) =>
getUserItemData({
api,
userId: user.Id,
itemId: id,
})
);
const results = await Promise.all(itemPromises);
// Filter out null items
return results.filter(
(item) => item !== null
) as unknown as BaseItemDto[];
},
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
staleTime: Infinity,
});
if (!data) return null;
return (
<>
<Text className="font-bold text-lg px-4 mb-2">{header}</Text>
<ScrollView
horizontal
className="px-4 mb-2"
showsHorizontalScrollIndicator={false}
>
{data.map((item) => renderItem(item))}
</ScrollView>
</>
);
};

View File

@@ -1,7 +1,8 @@
import React from "react"; import React, { useCallback, useRef } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
import { withLayoutContext } from "expo-router"; import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
import { import {
createNativeBottomTabNavigator, createNativeBottomTabNavigator,
@@ -13,12 +14,13 @@ const { Navigator } = createNativeBottomTabNavigator();
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs"; import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useSettings } from "@/utils/atoms/settings";
import { storage } from "@/utils/mmkv";
import type { import type {
ParamListBase, ParamListBase,
TabNavigationState, TabNavigationState,
} from "@react-navigation/native"; } from "@react-navigation/native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { useSettings } from "@/utils/atoms/settings";
export const NativeTabs = withLayoutContext< export const NativeTabs = withLayoutContext<
BottomTabNavigationOptions, BottomTabNavigationOptions,
@@ -29,11 +31,29 @@ export const NativeTabs = withLayoutContext<
export default function TabLayout() { export default function TabLayout() {
const [settings] = useSettings(); const [settings] = useSettings();
const { t } = useTranslation();
const router = useRouter();
useFocusEffect(
useCallback(() => {
const hasShownIntro = storage.getBoolean("hasShownIntro");
if (!hasShownIntro) {
const timer = setTimeout(() => {
router.push("/intro/page");
}, 1000);
return () => {
clearTimeout(timer);
};
}
}, [])
);
return ( return (
<> <>
<SystemBars hidden={false} style="light" /> <SystemBars hidden={false} style="light" />
<NativeTabs <NativeTabs
sidebarAdaptable sidebarAdaptable={false}
ignoresTopSafeArea ignoresTopSafeArea
barTintColor={Platform.OS === "android" ? "#121212" : undefined} barTintColor={Platform.OS === "android" ? "#121212" : undefined}
tabBarActiveTintColor={Colors.primary} tabBarActiveTintColor={Colors.primary}
@@ -43,46 +63,74 @@ export default function TabLayout() {
<NativeTabs.Screen <NativeTabs.Screen
name="(home)" name="(home)"
options={{ options={{
title: "Home", title: t("tabs.home"),
tabBarIcon: tabBarIcon:
Platform.OS == "android" Platform.OS == "android"
? ({ color, focused, size }) => ? ({ color, focused, size }) =>
require("@/assets/icons/house.fill.png") require("@/assets/icons/house.fill.png")
: () => ({ sfSymbol: "house" }), : ({ focused }) =>
focused
? { sfSymbol: "house.fill" }
: { sfSymbol: "house" },
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
name="(search)" name="(search)"
options={{ options={{
title: "Search", title: t("tabs.search"),
tabBarIcon: tabBarIcon:
Platform.OS == "android" Platform.OS == "android"
? ({ color, focused, size }) => ? ({ color, focused, size }) =>
require("@/assets/icons/magnifyingglass.png") require("@/assets/icons/magnifyingglass.png")
: () => ({ sfSymbol: "magnifyingglass" }), : ({ focused }) =>
focused
? { sfSymbol: "magnifyingglass" }
: { sfSymbol: "magnifyingglass" },
}}
/>
<NativeTabs.Screen
name="(favorites)"
options={{
title: t("tabs.favorites"),
tabBarIcon:
Platform.OS == "android"
? ({ color, focused, size }) =>
focused
? require("@/assets/icons/heart.fill.png")
: require("@/assets/icons/heart.png")
: ({ focused }) =>
focused
? { sfSymbol: "heart.fill" }
: { sfSymbol: "heart" },
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
name="(libraries)" name="(libraries)"
options={{ options={{
title: "Library", title: t("tabs.library"),
tabBarIcon: tabBarIcon:
Platform.OS == "android" Platform.OS == "android"
? ({ color, focused, size }) => ? ({ color, focused, size }) =>
require("@/assets/icons/server.rack.png") require("@/assets/icons/server.rack.png")
: () => ({ sfSymbol: "rectangle.stack" }), : ({ focused }) =>
focused
? { sfSymbol: "rectangle.stack.fill" }
: { sfSymbol: "rectangle.stack" },
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
name="(custom-links)" name="(custom-links)"
options={{ options={{
title: "Custom Links", title: t("tabs.custom_links"),
// @ts-expect-error // @ts-expect-error
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true, tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
tabBarIcon: tabBarIcon:
Platform.OS == "android" Platform.OS == "android"
? () => require("@/assets/icons/list.png") ? ({ focused }) => require("@/assets/icons/list.png")
: () => ({ sfSymbol: "list.dash" }), : ({ focused }) =>
focused
? { sfSymbol: "list.dash.fill" }
: { sfSymbol: "list.dash" },
}} }}
/> />
</NativeTabs> </NativeTabs>

View File

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

View File

@@ -3,32 +3,31 @@ import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls"; import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener"; import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets"; import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules/vlc-player"; import { VlcPlayerView } from "@/modules/vlc-player";
import { import {
PipStartedPayload,
PlaybackStatePayload, PlaybackStatePayload,
ProgressUpdatePayload, ProgressUpdatePayload,
VlcPlayerViewRef, VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types"; } from "@/modules/vlc-player/src/VlcPlayer.types";
import { useDownload } from "@/providers/DownloadProvider"; // import { useDownload } from "@/providers/DownloadProvider";
const downloadProvider = !Platform.isTV
? require("@/providers/DownloadProvider")
: null;
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import native from "@/utils/profiles/native"; import native from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { import {
getPlaystateApi, getPlaystateApi,
getUserLibraryApi, getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics"; import { useHaptic } from "@/hooks/useHaptic";
import { useFocusEffect, useGlobalSearchParams } from "expo-router"; import { useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { import React, {
useCallback, useCallback,
@@ -37,22 +36,20 @@ import React, {
useState, useState,
useEffect, useEffect,
} from "react"; } from "react";
import { import { Alert, View, AppState, AppStateStatus, Platform } from "react-native";
Alert,
BackHandler,
View,
AppState,
AppStateStatus,
Platform,
} from "react-native";
import { useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
import settings from "../(tabs)/(home)/settings";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client";
export default function page() { export default function page() {
console.log("Direct Player");
const videoRef = useRef<VlcPlayerViewRef>(null); const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const { t } = useTranslation();
const navigation = useNavigation();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
@@ -60,17 +57,23 @@ export default function page() {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true); const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false); const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [isPipStarted, setIsPipStarted] = useState(false);
const progress = useSharedValue(0); const progress = useSharedValue(0);
const isSeeking = useSharedValue(false); const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0); const cacheProgress = useSharedValue(0);
let getDownloadedItem = null;
if (!Platform.isTV) {
getDownloadedItem = downloadProvider.useDownload();
}
const { getDownloadedItem } = useDownload();
const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light");
const setShowControls = useCallback((show: boolean) => { const setShowControls = useCallback((show: boolean) => {
_setShowControls(show); _setShowControls(show);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
}, []); }, []);
const { const {
@@ -104,9 +107,8 @@ export default function page() {
} = useQuery({ } = useQuery({
queryKey: ["item", itemId], queryKey: ["item", itemId],
queryFn: async () => { queryFn: async () => {
console.log("Offline:", offline); if (offline && !Platform.isTV) {
if (offline) { const item = await getDownloadedItem.getDownloadedItem(itemId);
const item = await getDownloadedItem(itemId);
if (item) return item.item; if (item) return item.item;
} }
@@ -121,98 +123,102 @@ export default function page() {
staleTime: 0, staleTime: 0,
}); });
const { const [stream, setStream] = useState<{
data: stream, mediaSource: MediaSourceInfo;
isLoading: isLoadingStreamUrl, url: string;
isError: isErrorStreamUrl, sessionId: string | undefined;
} = useQuery({ } | null>(null);
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue], const [isLoadingStream, setIsLoadingStream] = useState(true);
queryFn: async () => { const [isErrorStream, setIsErrorStream] = useState(false);
console.log("Offline:", offline);
if (offline) {
const data = await getDownloadedItem(itemId);
if (!data?.mediaSource) return null;
const url = await getDownloadedFileUrl(data.item.Id!); useEffect(() => {
const fetchStream = async () => {
setIsLoadingStream(true);
setIsErrorStream(false);
if (item) try {
return { if (offline && !Platform.isTV) {
mediaSource: data.mediaSource, const data = await getDownloadedItem.getDownloadedItem(itemId);
url, if (!data?.mediaSource) {
sessionId: undefined, setStream(null);
}; return;
}
const url = await getDownloadedFileUrl(data.item.Id!);
if (item) {
setStream({
mediaSource: data.mediaSource as MediaSourceInfo,
url,
sessionId: undefined,
});
return;
}
}
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: native,
});
if (!res) {
setStream(null);
return;
}
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
setStream(null);
return;
}
setStream({
mediaSource,
sessionId,
url,
});
} catch (error) {
console.error("Error fetching stream:", error);
setIsErrorStream(true);
setStream(null);
} finally {
setIsLoadingStream(false);
} }
};
const res = await getStreamUrl({ fetchStream();
api, }, [itemId, mediaSourceId]);
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: native,
});
if (!res) return null;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert("Error", "Failed to get stream url");
return null;
}
return {
mediaSource,
sessionId,
url,
};
},
enabled: !!itemId && !!item,
staleTime: 0,
});
const togglePlay = useCallback(async () => { const togglePlay = useCallback(async () => {
if (!api) return; if (!api) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
if (isPlaying) { if (isPlaying) {
await videoRef.current?.pause(); await videoRef.current?.pause();
if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.value),
isPaused: true,
playMethod: stream.url?.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
console.log("Actually marked as paused");
} else { } else {
videoRef.current?.play(); videoRef.current?.play();
if (!offline && stream) { }
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!, if (!offline && stream) {
audioStreamIndex: audioIndex ? audioIndex : undefined, await getPlaystateApi(api).onPlaybackProgress({
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, itemId: item?.Id!,
mediaSourceId: mediaSourceId, audioStreamIndex: audioIndex ? audioIndex : undefined,
positionTicks: msToTicks(progress.value), subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
isPaused: false, mediaSourceId: mediaSourceId,
playMethod: stream?.url.includes("m3u8") positionTicks: msToTicks(progress.get()),
? "Transcode" isPaused: !isPlaying,
: "DirectStream", playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId, playSessionId: stream.sessionId,
}); });
}
} }
}, [ }, [
isPlaying, isPlaying,
@@ -224,13 +230,13 @@ export default function page() {
subtitleIndex, subtitleIndex,
mediaSourceId, mediaSourceId,
offline, offline,
progress.value, progress,
]); ]);
const reportPlaybackStopped = useCallback(async () => { const reportPlaybackStopped = useCallback(async () => {
if (offline) return; if (offline) return;
const currentTimeInTicks = msToTicks(progress.value); const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({ await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!, itemId: item?.Id!,
@@ -248,25 +254,9 @@ export default function page() {
videoRef.current?.stop(); videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]); }, [videoRef, reportPlaybackStopped]);
// TODO: unused should remove.
const reportPlaybackStart = useCallback(async () => {
if (offline) return;
if (!stream) return;
await getPlaystateApi(api!).onPlaybackStart({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
});
}, [api, item, mediaSourceId, stream]);
const onProgress = useCallback( const onProgress = useCallback(
async (data: ProgressUpdatePayload) => { async (data: ProgressUpdatePayload) => {
if (isSeeking.value === true) return; if (isSeeking.get() || isPlaybackStopped) return;
if (isPlaybackStopped === true) return;
const { currentTime } = data.nativeEvent; const { currentTime } = data.nativeEvent;
@@ -274,7 +264,7 @@ export default function page() {
setIsBuffering(false); setIsBuffering(false);
} }
progress.value = currentTime; progress.set(currentTime);
if (offline) return; if (offline) return;
@@ -282,13 +272,6 @@ export default function page() {
if (!item?.Id || !stream) return; if (!item?.Id || !stream) return;
console.log(
"onProgress ~",
currentTimeInTicks,
isPlaying,
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
);
await getPlaystateApi(api!).onPlaybackProgress({ await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id, itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined, audioStreamIndex: audioIndex ? audioIndex : undefined,
@@ -300,12 +283,9 @@ export default function page() {
playSessionId: stream.sessionId, playSessionId: stream.sessionId,
}); });
}, },
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex] [item?.Id, isSeeking, api, isPlaybackStopped, audioIndex, subtitleIndex]
); );
useOrientation();
useOrientationSettings();
useWebSocket({ useWebSocket({
isPlaying: isPlaying, isPlaying: isPlaying,
togglePlay: togglePlay, togglePlay: togglePlay,
@@ -313,6 +293,11 @@ export default function page() {
offline, offline,
}); });
const onPipStarted = useCallback((e: PipStartedPayload) => {
const { pipStarted } = e.nativeEvent;
setIsPipStarted(pipStarted);
}, []);
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => { const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent; const { state, isBuffering, isPlaying } = e.nativeEvent;
@@ -342,28 +327,13 @@ export default function page() {
: 0; : 0;
}, [item]); }, [item]);
useFocusEffect(
React.useCallback(() => {
return async () => {
stop();
console.log("Unmounted");
};
}, [])
);
const [appState, setAppState] = useState(AppState.currentState); const [appState, setAppState] = useState(AppState.currentState);
useEffect(() => { useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => { const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (appState.match(/inactive|background/) && nextAppState === "active") { // Handle app going to the background
console.log("App has come to the foreground!"); if (nextAppState.match(/inactive|background/)) {
// Handle app coming to the foreground _setShowControls(false);
} else if (nextAppState.match(/inactive|background/)) {
console.log("App has gone to the background!");
// Handle app going to the background
if (videoRef.current && videoRef.current.pause) {
videoRef.current.pause();
}
} }
setAppState(nextAppState); setAppState(nextAppState);
}; };
@@ -378,67 +348,71 @@ export default function page() {
// Cleanup the event listener when the component is unmounted // Cleanup the event listener when the component is unmounted
subscription.remove(); subscription.remove();
}; };
}, [appState]); }, [appState, isPipStarted, isPlaying]);
// Preselection of audio and subtitle tracks. // Preselection of audio and subtitle tracks.
if (!settings) return null; if (!settings) return null;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`]; let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
let externalTrack = { name: "", DeliveryUrl: "" };
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle"
) || [];
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex
);
const allAudio = const allAudio =
stream?.mediaSource.MediaStreams?.filter( stream?.mediaSource.MediaStreams?.filter(
(audio) => audio.Type === "Audio" (audio) => audio.Type === "Audio"
) || []; ) || [];
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle"
) || [];
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex
);
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex); const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
// Direct playback CASE const notTranscoding = !stream?.mediaSource.TranscodingUrl;
if (!bitrateValue) { if (
// If Subtitle is embedded we can use the position to select it straight away. chosenSubtitleTrack &&
if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) { (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`); ) {
} else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) { const finalIndex = notTranscoding
// If Subtitle is external we need to pass the URL to the player. ? allSubs.indexOf(chosenSubtitleTrack)
externalTrack = { : textSubs.indexOf(chosenSubtitleTrack);
name: chosenSubtitleTrack.DisplayTitle || "", initOptions.push(`--sub-track=${finalIndex}`);
DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
};
}
if (chosenAudioTrack)
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
} else {
// Transcoded playback CASE
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
externalTrack = {
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
DeliveryUrl: "",
};
}
} }
if (!item || isLoadingItem || isLoadingStreamUrl || !stream) if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
const insets = useSafeAreaInsets();
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => {
beforeRemoveListener();
};
}, [navigation]);
if (!item || isLoadingItem || !stream)
return ( return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black"> <View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader /> <Loader />
</View> </View>
); );
if (isErrorItem || isErrorStreamUrl) if (isErrorItem || isErrorStream)
return ( return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black"> <View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">Error</Text> <Text className="text-white">{t("player.error")}</Text>
</View> </View>
); );
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
}));
return ( return (
<View style={{ flex: 1, backgroundColor: "black" }}> <View style={{ flex: 1, backgroundColor: "black" }}>
<View <View
@@ -449,23 +423,25 @@ export default function page() {
position: "relative", position: "relative",
flexDirection: "column", flexDirection: "column",
justifyContent: "center", justifyContent: "center",
opacity: showControls ? (Platform.OS === "android" ? 0.7 : 0.5) : 1, paddingLeft: ignoreSafeAreas ? 0 : insets.left,
paddingRight: ignoreSafeAreas ? 0 : insets.right,
}} }}
> >
<VlcPlayerView <VlcPlayerView
ref={videoRef} ref={videoRef}
source={{ source={{
uri: stream.url, uri: stream?.url || "",
autoplay: true, autoplay: true,
isNetwork: true, isNetwork: true,
startPosition, startPosition,
externalTrack, externalSubtitles,
initOptions, initOptions,
}} }}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
onVideoProgress={onProgress} onVideoProgress={onProgress}
progressUpdateInterval={1000} progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged} onVideoStateChange={onPlaybackStateChanged}
onPipStarted={onPipStarted}
onVideoLoadStart={() => {}} onVideoLoadStart={() => {}}
onVideoLoadEnd={() => { onVideoLoadEnd={() => {
setIsVideoLoaded(true); setIsVideoLoaded(true);
@@ -473,8 +449,8 @@ export default function page() {
onVideoError={(e) => { onVideoError={(e) => {
console.error("Video Error:", e.nativeEvent); console.error("Video Error:", e.nativeEvent);
Alert.alert( Alert.alert(
"Error", t("player.error"),
"An error occurred while playing the video. Check logs in settings." t("player.an_error_occured_while_playing_the_video")
); );
writeToLog("ERROR", "Video Error", e.nativeEvent); writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}
@@ -496,6 +472,7 @@ export default function page() {
setIgnoreSafeAreas={setIgnoreSafeAreas} setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas} ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded} isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef?.current?.startPictureInPicture}
play={videoRef.current?.play} play={videoRef.current?.play}
pause={videoRef.current?.pause} pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo} seek={videoRef.current?.seekTo}
@@ -513,22 +490,3 @@ export default function page() {
</View> </View>
); );
} }
export function usePoster(
item: BaseItemDto,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!item || !api) return undefined;
return item.Type === "Audio"
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: item,
quality: 70,
width: 200,
});
}, [item, api]);
return poster ?? undefined;
}

View File

@@ -1,420 +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 { useWebSocket } from "@/hooks/useWebsockets";
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 { 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 * as Haptics from "expo-haptics";
import { Image } from "expo-image";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Pressable, useWindowDimensions, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, { OnProgressData, VideoRef } from "react-native-video";
export default function page() {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [settings] = useSettings();
const videoRef = useRef<VideoRef | null>(null);
const windowDimensions = useWindowDimensions();
const firstTime = useRef(true);
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 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) return;
const res = await getUserLibraryApi(api).getItem({
itemId,
userId: user?.Id,
});
return res.data;
},
enabled: !!itemId && !!api,
staleTime: 0,
});
const {
data: stream,
isLoading: isLoadingStreamUrl,
isError: isErrorStreamUrl,
} = useQuery({
queryKey: ["stream-url"],
queryFn: async () => {
if (!api) return;
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
});
if (!res) return null;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) return null;
return {
mediaSource,
sessionId,
url,
};
},
});
const poster = usePoster(item, api);
const videoSource = useVideoSource(item, api, poster, stream?.url);
const togglePlay = useCallback(
async (ticks: number) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
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(ticks),
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(ticks),
isPaused: false,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream?.sessionId,
});
}
},
[
isPlaying,
api,
item,
videoRef,
settings,
audioIndex,
subtitleIndex,
mediaSourceId,
stream,
]
);
const play = useCallback(() => {
console.log("play");
videoRef.current?.resume();
reportPlaybackStart();
}, [videoRef]);
const pause = useCallback(() => {
console.log("play");
videoRef.current?.pause();
}, [videoRef]);
const stop = useCallback(() => {
console.log("stop");
setIsPlaybackStopped(true);
videoRef.current?.pause();
reportPlaybackStopped();
}, [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,
});
};
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 = data.currentTime * 10000000;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
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,
audioIndex,
subtitleIndex,
mediaSourceId,
stream,
]
);
useFocusEffect(
useCallback(() => {
play();
return () => {
stop();
};
}, [play, stop])
);
useOrientation();
useOrientationSettings();
useWebSocket({
isPlaying: isPlaying,
pauseVideo: pause,
playVideo: play,
stopPlayback: 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">Error</Text>
</View>
);
if (!item || !stream)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">Error</Text>
</View>
);
return (
<View
style={{
width: windowDimensions.width,
height: windowDimensions.height,
position: "relative",
}}
className="flex flex-col items-center justify-center"
>
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
<Image
source={poster}
style={{ width: "100%", height: "100%", resizeMode: "contain" }}
/>
</View>
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
className="absolute z-0 h-full w-full opacity-0"
>
{videoSource && (
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={() => {}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
progressUpdateInterval={500}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
setIsPlaying(state.isPlaying);
}}
/>
)}
</Pressable>
<Controls
item={item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
enableTrickplay={false}
pause={pause}
play={play}
seek={seek}
isVlc={false}
stop={stop}
/>
</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: {
artist: item?.AlbumArtist ?? undefined,
title: item?.Name || "Unknown",
description: item?.Overview ?? undefined,
imageUri: poster,
subtitle: item?.Album ?? undefined,
},
};
}, [item, api, poster]);
return videoSource;
}

View File

@@ -1,560 +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 * as Haptics from "expo-haptics";
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";
const Player = () => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [settings] = useSettings();
const videoRef = useRef<VideoRef | null>(null);
const firstTime = useRef(true);
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
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);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}, []);
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 () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
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);
console.log(
"onProgress ~",
ticks,
isPlaying,
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
);
// TODO: Use this when streaming with HLS url, but NOT when direct playing
// TODO: since playable duration is always 0 then.
setIsBuffering(data.playableDuration === 0);
if (!item?.Id || data.currentTime === 0) {
return;
}
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.round(ticks),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
},
[
item,
isPlaying,
api,
isPlaybackStopped,
isSeeking,
stream,
mediaSourceId,
audioIndex,
subtitleIndex,
]
);
useOrientation();
useOrientationSettings();
useWebSocket({
isPlaying: isPlaying,
togglePlay: togglePlay,
stopPlayback: stop,
offline: false,
});
const [selectedTextTrack, setSelectedTextTrack] = useState<
SelectedTrack | undefined
>();
const [embededTextTracks, setEmbededTextTracks] = useState<
{
index: number;
language?: string | undefined;
selected?: boolean | undefined;
title?: string | undefined;
type: any;
}[]
>([]);
const [audioTracks, setAudioTracks] = useState<TrackInfo[]>([]);
const [selectedAudioTrack, setSelectedAudioTrack] = useState<
SelectedTrack | undefined
>(undefined);
useEffect(() => {
if (selectedTextTrack === undefined) {
const subtitleHelper = new SubtitleHelper(
stream?.mediaSource.MediaStreams ?? []
);
const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex(
subtitleIndex!
);
// Most likely the subtitle is burned in.
if (embeddedTrackIndex === -1) return;
console.log(
"Setting selected text track",
subtitleIndex,
embeddedTrackIndex
);
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">Error</Text>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: "black" }}>
<View
style={{
display: "flex",
width: "100%",
height: "100%",
position: "relative",
flexDirection: "column",
justifyContent: "center",
opacity: showControls ? 0.5 : 1,
}}
>
{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}
pictureInPicture={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) => {
console.log("onAudioTracks: ", e.audioTracks);
setAudioTracks(
e.audioTracks.map((t) => ({
index: t.index,
name: t.title ?? "",
language: t.language,
}))
);
}}
selectedTextTrack={selectedTextTrack}
selectedAudioTrack={selectedAudioTrack}
/>
</>
) : (
<Text>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) => {
console.log("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: {
artist: item?.AlbumArtist ?? undefined,
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,13 +1,10 @@
import { Link, Stack, usePathname } from "expo-router"; import { Link, Stack } from "expo-router";
import { StyleSheet } from "react-native"; import { StyleSheet } from "react-native";
import { ThemedText } from "@/components/ThemedText"; import { ThemedText } from "@/components/ThemedText";
import { ThemedView } from "@/components/ThemedView"; import { ThemedView } from "@/components/ThemedView";
import { useEffect } from "react";
export default function NotFoundScreen() { export default function NotFoundScreen() {
const pathname = usePathname();
return ( return (
<> <>
<Stack.Screen options={{ title: "Oops!" }} /> <Stack.Screen options={{ title: "Oops!" }} />

View File

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

View File

@@ -1,14 +1,16 @@
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { import {
Alert, Alert,
KeyboardAvoidingView, KeyboardAvoidingView,
@@ -19,12 +21,11 @@ import {
} from "react-native"; } from "react-native";
import { z } from "zod"; import { z } from "zod";
import { t } from 'i18next';
const CredentialsSchema = z.object({ const CredentialsSchema = z.object({
username: z.string().min(1, "Username is required"), username: z.string().min(1, t("login.username_required")),});
});
const Login: React.FC = () => { const Login: React.FC = () => {
const { setServer, login, removeServer, initiateQuickConnect } = const { setServer, login, removeServer, initiateQuickConnect } =
useJellyfin(); useJellyfin();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
@@ -38,7 +39,6 @@ const Login: React.FC = () => {
const [serverURL, setServerURL] = useState<string>(_apiUrl); const [serverURL, setServerURL] = useState<string>(_apiUrl);
const [serverName, setServerName] = useState<string>(""); const [serverName, setServerName] = useState<string>("");
const [error, setError] = useState<string>("");
const [credentials, setCredentials] = useState<{ const [credentials, setCredentials] = useState<{
username: string; username: string;
password: string; password: string;
@@ -76,8 +76,10 @@ const Login: React.FC = () => {
onPress={() => { onPress={() => {
removeServer(); removeServer();
}} }}
className="flex flex-row items-center"
> >
<Ionicons name="chevron-back" size={24} color="white" /> <Ionicons name="chevron-back" size={18} color={Colors.primary} />
<Text className="ml-2 text-purple-600">{t("login.change_server")}</Text>
</TouchableOpacity> </TouchableOpacity>
) : null, ) : null,
}); });
@@ -94,9 +96,9 @@ const Login: React.FC = () => {
} }
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
setError(error.message); Alert.alert(t("login.connection_failed"), error.message);
} else { } else {
setError("An unexpected error occurred"); Alert.alert(t("login.connection_failed"), t("login.an_unexpected_error_occured"));
} }
} finally { } finally {
setLoading(false); setLoading(false);
@@ -120,7 +122,7 @@ const Login: React.FC = () => {
* - Sets loadingServerCheck state to true at the beginning and false at the end. * - Sets loadingServerCheck state to true at the beginning and false at the end.
* - Logs errors and timeout information to the console. * - Logs errors and timeout information to the console.
*/ */
async function checkUrl(url: string) { const checkUrl = useCallback(async (url: string) => {
setLoadingServerCheck(true); setLoadingServerCheck(true);
try { try {
@@ -130,15 +132,18 @@ const Login: React.FC = () => {
if (response.ok) { if (response.ok) {
const data = (await response.json()) as PublicSystemInfo; const data = (await response.json()) as PublicSystemInfo;
setServerName(data.ServerName || ""); setServerName(data.ServerName || "");
return url; return url;
} }
return undefined;
} catch {
return undefined; return undefined;
} finally { } finally {
setLoadingServerCheck(false); setLoadingServerCheck(false);
} }
} }, []);
/** /**
* Handles the connection attempt to a Jellyfin server. * Handles the connection attempt to a Jellyfin server.
@@ -156,159 +161,168 @@ const Login: React.FC = () => {
* - Sets the server address using `setServer` if the connection is successful. * - Sets the server address using `setServer` if the connection is successful.
* *
*/ */
const handleConnect = async (url: string) => { const handleConnect = useCallback(async (url: string) => {
url = url.trim(); url = url.trim().replace(/\/$/, "");
const result = await checkUrl(url); const result = await checkUrl(url);
if (result === undefined) { if (result === undefined) {
Alert.alert( Alert.alert(
"Connection failed", t("login.connection_failed"),
"Could not connect to the server. Please check the URL and your network connection." t("login.could_not_connect_to_server")
); );
return; return;
} }
setServer({ address: url }); setServer({ address: url });
}; }, []);
const handleQuickConnect = async () => { const handleQuickConnect = async () => {
try { try {
const code = await initiateQuickConnect(); const code = await initiateQuickConnect();
if (code) { if (code) {
Alert.alert("Quick Connect", `Enter code ${code} to login`, [ Alert.alert(t("login.quick_connect"), t("login.enter_code_to_login", {code: code}), [
{ {
text: "Got It", text: t("login.got_it"),
}, },
]); ]);
} }
} catch (error) { } catch (error) {
Alert.alert("Error", "Failed to initiate Quick Connect"); Alert.alert(t("login.error_title"), t("login.failed_to_initiate_quick_connect"));
} }
}; };
if (api?.basePath) { return (
return ( <SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
<SafeAreaView style={{ flex: 1 }}> <KeyboardAvoidingView
<KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : "height"}
behavior={Platform.OS === "ios" ? "padding" : "height"} >
style={{ flex: 1, height: "100%" }} {api?.basePath ? (
> <>
<View className="flex flex-col h-full relative items-center justify-center"> <View className="flex flex-col h-full relative items-center justify-center">
<View className="px-4 -mt-20 w-full"> <View className="px-4 -mt-20 w-full">
<View className="flex flex-col space-y-2"> <View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold -mb-2"> <Text className="text-2xl font-bold -mb-2">
Log in
<> <>
{serverName ? ( {serverName ? (
<> <>
{" to "} {t("login.login_to_title") + " "}
<Text className="text-purple-600">{serverName}</Text> <Text className="text-purple-600">{serverName}</Text>
</> </>
) : null} ) : t("login.login_title")}
</> </>
</Text> </Text>
<Text className="text-xs text-neutral-400">{serverURL}</Text> <Text className="text-xs text-neutral-400">
<Input {api.basePath}
placeholder="Username" </Text>
onChangeText={(text) => <Input
setCredentials({ ...credentials, username: text }) placeholder={t("login.username_placeholder")}
} onChangeText={(text) =>
value={credentials.username} setCredentials({ ...credentials, username: text })
autoFocus }
secureTextEntry={false} value={credentials.username}
keyboardType="default" autoFocus
returnKeyType="done" secureTextEntry={false}
autoCapitalize="none" keyboardType="default"
textContentType="username" returnKeyType="done"
clearButtonMode="while-editing" autoCapitalize="none"
maxLength={500} textContentType="username"
/> clearButtonMode="while-editing"
maxLength={500}
/>
<Input <Input
className="mb-2" placeholder={t("login.password_placeholder")}
placeholder="Password" onChangeText={(text) =>
onChangeText={(text) => setCredentials({ ...credentials, password: text })
setCredentials({ ...credentials, password: text }) }
} value={credentials.password}
value={credentials.password} secureTextEntry
secureTextEntry keyboardType="default"
keyboardType="default" returnKeyType="done"
returnKeyType="done" autoCapitalize="none"
autoCapitalize="none" textContentType="password"
textContentType="password" clearButtonMode="while-editing"
clearButtonMode="while-editing" maxLength={500}
maxLength={500} />
/> <View className="flex flex-row items-center justify-between">
<Button
onPress={handleLogin}
loading={loading}
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"
>
<MaterialCommunityIcons
name="cellphone-lock"
size={24}
color="white"
/>
</TouchableOpacity>
</View>
</View>
</View> </View>
<Text className="text-red-600 mb-2">{error}</Text> <View className="absolute bottom-0 left-0 w-full px-4 mb-2"></View>
</View> </View>
</>
<View className="absolute bottom-0 left-0 w-full px-4 mb-2"> ) : (
<Button <>
color="black" <View className="flex flex-col h-full items-center justify-center w-full">
onPress={handleQuickConnect} <View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
className="w-full mb-2" <Image
> style={{
Use Quick Connect width: 100,
</Button> height: 100,
<Button onPress={handleLogin} loading={loading}> marginLeft: -23,
Log in marginBottom: -20,
</Button> }}
source={require("@/assets/images/StreamyFinFinal.png")}
/>
<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"
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
maxLength={500}
/>
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)}
className="w-full grow"
>
{t("server.connect_button")}
</Button>
<JellyfinServerDiscovery
onServerSelect={(server) => {
setServerURL(server.address);
if (server.serverName) {
setServerName(server.serverName);
}
handleConnect(server.address);
}}
/>
<PreviousServersList
onServerSelect={(s) => {
handleConnect(s.address);
}}
/>
</View>
</View> </View>
</View> </>
</KeyboardAvoidingView> )}
</SafeAreaView>
);
}
return (
<SafeAreaView style={{ flex: 1 }}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1, height: "100%" }}
>
<View className="flex flex-col h-full relative items-center justify-center w-full">
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
}}
source={require("@/assets/images/StreamyFinFinal.png")}
/>
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="text-neutral-500">
Enter the URL to your Jellyfin server
</Text>
<Input
placeholder="Server URL"
onChangeText={setServerURL}
value={serverURL}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
maxLength={500}
/>
<Text className="text-xs text-neutral-500">
Make sure to include http or https
</Text>
</View>
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)}
className="w-full grow"
>
Connect
</Button>
</View>
</View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</SafeAreaView> </SafeAreaView>
); );

BIN
assets/icons/heart.fill.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
assets/icons/heart.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -0,0 +1,65 @@
<svg
type="certified"
viewBox="0 0 80 80"
preserveAspectRatio="xMidYMid"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<g transform="translate(2.29, 0)">
<path
d="M42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.6297143,21.1451429 51.5085714,21.4605714 51.3097143,21.408 C47.8902857,20.4868571 42.5577143,25.0217143 39.1017143,22.0891429 C39.008,22.9485714 38.2331429,27.0857143 32.3314286,26.4731429 C32.192,26.4594286 32.1371429,26.304 32.24,26.2171429 C33.1542857,25.44 34.2765714,23.2891429 33.3142857,21.9154286 C30.3108571,23.9085714 28.7565714,23.9954286 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.9222857 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.312 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8354286 C28.224,15.3188571 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.7862857 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6925714 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857"
id="Fill-2"
fill="#00912D"
></path>
<mask id="mask-2" fill="white">
<polygon
points="0.137142857 0.921142857 75.0534777 0.921142857 75.0534777 79.8628571 0.137142857 79.8628571"
></polygon>
</mask>
<path
d="M13.0491429,59.1817143 C9.90628571,55.3554286 7.86971429,50.576 7.51771429,44.9622857 C6.912,35.2342857 10.2354286,26.0845714 23.1794286,21.4834286 C23.1908571,21.5245714 23.1725714,21.5748571 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.92 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.3097143 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8331429 C28.224,15.3165714 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.784 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6902857 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.328,20.8502857 51.1337143,20.7245714 50.9508571,20.5874286 C60.2765714,23.504 66.7474286,30.1531429 67.44,41.2251429 C67.8811429,48.2948571 65.5702857,54.3885714 61.568,59.1154286 C62.784,59.2891429 63.9931429,59.4925714 65.2045714,59.6937143 C70.304,53.4537143 73.2502857,45.5428571 73.2502857,37.056 C73.2502857,17.7165714 57.5337143,2.56685714 37.472,2.56685714 C17.4102857,2.56685714 1.69371429,17.7165714 1.69371429,37.056 C1.69371429,45.5565714 4.64,53.472 9.744,59.7097143 C10.8434286,59.5268571 11.9451429,59.3462857 13.0491429,59.1817143"
fill="#FFD700"
mask="url(#mask-2)"
></path>
<path
d="M9.744,59.7097143 C4.64,53.472 1.69371429,45.5565714 1.69371429,37.056 C1.69371429,17.7165714 17.4102857,2.56685714 37.472,2.56685714 C57.5337143,2.56685714 73.2502857,17.7165714 73.2502857,37.056 C73.2502857,45.5428571 70.304,53.4537143 65.2045714,59.6937143 C65.8125714,59.7942857 66.4205714,59.8742857 67.0285714,59.984 C71.9497143,53.6457143 74.8937143,45.6982857 74.8937143,37.056 C74.8937143,16.3862857 58.1394286,0.921142857 37.472,0.921142857 C16.8022857,0.921142857 0.048,16.3862857 0.048,37.056 C0.048,45.7074286 2.99885714,53.6594286 7.92914286,59.9977143 C8.53257143,59.8902857 9.13828571,59.8102857 9.744,59.7097143"
fill="#FA6E0F"
mask="url(#mask-2)"
></path>
<path
d="M58.2857143,74.9394286 C62.3748571,75.1954286 65.7874286,77.2137143 67.8468571,79.9474286 C67.9131429,80.0182857 68.0114286,80.016 68.0411429,79.9382857 C68.7451429,77.0971429 68.9394286,74.0662857 68.5851429,71.0125714 C68.5874286,70.9805714 68.6125714,70.9577143 68.6537143,70.9485714 C70.576,70.3428571 72.7017143,70.0137143 74.9645714,70.0457143 C75.0857143,70.0594286 75.0834286,69.9405714 74.9554286,69.8194286 C72.5577143,67.4994286 69.6297143,65.6914286 66.416,64.5417143 C65.3051429,67.68 64.2217143,70.816 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286 L58.2857143,74.9394286"
fill="#0AC855"
mask="url(#mask-2)"
></path>
<path
d="M62.9645714,74.0434286 L58.2857143,74.9394286 C58.2857143,74.9394286 58.3451429,74.512 58.528,73.3325714 C60.9417143,73.6754286 62.9645714,74.0434286 62.9645714,74.0434286"
fill="#0B4902"
></path>
<g transform="translate(0, 20.57)">
<mask id="mask-4" fill="white">
<polygon
points="0.137142857 0.016 67.4935952 0.016 67.4935952 59.2914286 0.137142857 59.2914286"
></polygon>
</mask>
<path
d="M13.0765714,38.6057143 C29.1177143,36.2605714 45.5222857,36.2354286 61.568,38.544 C65.5702857,33.8171429 67.8811429,27.7234286 67.44,20.6537143 C66.7474286,9.58171429 60.2765714,2.93257143 50.9508571,0.016 C51.1337143,0.153142857 51.328,0.278857143 51.4902857,0.434285714 C51.6297143,0.573714286 51.5085714,0.889142857 51.3097143,0.836571429 C47.8902857,-0.0845714286 42.5577143,4.45028571 39.1017143,1.51771429 C39.008,2.37485714 38.2331429,6.51428571 32.3314286,5.90171429 C32.192,5.888 32.1371429,5.73257143 32.24,5.64571429 C33.1542857,4.86857143 34.2765714,2.71542857 33.3142857,1.344 C30.3108571,3.33714286 28.7565714,3.424 23.2182857,1.024 C23.1725714,1.00342857 23.1908571,0.953142857 23.1794286,0.912 C10.2354286,5.51314286 6.912,14.6628571 7.51771429,24.3908571 C7.86971429,30.0091429 9.93142857,34.7748571 13.0765714,38.6057143"
fill="#FA3200"
mask="url(#mask-4)"
></path>
<path
d="M12.0868571,53.472 C12,53.488 11.9154286,53.4514286 11.8948571,53.392 C10.8274286,50.2445714 9.73485714,47.0971429 8.62171429,43.9611429 C5.41028571,45.1108571 2.49371429,46.9302857 0.0982857143,49.248 C-0.0297142857,49.3691429 -0.032,49.488 0.0891428571,49.4742857 C2.352,49.4422857 4.47771429,49.7714286 6.4,50.3771429 C6.44114286,50.3862857 6.46628571,50.4091429 6.46857143,50.4411429 C6.11428571,53.4948571 6.30857143,56.5257143 7.01257143,59.3668571 C7.04228571,59.4445714 7.14057143,59.4468571 7.20685714,59.376 C9.26628571,56.6422857 12.6742857,54.624 16.7657143,54.368 L12.0868571,53.472"
fill="#0AC855"
mask="url(#mask-4)"
></path>
</g>
<path
d="M62.9645714,74.0434286 C46.192,71.104 28.8571429,71.104 12.0868571,74.0434286 C12,74.0594286 11.9154286,74.0228571 11.8948571,73.9634286 C10.3428571,69.3851429 8.74285714,64.8182857 7.09257143,60.2628571 C7.06971429,60.1988571 7.14057143,60.1257143 7.248,60.1074286 C27.1885714,56.464 47.8605714,56.464 67.8034286,60.1074286 C67.9108571,60.1257143 67.9817143,60.1988571 67.9565714,60.2628571 C66.3085714,64.8182857 64.7085714,69.3851429 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286"
fill="#00912D"
></path>
<path
d="M12.0868571,74.0434286 L16.7657143,74.9394286 C16.7657143,74.9394286 16.704,74.512 16.5211429,73.3325714 C14.1074286,73.6754286 12.0868571,74.0434286 12.0868571,74.0434286"
fill="#0B4902"
></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.4 KiB

46
augmentations/api.ts Normal file
View File

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

4
augmentations/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from "./api";
export * from "./mmkv";
export * from "./number";
export * from "./string";

22
augmentations/mmkv.ts Normal file
View File

@@ -0,0 +1,22 @@
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
}
}
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.set(key, JSON.stringify(value));
}
}

35
augmentations/number.ts Normal file
View File

@@ -0,0 +1,35 @@
declare global {
interface Number {
bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number;
minutesToMilliseconds(): number;
hoursToMilliseconds(): number;
}
}
Number.prototype.bytesToReadable = function (decimals: number = 2) {
const bytes = this.valueOf();
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 i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
Number.prototype.secondsToMilliseconds = function () {
return this.valueOf() * 1000;
};
Number.prototype.minutesToMilliseconds = function () {
return this.valueOf() * (60).secondsToMilliseconds();
};
Number.prototype.hoursToMilliseconds = function () {
return this.valueOf() * (60).minutesToMilliseconds();
};
export {};

16
augmentations/string.ts Normal file
View File

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

2934
bun.lock Normal file

File diff suppressed because it is too large Load Diff

BIN
bun.lockb

Binary file not shown.

View File

@@ -0,0 +1,114 @@
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";
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"] });
},
});
return (
<View {...props}>
<RoundButton
size="large"
icon={isFavorite ? "heart" : "heart-outline"}
fillColor={isFavorite ? "primary" : undefined}
onPress={() => {
if (isFavorite) {
unmarkFavoriteMutation.mutate();
} else {
markFavoriteMutation.mutate();
}
}}
/>
</View>
);
};

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import * as Haptics from "expo-haptics"; import { useHaptic } from "@/hooks/useHaptic";
import React, { PropsWithChildren, ReactNode, useMemo } from "react"; import React, { PropsWithChildren, ReactNode, useMemo } from "react";
import { Text, TouchableOpacity, View } from "react-native"; import { Platform, Text, TouchableOpacity, View } from "react-native";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
export interface ButtonProps export interface ButtonProps
@@ -37,31 +37,35 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
case "red": case "red":
return "bg-red-600"; return "bg-red-600";
case "black": case "black":
return "bg-neutral-900 border border-neutral-800"; return "bg-neutral-900";
case "transparent": case "transparent":
return "bg-transparent"; return "bg-transparent";
} }
}, [color]); }, [color]);
const lightHapticFeedback = useHaptic("light");
return ( return (
<TouchableOpacity <TouchableOpacity
className={` className={`
p-3 rounded-xl items-center justify-center p-3 rounded-xl items-center justify-center
${loading || (disabled && "opacity-50")} ${(loading || disabled) && "opacity-50"}
${colorClasses} ${colorClasses}
${className} ${className}
`} `}
onPress={() => { onPress={() => {
if (!loading && !disabled && onPress) { if (!loading && !disabled && onPress) {
onPress(); onPress();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
} }
}} }}
disabled={disabled || loading} disabled={disabled || loading}
{...props} {...props}
> >
{loading ? ( {loading ? (
<Loader /> <View className="p-0.5">
<Loader />
</View>
) : ( ) : (
<View <View
className={` className={`

View File

@@ -1,5 +1,4 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import React, { useCallback, useEffect } from "react"; import React, { useCallback, useEffect } from "react";
import { Platform, TouchableOpacity, ViewProps } from "react-native"; import { Platform, TouchableOpacity, ViewProps } from "react-native";
import GoogleCast, { import GoogleCast, {
@@ -18,12 +17,12 @@ interface Props extends ViewProps {
background?: "blur" | "transparent"; background?: "blur" | "transparent";
} }
export const Chromecast: React.FC<Props> = ({ export function Chromecast({
width = 48, width = 48,
height = 48, height = 48,
background = "transparent", background = "transparent",
...props ...props
}) => { }) {
const client = useRemoteMediaClient(); const client = useRemoteMediaClient();
const castDevice = useCastDevice(); const castDevice = useCastDevice();
const devices = useDevices(); const devices = useDevices();
@@ -34,6 +33,7 @@ export const Chromecast: React.FC<Props> = ({
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (!discoveryManager) { if (!discoveryManager) {
console.warn("DiscoveryManager is not initialized");
return; return;
} }
@@ -64,6 +64,7 @@ export const Chromecast: React.FC<Props> = ({
}} }}
{...props} {...props}
> >
<AndroidCastButton />
<Feather name="cast" size={22} color={"white"} /> <Feather name="cast" size={22} color={"white"} />
</RoundButton> </RoundButton>
); );
@@ -77,7 +78,8 @@ export const Chromecast: React.FC<Props> = ({
}} }}
{...props} {...props}
> >
<AndroidCastButton />
<Feather name="cast" size={22} color={"white"} /> <Feather name="cast" size={22} color={"white"} />
</RoundButton> </RoundButton>
); );
}; }

View File

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

View File

View File

@@ -49,6 +49,11 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
else 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.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`;
}, [item]); }, [item]);
const progress = useMemo(() => { const progress = useMemo(() => {

View File

@@ -2,7 +2,7 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue"; import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings"; import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server"; import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
@@ -32,6 +32,7 @@ import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle"; import ProgressCircle from "./ProgressCircle";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
import { t } from "i18next";
interface DownloadProps extends ViewProps { interface DownloadProps extends ViewProps {
items: BaseItemDto[]; items: BaseItemDto[];
@@ -55,6 +56,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [queue, setQueue] = useAtom(queueAtom); const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings(); const [settings] = useSettings();
const { processes, startBackgroundDownload, downloadedFiles } = useDownload(); const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
const { startRemuxing } = useRemuxHlsToMp4(); const { startRemuxing } = useRemuxHlsToMp4();
@@ -64,7 +66,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1); const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] = const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0); useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({ const [maxBitrate, setMaxBitrate] = useState<Bitrate>(settings?.defaultBitrate ?? {
key: "Max", key: "Max",
value: undefined, value: undefined,
}); });
@@ -74,7 +76,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
[user] [user]
); );
const usingOptimizedServer = useMemo( const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === "optimized", () => settings?.downloadMethod === DownloadMethod.Optimized,
[settings] [settings]
); );
@@ -160,7 +162,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
); );
} }
} else { } else {
toast.error("You are not allowed to download files."); toast.error(t("home.downloads.toasts.you_are_not_allowed_to_download_files"));
} }
}, [ }, [
queue, queue,
@@ -192,10 +194,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
for (const item of items) { for (const item of items) {
if (itemsNotDownloaded.length > 1) { if (itemsNotDownloaded.length > 1) {
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings( const defaults = getDefaultPlaySettings(item, settings!);
item, mediaSource = defaults.mediaSource;
settings! audioIndex = defaults.audioIndex;
)); subtitleIndex = defaults.subtitleIndex;
// Keep using the selected bitrate for consistency across all downloads
} }
const res = await getStreamUrl({ const res = await getStreamUrl({
@@ -212,8 +215,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
if (!res) { if (!res) {
Alert.alert( Alert.alert(
"Something went wrong", t("home.downloads.something_went_wrong"),
"Could not get stream url from Jellyfin" t("home.downloads.could_not_get_stream_url_from_jellyfin")
); );
continue; continue;
} }
@@ -330,7 +333,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
{title} {title}
</Text> </Text>
<Text className="text-neutral-300"> <Text className="text-neutral-300">
{subtitle || `Download ${itemsNotDownloaded.length} items`} {subtitle || t("item_card.download.download_x_item", {item_count: itemsNotDownloaded.length})}
</Text> </Text>
</View> </View>
<View className="flex flex-col space-y-2 w-full items-start"> <View className="flex flex-col space-y-2 w-full items-start">
@@ -368,13 +371,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
onPress={acceptDownloadOptions} onPress={acceptDownloadOptions}
color="purple" color="purple"
> >
Download {t("item_card.download.download_button")}
</Button> </Button>
<View className="opacity-70 text-center w-full flex items-center"> <View className="opacity-70 text-center w-full flex items-center">
<Text className="text-xs"> <Text className="text-xs">
{usingOptimizedServer {usingOptimizedServer
? "Using optimized server" ? t("item_card.download.using_optimized_server")
: "Using default method"} : t("item_card.download.using_default_method")}
</Text> </Text>
</View> </View>
</View> </View>
@@ -391,7 +394,9 @@ export const DownloadSingleItem: React.FC<{
return ( return (
<DownloadItems <DownloadItems
size={size} size={size}
title="Download Episode" title={item.Type == "Episode"
? t("item_card.download.download_episode")
: t("item_card.download.download_movie")}
subtitle={item.Name!} subtitle={item.Name!}
items={[item]} items={[item]}
MissingDownloadIconComponent={() => ( MissingDownloadIconComponent={() => (

View File

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

View File

@@ -3,6 +3,7 @@ import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { DownloadSingleItem } from "@/components/DownloadItem"; import { DownloadSingleItem } from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
import { PlayButton } from "@/components/PlayButton"; import { PlayButton } from "@/components/PlayButton";
import { PlayedStatus } from "@/components/PlayedStatus"; import { PlayedStatus } from "@/components/PlayedStatus";
import { SimilarItems } from "@/components/SimilarItems"; import { SimilarItems } from "@/components/SimilarItems";
@@ -20,21 +21,20 @@ import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"
import { import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { View } from "react-native"; import { Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast"; const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
import { ItemHeader } from "./ItemHeader"; import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSelector } from "./MediaSourceSelector"; import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { SubtitleHelper } from "@/utils/SubtitleHelper"; import { AddToFavorites } from "./AddToFavorites";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
export type SelectedOptions = { export type SelectedOptions = {
bitrate: Bitrate; bitrate: Bitrate;
@@ -68,7 +68,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
// Needs to automatically change the selected to the default values for default indexes. // Needs to automatically change the selected to the default values for default indexes.
useEffect(() => { useEffect(() => {
console.log(defaultAudioIndex, defaultSubtitleIndex);
setSelectedOptions(() => ({ setSelectedOptions(() => ({
bitrate: defaultBitrate, bitrate: defaultBitrate,
mediaSource: defaultMediaSource, mediaSource: defaultMediaSource,
@@ -82,22 +81,29 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
defaultMediaSource, defaultMediaSource,
]); ]);
useEffect(() => { if (!Platform.isTV) {
navigation.setOptions({ useEffect(() => {
headerRight: () => navigation.setOptions({
item && ( headerRight: () =>
<View className="flex flex-row items-center space-x-2"> item && (
<Chromecast background="blur" width={22} height={22} /> <View className="flex flex-row items-center space-x-2">
{item.Type !== "Program" && ( <Chromecast.Chromecast
<View className="flex flex-row items-center space-x-2"> background="blur"
<DownloadSingleItem item={item} size="large" /> width={22}
<PlayedStatus item={item} /> height={22}
</View> />
)} {item.Type !== "Program" && (
</View> <View className="flex flex-row items-center space-x-2">
), <DownloadSingleItem item={item} size="large" />
}); <PlayedStatus items={[item]} size="large" />
}, [item]); <AddToFavorites item={item} type="item" />
</View>
)}
</View>
),
});
}, [item]);
}
useEffect(() => { useEffect(() => {
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
@@ -111,37 +117,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
const loading = useMemo(() => { const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo); return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]); }, [loadingLogo, logoUrl]);
const [isTranscoding, setIsTranscoding] = useState(false);
const [previouslyChosenSubtitleIndex, setPreviouslyChosenSubtitleIndex] =
useState<number | undefined>(selectedOptions?.subtitleIndex);
useEffect(() => {
const isTranscoding = Boolean(selectedOptions?.bitrate.value);
if (isTranscoding) {
setPreviouslyChosenSubtitleIndex(selectedOptions?.subtitleIndex);
const subHelper = new SubtitleHelper(
selectedOptions?.mediaSource?.MediaStreams ?? []
);
const newSubtitleIndex = subHelper.getMostCommonSubtitleByName(
selectedOptions?.subtitleIndex
);
setSelectedOptions((prev) => ({
...prev!,
subtitleIndex: newSubtitleIndex ?? -1,
}));
}
if (!isTranscoding && previouslyChosenSubtitleIndex !== undefined) {
setSelectedOptions((prev) => ({
...prev!,
subtitleIndex: previouslyChosenSubtitleIndex,
}));
}
setIsTranscoding(isTranscoding);
}, [selectedOptions?.bitrate]);
if (!selectedOptions) return null; if (!selectedOptions) return null;
return ( return (
@@ -189,9 +164,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
} }
> >
<View className="flex flex-col bg-transparent shrink"> <View className="flex flex-col bg-transparent shrink">
{/* {!Platform.isTV && ( */}
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink"> <View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
<ItemHeader item={item} className="mb-4" /> <ItemHeader item={item} className="mb-4" />
{item.Type !== "Program" && ( {item.Type !== "Program" && !Platform.isTV && (
<View className="flex flex-row items-center justify-start w-full h-16"> <View className="flex flex-row items-center justify-start w-full h-16">
<BitrateSelector <BitrateSelector
className="mr-1" className="mr-1"
@@ -220,7 +196,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
className="mr-1" className="mr-1"
source={selectedOptions.mediaSource} source={selectedOptions.mediaSource}
onChange={(val) => { onChange={(val) => {
console.log(val);
setSelectedOptions( setSelectedOptions(
(prev) => (prev) =>
prev && { prev && {
@@ -232,7 +207,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
selected={selectedOptions.audioIndex} selected={selectedOptions.audioIndex}
/> />
<SubtitleTrackSelector <SubtitleTrackSelector
isTranscoding={isTranscoding}
source={selectedOptions.mediaSource} source={selectedOptions.mediaSource}
onChange={(val) => onChange={(val) =>
setSelectedOptions( setSelectedOptions(
@@ -248,11 +222,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View> </View>
)} )}
{/* {!Platform.isTV && ( */}
<PlayButton <PlayButton
className="grow" className="grow"
selectedOptions={selectedOptions} selectedOptions={selectedOptions}
item={item} item={item}
/> />
{/* )} */}
</View> </View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (

View File

@@ -1,10 +1,11 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import { View, ViewProps } from "react-native"; import { View, ViewProps } from "react-native";
import { GenreTags } from "./GenreTags";
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader"; import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
import { Ratings } from "./Ratings"; import { Ratings } from "./Ratings";
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader"; import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
import { GenreTags } from "./GenreTags"; import { ItemActions } from "./series/SeriesActions";
import React from "react";
interface Props extends ViewProps { interface Props extends ViewProps {
item?: BaseItemDto | null; item?: BaseItemDto | null;
@@ -27,7 +28,10 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
return ( return (
<View className="flex flex-col" {...props}> <View className="flex flex-col" {...props}>
<View className="flex flex-col" {...props}> <View className="flex flex-col" {...props}>
<Ratings item={item} className="mb-2" /> <View className="flex flex-row items-center justify-between">
<Ratings item={item} className="mb-2" />
<ItemActions item={item} />
</View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (
<> <>
<EpisodeTitleHeader item={item} /> <EpisodeTitleHeader item={item} />

View File

@@ -15,6 +15,7 @@ import {
BottomSheetScrollView, BottomSheetScrollView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { Button } from "./Button"; import { Button } from "./Button";
import { useTranslation } from "react-i18next";
interface Props { interface Props {
source?: MediaSourceInfo; source?: MediaSourceInfo;
@@ -22,15 +23,16 @@ interface Props {
export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => { export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation();
return ( return (
<View className="px-4 mt-2 mb-4"> <View className="px-4 mt-2 mb-4">
<Text className="text-lg font-bold mb-4">Video</Text> <Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}> <TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
<View className="flex flex-row space-x-2"> <View className="flex flex-row space-x-2">
<VideoStreamInfo source={source} /> <VideoStreamInfo source={source} />
</View> </View>
<Text className="text-purple-600">More details</Text> <Text className="text-purple-600">{t("item_card.more_details")}</Text>
</TouchableOpacity> </TouchableOpacity>
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
@@ -52,14 +54,14 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
<BottomSheetScrollView> <BottomSheetScrollView>
<View className="flex flex-col space-y-2 p-4 mb-4"> <View className="flex flex-col space-y-2 p-4 mb-4">
<View className=""> <View className="">
<Text className="text-lg font-bold mb-4">Video</Text> <Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
<View className="flex flex-row space-x-2"> <View className="flex flex-row space-x-2">
<VideoStreamInfo source={source} /> <VideoStreamInfo source={source} />
</View> </View>
</View> </View>
<View className=""> <View className="">
<Text className="text-lg font-bold mb-2">Audio</Text> <Text className="text-lg font-bold mb-2">{t("item_card.audio")}</Text>
<AudioStreamInfo <AudioStreamInfo
audioStreams={ audioStreams={
source?.MediaStreams?.filter( source?.MediaStreams?.filter(
@@ -70,7 +72,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
</View> </View>
<View className=""> <View className="">
<Text className="text-lg font-bold mb-2">Subtitles</Text> <Text className="text-lg font-bold mb-2">{t("item_card.subtitles")}</Text>
<SubtitleStreamInfo <SubtitleStreamInfo
subtitleStreams={ subtitleStreams={
source?.MediaStreams?.filter( source?.MediaStreams?.filter(
@@ -175,6 +177,8 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
) as MediaStream; ) as MediaStream;
}, [source.MediaStreams]); }, [source.MediaStreams]);
if (!videoStream) return null;
return ( return (
<View className="flex-row flex-wrap gap-2"> <View className="flex-row flex-wrap gap-2">
<Badge <Badge

View File

@@ -0,0 +1,46 @@
import React from "react";
import { View, Text, TouchableOpacity } from "react-native";
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
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;
}
const JellyfinServerDiscovery: React.FC<Props> = ({ onServerSelect }) => {
const { servers, isSearching, startDiscovery } = useJellyfinDiscovery();
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")}
</Text>
</Button>
{servers.length ? (
<ListGroup title={t("server.servers")} className="mt-4">
{servers.map((server) => (
<ListItem
key={server.address}
onPress={() =>
onServerSelect?.({
address: server.address,
serverName: server.serverName,
})
}
title={server.address}
showArrow
/>
))}
</ListGroup>
) : null}
</View>
);
};
export default JellyfinServerDiscovery;

View File

@@ -1,35 +0,0 @@
import { PropsWithChildren, ReactNode } from "react";
import { View, ViewProps } from "react-native";
import { Text } from "./common/Text";
interface Props extends ViewProps {
title?: string | null | undefined;
subTitle?: string | null | undefined;
children?: ReactNode;
iconAfter?: ReactNode;
}
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
title,
subTitle,
iconAfter,
children,
...props
}) => {
return (
<View
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
{...props}
>
<View className="flex flex-col overflow-visible">
<Text className="font-bold ">{title}</Text>
{subTitle && (
<Text uiTextView selectable className="text-xs">
{subTitle}
</Text>
)}
</View>
{iconAfter}
</View>
);
};

View File

@@ -1,13 +1,12 @@
import { tc } from "@/utils/textTools";
import { import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react"; import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb"; import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto; item: BaseItemDto;
@@ -21,6 +20,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
selected, selected,
...props ...props
}) => { }) => {
if (Platform.isTV) return null;
const selectedName = useMemo( const selectedName = useMemo(
() => () =>
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find( item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
@@ -29,6 +29,29 @@ export const MediaSourceSelector: React.FC<Props> = ({
[item, selected] [item, selected]
); );
const { t } = useTranslation();
const commonPrefix = useMemo(() => {
const mediaSources = item.MediaSources || [];
if (!mediaSources.length) return "";
let commonPrefix = "";
for (let i = 0; i < mediaSources[0].Name!.length; i++) {
const char = mediaSources[0].Name![i];
if (mediaSources.every((source) => source.Name![i] === char)) {
commonPrefix += char;
} else {
commonPrefix = commonPrefix.slice(0, -1);
break;
}
}
return commonPrefix;
}, [item.MediaSources]);
const name = (name?: string | null) => {
return name?.replace(commonPrefix, "").toLowerCase();
};
return ( return (
<View <View
className="flex shrink" className="flex shrink"
@@ -39,7 +62,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col" {...props}> <View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">Video</Text> <Text className="opacity-50 mb-1 text-xs">
{t("item_card.video")}
</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center"> <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
<Text numberOfLines={1}>{selectedName}</Text> <Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -63,9 +88,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
}} }}
> >
<DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits( {`${name(source.Name)}`}
source.Size
)}`}
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.Item> </DropdownMenu.Item>
))} ))}
@@ -74,9 +97,3 @@ export const MediaSourceSelector: React.FC<Props> = ({
</View> </View>
); );
}; };
const name = (name?: string | null) => {
if (name && name.length > 40)
return name.substring(0, 20) + " [...] " + name.substring(name.length - 20);
return name;
};

View File

@@ -11,6 +11,7 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { useTranslation } from "react-i18next";
interface Props extends ViewProps { interface Props extends ViewProps {
actorId: string; actorId: string;
@@ -24,6 +25,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { t } = useTranslation();
const { data: actor } = useQuery({ const { data: actor } = useQuery({
queryKey: ["actor", actorId], queryKey: ["actor", actorId],
@@ -76,7 +78,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
return ( return (
<View {...props}> <View {...props}>
<Text className="text-lg font-bold mb-2 px-4"> <Text className="text-lg font-bold mb-2 px-4">
More with {actor?.Name} {t("item_card.more_with", {name: actor?.Name})}
</Text> </Text>
<HorizontalScroll <HorizontalScroll
data={items} data={items}

View File

@@ -2,6 +2,7 @@ import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { tc } from "@/utils/textTools"; import { tc } from "@/utils/textTools";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
interface Props extends ViewProps { interface Props extends ViewProps {
text?: string | null; text?: string | null;
@@ -14,12 +15,13 @@ export const OverviewText: React.FC<Props> = ({
...props ...props
}) => { }) => {
const [limit, setLimit] = useState(characterLimit); const [limit, setLimit] = useState(characterLimit);
const { t } = useTranslation();
if (!text) return null; if (!text) return null;
return ( return (
<View className="flex flex-col" {...props}> <View className="flex flex-col" {...props}>
<Text className="text-lg font-bold mb-2">Overview</Text> <Text className="text-lg font-bold mb-2">{t("item_card.overview")}</Text>
<TouchableOpacity <TouchableOpacity
onPress={() => onPress={() =>
setLimit((prev) => setLimit((prev) =>
@@ -31,7 +33,7 @@ export const OverviewText: React.FC<Props> = ({
<Text>{tc(text, limit)}</Text> <Text>{tc(text, limit)}</Text>
{text.length > characterLimit && ( {text.length > characterLimit && (
<Text className="text-purple-600 mt-1"> <Text className="text-purple-600 mt-1">
{limit === characterLimit ? "Show more" : "Show less"} {limit === characterLimit ? t("item_card.show_more") : t("item_card.show_less")}
</Text> </Text>
)} )}
</View> </View>

View File

@@ -1,6 +1,6 @@
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { type PropsWithChildren, type ReactElement } from "react"; import { type PropsWithChildren, type ReactElement } from "react";
import { View, ViewProps } from "react-native"; import {NativeScrollEvent, NativeSyntheticEvent, View, ViewProps} from "react-native";
import Animated, { import Animated, {
interpolate, interpolate,
useAnimatedRef, useAnimatedRef,
@@ -13,6 +13,7 @@ interface Props extends ViewProps {
logo?: ReactElement; logo?: ReactElement;
episodePoster?: ReactElement; episodePoster?: ReactElement;
headerHeight?: number; headerHeight?: number;
onEndReached?: (() => void) | null | undefined;
} }
export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
@@ -21,6 +22,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
episodePoster, episodePoster,
headerHeight = 400, headerHeight = 400,
logo, logo,
onEndReached,
...props ...props
}: Props) => { }: Props) => {
const scrollRef = useAnimatedRef<Animated.ScrollView>(); const scrollRef = useAnimatedRef<Animated.ScrollView>();
@@ -47,6 +49,11 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
}; };
}); });
function isCloseToBottom({layoutMeasurement, contentOffset, contentSize}: NativeScrollEvent) {
return layoutMeasurement.height + contentOffset.y >= contentSize.height - 20;
}
return ( return (
<View className="flex-1" {...props}> <View className="flex-1" {...props}>
<Animated.ScrollView <Animated.ScrollView
@@ -55,6 +62,10 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
}} }}
ref={scrollRef} ref={scrollRef}
scrollEventThrottle={16} scrollEventThrottle={16}
onScroll={e => {
if (isCloseToBottom(e.nativeEvent))
onEndReached?.()
}}
> >
{logo && ( {logo && (
<View <View

View File

@@ -1,3 +1,4 @@
import { Platform } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
@@ -31,8 +32,11 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Button } from "./Button"; import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent"; import { SelectedOptions } from "./ItemContent";
import { chromecastProfile } from "@/utils/profiles/chromecast"; const chromecastProfile = !Platform.isTV
import * as Haptics from "expo-haptics"; ? require("@/utils/profiles/chromecast")
: null;
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends React.ComponentProps<typeof Button> { interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto; item: BaseItemDto;
@@ -50,6 +54,7 @@ export const PlayButton: React.FC<Props> = ({
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient(); const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus(); const mediaStatus = useMediaStatus();
const { t } = useTranslation();
const [colorAtom] = useAtom(itemThemeColorAtom); const [colorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
@@ -64,14 +69,11 @@ export const PlayButton: React.FC<Props> = ({
const widthProgress = useSharedValue(0); const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0);
const [settings] = useSettings(); const [settings] = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback( const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => { (q: string, bitrateValue: number | undefined) => {
if (!bitrateValue) { router.push(`/player/direct-player?${q}`);
router.push(`/player/direct-player?${q}`);
return;
}
router.push(`/player/transcoding-player?${q}`);
}, },
[router] [router]
); );
@@ -79,7 +81,7 @@ export const PlayButton: React.FC<Props> = ({
const onPress = useCallback(async () => { const onPress = useCallback(async () => {
if (!item) return; if (!item) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: item.Id!, itemId: item.Id!,
@@ -111,99 +113,101 @@ export const PlayButton: React.FC<Props> = ({
switch (selectedIndex) { switch (selectedIndex) {
case 0: case 0:
await CastContext.getPlayServicesState().then(async (state) => { if (!Platform.isTV) {
if (state && state !== PlayServicesState.SUCCESS) await CastContext.getPlayServicesState().then(async (state) => {
CastContext.showPlayServicesErrorDialog(state); if (state && state !== PlayServicesState.SUCCESS)
else { CastContext.showPlayServicesErrorDialog(state);
// Get a new URL with the Chromecast device profile: else {
const data = await getStreamUrl({ // Get a new URL with the Chromecast device profile:
api, const data = await getStreamUrl({
item, api,
deviceProfile: chromecastProfile, item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!, deviceProfile: chromecastProfile,
userId: user?.Id, startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
audioStreamIndex: selectedOptions.audioIndex, userId: user?.Id,
maxStreamingBitrate: selectedOptions.bitrate?.value, audioStreamIndex: selectedOptions.audioIndex,
mediaSourceId: selectedOptions.mediaSource?.Id, maxStreamingBitrate: selectedOptions.bitrate?.value,
subtitleStreamIndex: selectedOptions.subtitleIndex, mediaSourceId: selectedOptions.mediaSource?.Id,
}); subtitleStreamIndex: selectedOptions.subtitleIndex,
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
"Client error",
"Could not create stream for Chromecast"
);
return;
}
client
.loadMedia({
mediaInfo: {
contentUrl: data?.url,
contentType: "video/mp4",
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
},
},
startTime: 0,
})
.then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
CastContext.showExpandedControls();
}); });
}
}); if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast")
);
return;
}
client
.loadMedia({
mediaInfo: {
contentUrl: data?.url,
contentType: "video/mp4",
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
},
},
startTime: 0,
})
.then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
CastContext.showExpandedControls();
});
}
});
}
break; break;
case 1: case 1:
goToPlayer(queryString, selectedOptions.bitrate?.value); goToPlayer(queryString, selectedOptions.bitrate?.value);

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
import React, { useMemo } from "react";
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;
}
interface PreviousServersListProps {
onServerSelect: (server: Server) => void;
}
export const PreviousServersList: React.FC<PreviousServersListProps> = ({
onServerSelect,
}) => {
const [_previousServers, setPreviousServers] =
useMMKVString("previousServers");
const previousServers = useMemo(() => {
return JSON.parse(_previousServers || "[]") as Server[];
}, [_previousServers]);
const { t } = useTranslation();
if (!previousServers.length) return null;
return (
<View>
<ListGroup title={t("server.previous_servers")} className="mt-4">
{previousServers.map((s) => (
<ListItem
key={s.address}
onPress={() => onServerSelect(s)}
title={s.address}
showArrow
/>
))}
<ListItem
onPress={() => {
setPreviousServers("[]");
}}
title={t("server.clear_button")}
textColor="red"
/>
</ListGroup>
</View>
);
};

View File

@@ -3,6 +3,10 @@ import { View, ViewProps } from "react-native";
import { Badge } from "./Badge"; import { Badge } from "./Badge";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image"; 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";
interface Props extends ViewProps { interface Props extends ViewProps {
item?: BaseItemDto | null; item?: BaseItemDto | null;
@@ -17,7 +21,7 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
)} )}
{item.CommunityRating && ( {item.CommunityRating && (
<Badge <Badge
text={item.CommunityRating} text={item.CommunityRating.toFixed(1)}
variant="gray" variant="gray"
iconLeft={<Ionicons name="star" size={14} color="gold" />} iconLeft={<Ionicons name="star" size={14} color="gold" />}
/> />
@@ -28,7 +32,11 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
variant="gray" variant="gray"
iconLeft={ iconLeft={
<Image <Image
source={require("@/assets/images/rotten-tomatoes.png")} source={
item.CriticRating < 60
? require("@/assets/images/rotten-tomatoes.png")
: require("@/assets/images/not-rotten-tomatoes.svg")
}
style={{ style={{
width: 14, width: 14,
height: 14, height: 14,
@@ -40,3 +48,86 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
</View> </View>
); );
}; };
export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult }> = ({
result,
}) => {
const { jellyseerrApi } = useJellyseerr();
const { data, isLoading } = useQuery({
queryKey: ["jellyseerr", result.id, result.mediaType, "ratings"],
queryFn: async () => {
return result.mediaType === MediaType.MOVIE
? jellyseerrApi?.movieRatings(result.id)
: jellyseerrApi?.tvRatings(result.id);
},
staleTime: (5).minutesToMilliseconds(),
retry: false,
enabled: !!jellyseerrApi,
});
return (
(isLoading ||
!!result.voteCount ||
(data?.criticsRating && !!data?.criticsScore) ||
(data?.audienceRating && !!data?.audienceScore)) && (
<View className="flex flex-row flex-wrap space-x-1">
{data?.criticsRating && !!data?.criticsScore && (
<Badge
text={`${data.criticsScore}%`}
variant="gray"
iconLeft={
<Image
className="mr-1"
source={
data?.criticsRating === "Rotten"
? require("@/utils/jellyseerr/src/assets/rt_rotten.svg")
: require("@/utils/jellyseerr/src/assets/rt_fresh.svg")
}
style={{
width: 14,
height: 14,
}}
/>
}
/>
)}
{data?.audienceRating && !!data?.audienceScore && (
<Badge
text={`${data.audienceScore}%`}
variant="gray"
iconLeft={
<Image
className="mr-1"
source={
data?.audienceRating === "Spilled"
? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg")
: require("@/utils/jellyseerr/src/assets/rt_aud_fresh.svg")
}
style={{
width: 14,
height: 14,
}}
/>
}
/>
)}
{!!result.voteCount && (
<Badge
text={`${Math.round(result.voteAverage * 10)}%`}
variant="gray"
iconLeft={
<Image
className="mr-1"
source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")}
style={{
width: 14,
height: 14,
}}
/>
}
/>
)}
</View>
)
);
};

View File

@@ -6,10 +6,10 @@ import {
TouchableOpacity, TouchableOpacity,
TouchableOpacityProps, TouchableOpacityProps,
} from "react-native"; } from "react-native";
import * as Haptics from "expo-haptics"; import { useHaptic } from "@/hooks/useHaptic";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
onPress: () => void; onPress?: () => void;
icon?: keyof typeof Ionicons.glyphMap; icon?: keyof typeof Ionicons.glyphMap;
background?: boolean; background?: boolean;
size?: "default" | "large"; size?: "default" | "large";
@@ -29,12 +29,13 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
}) => { }) => {
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9"; const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
const fillColorClass = fillColor === "primary" ? "bg-purple-600" : ""; const fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
const lightHapticFeedback = useHaptic("light");
const handlePress = () => { const handlePress = () => {
if (hapticFeedback) { if (hapticFeedback) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
} }
onPress(); onPress?.();
}; };
if (fillColor) if (fillColor)

View File

@@ -12,6 +12,7 @@ import { ItemCardText } from "./ItemCardText";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
import { HorizontalScroll } from "./common/HorrizontalScroll"; import { HorizontalScroll } from "./common/HorrizontalScroll";
import { TouchableItemRouter } from "./common/TouchableItemRouter"; import { TouchableItemRouter } from "./common/TouchableItemRouter";
import { useTranslation } from "react-i18next";
interface SimilarItemsProps extends ViewProps { interface SimilarItemsProps extends ViewProps {
itemId?: string | null; itemId?: string | null;
@@ -23,6 +24,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { t } = useTranslation();
const { data: similarItems, isLoading } = useQuery<BaseItemDto[]>({ const { data: similarItems, isLoading } = useQuery<BaseItemDto[]>({
queryKey: ["similarItems", itemId], queryKey: ["similarItems", itemId],
@@ -47,12 +49,12 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
return ( return (
<View {...props}> <View {...props}>
<Text className="px-4 text-lg font-bold mb-2">Similar items</Text> <Text className="px-4 text-lg font-bold mb-2">{t("item_card.similar_items")}</Text>
<HorizontalScroll <HorizontalScroll
data={movies} data={movies}
loading={isLoading} loading={isLoading}
height={247} height={247}
noItemsText="No similar items found" noItemsText={t("item_card.no_similar_items_found")}
renderItem={(item: BaseItemDto, idx: number) => ( renderItem={(item: BaseItemDto, idx: number) => (
<TouchableItemRouter <TouchableItemRouter
key={idx} key={idx}

View File

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

View File

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

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