Compare commits

...

374 Commits

Author SHA1 Message Date
Fredrik Burmester
b4d9552401 chore 2025-01-23 13:37:41 +01:00
Fredrik Burmester
c74336a1a1 chore 2025-01-23 13:16:55 +01:00
Fredrik Burmester
17257ece85 chore 2025-01-23 11:38:42 +01:00
Fredrik Burmester
d5c634b74b Revert "Merge branch 'develop' into chore/expo-52"
This reverts commit 933f3f2f7c, reversing
changes made to f92fee4158.
2025-01-23 11:34:22 +01:00
Fredrik Burmester
933f3f2f7c Merge branch 'develop' into chore/expo-52 2025-01-23 11:30: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
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
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
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
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
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
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
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
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
f92fee4158 wip 2025-01-03 11:42:18 +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
3aef9458e3 Merge pull request #303 from fredrikburmester/feat/context-menu-action-for-items
feat: context menu actions for items
2024-12-23 12:02:09 +01:00
Fredrik Burmester
5bce394836 Merge pull request #304 from fredrikburmester/feat/safe-areas-in-controls
feat: setting toggle for safe areas in controls
2024-12-23 12:01:31 +01:00
Fredrik Burmester
90930d478c fix: call with correct args 2024-12-23 11:25:24 +01:00
Fredrik Burmester
dd09f3d4d9 feat: settings toggle for safe areas in controls 2024-12-23 10:26:15 +01:00
Fredrik Burmester
8608ad02f7 feat: context menu actions for items 2024-12-22 11:57:44 +01:00
Fredrik Burmester
030947fc38 fix: login design 2024-12-22 11:33:15 +01:00
Fredrik Burmester
9b18188b32 fix: live tv design 2024-12-22 11:28:28 +01:00
Fredrik Burmester
d86853dec9 fix: login design 2024-12-22 11:28:21 +01:00
Fredrik Burmester
0750acdc13 Merge pull request #302 from Alexk2309/fix/remove-episode-list-and-next-up-in-offline-playback
Removed episodelist button and previous/next buttons for offline playback
2024-12-22 10:40:08 +01:00
Alex Kim
d8231f5b80 Fixed compile error 2024-12-22 17:46:56 +11:00
Alex Kim
41d17499bb Removed episodelist button and previous/next buttons for offline playback 2024-12-22 17:43:58 +11:00
Fredrik Burmester
60f1217cae Merge pull request #300 from herrrta/fix/storage-read-crash
dont rely on cache for downloadedItems
2024-12-21 22:02:49 +01:00
herrrta
834de10e34 dont rely on cache for downloadedItems 2024-12-21 14:10:55 -05:00
Fredrik Burmester
51f17f983d fix: add safe areas back to controls 2024-12-21 13:18:56 +01:00
Fredrik Burmester
ba4a2c0b79 fix: haptics 2024-12-21 13:03:32 +01:00
Fredrik Burmester
a32eb710ec feat: haptics 2024-12-21 12:56:04 +01:00
Fredrik Burmester
cb05da782a feat: optimistic update 2024-12-21 12:55:58 +01:00
Fredrik Burmester
5a680a4392 fix: smoother item page loading 2024-12-21 12:45:32 +01:00
Fredrik Burmester
8a44d2ff15 fix: correct types 2024-12-21 12:31:00 +01:00
Fredrik Burmester
f3f260625f fix: refactor buttons 2024-12-21 12:22:53 +01:00
Fredrik Burmester
6908620f4e fix: downloaded movie title overflowing 2024-12-19 15:10:21 +01:00
Fredrik Burmester
9932266203 fix: key error 2024-12-19 15:06:26 +01:00
Fredrik Burmester
cb2268e39c fix: don't show empty folders in movie/tvshow libraries 2024-12-19 15:05:31 +01:00
Fredrik Burmester
bf9be278d3 fix: texts and icons 2024-12-19 14:48:51 +01:00
Fredrik Burmester
584fcc09d6 fix: use ionicons 2024-12-19 14:18:53 +01:00
Fredrik Burmester
7a26b5004b fix: text reorder 2024-12-19 14:18:47 +01:00
Fredrik Burmester
ae92692ea0 fix: title props 2024-12-19 14:18:37 +01:00
Fredrik Burmester
92e4b3b8cf fix: use ionicons 2024-12-19 14:18:26 +01:00
Fredrik Burmester
127ec1391b fix: text 2024-12-19 14:12:29 +01:00
Fredrik Burmester
0ac4f826bc Merge pull request #297 from fredrikburmester/feat/technical-details
feat: add technical details to item
2024-12-19 12:06:12 +01:00
Fredrik Burmester
6190f2e602 feat: add technical details to item 2024-12-19 12:05:57 +01:00
Fredrik Burmester
24fdd071af Merge pull request #294 from jakequade/master
fix: restore streaming codecs
2024-12-19 10:11:04 +01:00
jakequade
be3122caac add in proper codecs for chromecast 2024-12-17 20:41:07 +11:00
Fredrik Burmester
39a220bbed Merge pull request #289 from herrrta/fix/delete-type
Move downloads to a cache directory
2024-12-14 22:49:15 +01:00
herrrta
e3bdbb5cbd Move downloads to a cache directory
- cleanup cache during apps first cold start
- Downloads now saved in cacheDirectory and moved to documents when verified complete
- Bring back download size to episode card
- Improve reading a files size if its a known downloaded file
- Added decimal to divisor in bytesToReadable for more accurate file size conversions
2024-12-14 12:19:44 -05:00
Fredrik Burmester
b6ad05d980 Merge pull request #284 from Alexk2309/hotfix/transcoded-streams
Hotfix/transcoded streams
2024-12-13 07:48:54 +01:00
Alex Kim
0360b5cbd5 Merged changes from main 2024-12-13 16:39:58 +11:00
Alex Kim
a9b1d9fb0a Added bandaid fix 2024-12-13 05:03:16 +11:00
Alex Kim
4291ef55b9 Added tmp fix 2024-12-13 01:04:55 +11:00
Fredrik Burmester
655060fb40 Merge pull request #282 from Alexk2309/hotfix/for-settings
Hotfix/for settings
2024-12-12 12:20:35 +01:00
Alex Kim
0e29b8b671 Added temporary fix 2024-12-12 21:41:22 +11:00
Alex Kim
72f64c71dd Added .vscode to git ignore 2024-12-12 16:37:06 +11:00
Alex Kim
ddfd9f6ce3 Added vscode styling for pretier extension 2024-12-12 16:36:17 +11:00
Alex Kim
67fb339d40 Added fix that fully stops the UseEffect hook from been calling indefinetly 2024-12-12 16:33:30 +11:00
Alex Kim
9e0a7f047c Added new pulled state, to stop infinite callback for useEffect hookt in MediaContext 2024-12-12 15:44:53 +11:00
Fredrik Burmester
aab806bbf4 Merge pull request #281 from Alexk2309/hotfix/fix-options-after-subtitle-audio-revamp
Added fix
2024-12-11 19:39:37 +01:00
Alex Kim
4a53b20618 Added fix 2024-12-12 05:38:14 +11:00
Fredrik Burmester
45299a5c5d Merge pull request #279 from Alexk2309/revamp/audio-subtitle-selection
Revamp/audio subtitle selection
2024-12-11 18:59:24 +01:00
Alex Kim
65ad4effca Merged main into branch 2024-12-12 04:25:37 +11:00
Alex Kim
35fcb5ca0c Completed subtitle feature 2024-12-12 04:23:09 +11:00
Fredrik Burmester
5dc0066370 fix: remove auto http/s and allow for more flexible urls 2024-12-11 18:02:27 +01:00
Alex Kim
3fb20a8ca2 Revamped transcoding subtitles 2024-12-12 02:41:30 +11:00
Fredrik Burmester
180ed54fed fix: initial support playlists 2024-12-11 15:49:20 +01:00
Fredrik Burmester
72859b4ae3 fix: make the next episode button work with skip credits button 2024-12-11 08:28:11 +01:00
Fredrik Burmester
bfe96edb29 fix: don't show if no next episode 2024-12-10 21:52:46 +01:00
Fredrik Burmester
46f4acdad0 Merge pull request #276 from fredrikburmester/feat/go-to-next-episode-countdown
feat: go to next episode countdown
2024-12-10 21:11:51 +01:00
Fredrik Burmester
da1aa9f48c feat: go to next episode countdown 2024-12-10 20:37:58 +01:00
Alex Kim
1d0d99c79b Added stream ranker class 2024-12-11 06:02:13 +11:00
Alex Kim
33a6295b20 Added more selection options 2024-12-11 05:22:56 +11:00
Alex Kim
72cc381087 Added use default audio 2024-12-11 04:59:33 +11:00
Alex Kim
c4bfaf2d56 Made subtitle mode fetch from server 2024-12-11 04:52:12 +11:00
Alex Kim
487ac398e5 Added subtitle mode in options 2024-12-11 04:48:53 +11:00
Alex Kim
84fd0edc49 WIP 2024-12-11 04:01:30 +11:00
Fredrik Burmester
0e1583c440 Merge pull request #275 from Alexk2309/fix/select-same-bitrate-when-changing-episode-in-player
Fix for the selecting the same bitrate when changing the same episode…
2024-12-10 10:51:30 +01:00
Alex Kim
6459e5f323 Fix for the selecting the same bitrate when changing the same episode in the player 2024-12-10 20:48:15 +11:00
Fredrik Burmester
319e1fd53f Merge pull request #274 from Alexk2309/fix/trick-play-invalid-for-transcoded-player
Fix/trick play invalid for transcoded player
2024-12-10 10:24:39 +01:00
Alex Kim
93bd817eaf Removed old function argumement 2024-12-10 20:22:41 +11:00
Alex Kim
d9f21e6824 Removed Unused imports for controls 2024-12-10 20:21:53 +11:00
Alex Kim
d287f5d082 Added fix for invalid trickplay, for transcoded player 2024-12-10 20:21:00 +11:00
Fredrik Burmester
ecd2fa386e chore 2024-12-09 21:33:10 +01:00
Fredrik Burmester
7c022bbaff Merge pull request #273 from Alexk2309/hotfix/show-audio-slider-when-changing-audio-through-device
Added feature to show audio slider when changing audio through device
2024-12-09 21:32:42 +01:00
Alex Kim
5d79ee34cf Added feature to show audio slider when changing audio through device 2024-12-10 05:20:49 +11:00
Fredrik Burmester
b0adad8dc4 Merge pull request #272 from Alexk2309/feature/audio-slider-in-controls
Feature/audio slider in controls
2024-12-09 17:22:14 +01:00
Alex Kim
c3d3f538d7 Finished changes for audio selection 2024-12-10 03:21:02 +11:00
Alex Kim
6b6dedf303 WIP 2024-12-10 02:50:03 +11:00
Alex Kim
8d22e4c075 Added audioSlider.tsx 2024-12-10 02:20:52 +11:00
Fredrik Burmester
752cb1cdc6 wip 2024-08-18 17:10:31 +02:00
133 changed files with 6678 additions and 2369 deletions

View File

@@ -1,13 +1,13 @@
name: Bug report
description: Create a report to help us improve
title: '[Bug]: '
title: "[Bug]: "
labels:
- ['❌ bug']
projects:
- ['fredrikburmester/5']
- ["❌ bug"]
projects:
- ["fredrikburmester/5"]
assignees:
- fredrikburmester
body:
- type: textarea
id: what-happened
@@ -43,8 +43,9 @@ body:
id: version
attributes:
label: Version
description: What version of Streamyfin are you running?
description: What version of Streamyfin are you running?
options:
- 0.23.0
- 0.22.0
- 0.21.0
- older
@@ -54,6 +55,5 @@ body:
- type: textarea
id: screenshots
attributes:
label:
If applicable, please add screenshots to help explain your problem.
label: 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.

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"
}

1
.gitignore vendored
View File

@@ -35,3 +35,4 @@ credentials.json
*.ipa
.continuerc.json
.vscode/

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

View File

@@ -11,7 +11,5 @@
},
"[swift]": {
"editor.defaultFormatter": "sswg.swift-lang"
},
"java.configuration.updateBuildConfiguration": "interactive",
"java.compile.nullAnalysis.mode": "automatic"
}
}

View File

@@ -15,10 +15,10 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
- 🚀 **Skp intro / credits support**
- 🖼️ **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.
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
- 🤖 **Jellyseerr integration**: Request media directly in the app.
## 🧪 Experimental Features
@@ -66,15 +66,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>
</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
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'll 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">
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
</a>
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
## 🚀 Getting Started
@@ -89,36 +87,10 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
### Development info
1. Use node `20`
2. Install dependencies `bun i`
3. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`.
## Extended chromecast controls
Add this to AppDelegate.mm:
```
- (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>
```
1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload`
3. Make sure you have xcode and/or android studio installed.
4. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on you computer and run the app.
## 📄 License
@@ -136,7 +108,7 @@ Key points of the MPL-2.0:
## 🌐 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:
@@ -145,7 +117,7 @@ If you have questions or need support, feel free to reach out:
## 📝 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
@@ -153,8 +125,9 @@ I'd like to thank the following people and projects for their contributions to S
- [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.
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
- The Jellyfin devs for always being helpful in the Discord.
## 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)

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.22.0",
"version": "0.23.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -36,7 +36,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 47,
"versionCode": 49,
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png"
},
@@ -130,6 +130,7 @@
},
"updates": {
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
}
},
"newArchEnabled": false
}
}

View File

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

View File

@@ -0,0 +1,27 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router";
import { Platform } from "react-native";
export default function SearchLayout() {
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: "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,4 +1,5 @@
import { Chromecast } from "@/components/Chromecast";
import { Text } from "@/components/common/Text";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
@@ -15,6 +16,9 @@ export default function IndexLayout() {
headerLargeTitle: true,
headerTitle: "Home",
headerBlurEffect: "prominent",
headerLargeStyle: {
backgroundColor: "black",
},
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerRight: () => (
@@ -49,6 +53,30 @@ export default function IndexLayout() {
title: "Settings",
}}
/>
<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/popular-lists/page"
options={{
title: "",
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}

View File

@@ -6,16 +6,21 @@ import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import {useNavigation, useRouter} from "expo-router";
import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai";
import React, {useEffect, useMemo, useRef} from "react";
import {Alert, ScrollView, TouchableOpacity, View} from "react-native";
import React, { useEffect, useMemo, useRef } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { Button } from "@/components/Button";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {DownloadSize} from "@/components/downloads/DownloadSize";
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
import {toast} from "sonner-native";
import {writeToLog} from "@/utils/log";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { toast } from "sonner-native";
import { writeToLog } from "@/utils/log";
export default function page() {
const navigation = useNavigation();
@@ -56,28 +61,29 @@ export default function page() {
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
onPress={bottomSheetModalRef.current?.present}
>
<DownloadSize items={downloadedFiles?.map(f => f.item) || []}/>
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
</TouchableOpacity>
)
})
),
});
}, [downloadedFiles]);
const deleteMovies = () => deleteFileByType("Movie")
.then(() => toast.success("Deleted all movies successfully!"))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error("Failed to delete all movies");
});
const deleteShows = () => deleteFileByType("Episode")
.then(() => toast.success("Deleted all TV-Series successfully!"))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error("Failed to delete all TV-Series");
});
const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()])
const deleteMovies = () =>
deleteFileByType("Movie")
.then(() => toast.success("Deleted all movies successfully!"))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error("Failed to delete all movies");
});
const deleteShows = () =>
deleteFileByType("Episode")
.then(() => toast.success("Deleted all TV-Series successfully!"))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error("Failed to delete all TV-Series");
});
const deleteAllMedia = async () =>
await Promise.all([deleteMovies(), deleteShows()]);
return (
<>
@@ -94,7 +100,7 @@ export default function page() {
<View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Queue</Text>
<Text className="text-xs opacity-70 text-red-600">
Queue and downloads will be lost on app restart
Queue and active downloads will be lost on app restart
</Text>
<View className="flex flex-col space-y-2 mt-2">
{queue.map((q, index) => (
@@ -107,7 +113,9 @@ export default function page() {
>
<View>
<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>
<TouchableOpacity
onPress={() => {
@@ -118,7 +126,7 @@ export default function page() {
});
}}
>
<Ionicons name="close" size={24} color="red"/>
<Ionicons name="close" size={24} color="red" />
</TouchableOpacity>
</TouchableOpacity>
))}
@@ -130,7 +138,7 @@ export default function page() {
</View>
)}
<ActiveDownloads/>
<ActiveDownloads />
</View>
{movies.length > 0 && (
@@ -145,7 +153,7 @@ export default function page() {
<View className="px-4 flex flex-row">
{movies?.map((item) => (
<View className="mb-2 last:mb-0" key={item.item.Id}>
<MovieCard item={item.item}/>
<MovieCard item={item.item} />
</View>
))}
</View>
@@ -157,13 +165,18 @@ export default function page() {
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">TV-Series</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{groupedBySeries?.length}</Text>
<Text className="text-xs font-bold">
{groupedBySeries?.length}
</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{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
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
@@ -200,9 +213,15 @@ export default function page() {
>
<BottomSheetView>
<View className="p-4 space-y-4 mb-4">
<Button color="purple" onPress={deleteMovies}>Delete all Movies</Button>
<Button color="purple" onPress={deleteShows}>Delete all TV-Series</Button>
<Button color="red" onPress={deleteAllMedia}>Delete all</Button>
<Button color="purple" onPress={deleteMovies}>
Delete all Movies
</Button>
<Button color="purple" onPress={deleteShows}>
Delete all TV-Series
</Button>
<Button color="red" onPress={deleteAllMedia}>
Delete all
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>

View File

@@ -64,7 +64,7 @@ export default function index() {
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
const { downloadedFiles } = useDownload();
const { downloadedFiles, cleanCacheDirectory } = useDownload();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
@@ -107,6 +107,9 @@ export default function index() {
setIsConnected(state.isConnected);
});
cleanCacheDirectory().catch((e) =>
console.error("Something went wrong cleaning cache directory")
);
return () => {
unsubscribe();
};

View File

@@ -1,185 +1,87 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/ListItem";
import { SettingToggles } from "@/components/settings/SettingToggles";
import {bytesToReadable, useDownload} from "@/providers/DownloadProvider";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import {clearLogs, useLog} from "@/utils/log";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { AudioToggles } from "@/components/settings/AudioToggles";
import { DownloadSettings } from "@/components/settings/DownloadSettings";
import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles";
import { OtherSettings } from "@/components/settings/OtherSettings";
import { PluginSettings } from "@/components/settings/PluginSettings";
import { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { UserInfo } from "@/components/settings/UserInfo";
import { useJellyfin } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import {Alert, ScrollView, View} from "react-native";
import { useNavigation, useRouter } from "expo-router";
import { useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import * as Progress from 'react-native-progress';
import * as FileSystem from "expo-file-system";
export default function settings() {
const { logout } = useJellyfin();
const { deleteAllFiles, appSizeUsage } = useDownload();
const { logs } = useLog();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const router = useRouter();
const insets = useSafeAreaInsets();
const {data: size , isLoading: appSizeLoading } = useQuery({
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 { logout } = useJellyfin();
const onClearLogsClicked = async () => {
clearLogs();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
};
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
onPress={() => {
logout();
}}
>
<Text className="text-red-600">Log out</Text>
</TouchableOpacity>
),
});
}, []);
return (
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className="p-4 flex flex-col gap-y-4">
{/* <Button
onPress={() => {
registerBackgroundFetchAsync();
}}
>
registerBackgroundFetchAsync
</Button> */}
<View>
<Text className="font-bold text-lg mb-2">User Info</Text>
<UserInfo />
<QuickConnect className="mb-4" />
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
<ListItem title="User" subTitle={user?.Name} />
<ListItem title="Server" subTitle={api?.basePath} />
<ListItem title="Token" subTitle={api?.accessToken} />
</View>
<Button className="my-2.5" color="black" onPress={logout}>
Log out
</Button>
</View>
<MediaProvider>
<MediaToggles className="mb-4" />
<AudioToggles className="mb-4" />
<SubtitleToggles className="mb-4" />
</MediaProvider>
<View>
<Text className="font-bold text-lg mb-2">Quick connect</Text>
<Button onPress={openQuickConnectAuthCodeInput} color="black">
Authorize
</Button>
</View>
<OtherSettings />
<DownloadSettings />
<SettingToggles />
<PluginSettings />
<View className="flex flex-col space-y-2">
<Text className="font-bold text-lg mb-2">Storage</Text>
<View className="mb-4 space-y-2">
{size && <Text>App usage: {bytesToReadable(size.app)}</Text>}
<Progress.Bar
className="bg-gray-100/10"
indeterminate={appSizeLoading}
color="#9333ea"
width={null}
height={10}
borderRadius={6}
borderWidth={0}
progress={size?.used}
<View className="mb-4">
<ListGroup title={"Logs"}>
<ListItem
onPress={() => router.push("/settings/logs/page")}
showArrow
title={"Logs"}
/>
{size && (
<Text>Available: {bytesToReadable(size.remaining)}, Total: {bytesToReadable(size.total)}</Text>
)}
</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>
<ListItem
textColor="red"
onPress={onClearLogsClicked}
title={"Delete All Logs"}
/>
</ListGroup>
</View>
<StorageSettings />
</View>
</ScrollView>
);

View File

@@ -0,0 +1,78 @@
import { Text } from "@/components/common/Text";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
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";
export default function page() {
const navigation = useNavigation();
const [api] = useAtom(apiAtom);
const [settings, updateSettings] = useSettings();
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
useState<string>(settings?.optimizedVersionsServerUrl || "");
const saveMutation = useMutation({
mutationFn: async (newVal: string) => {
if (newVal.length === 0 || !newVal.startsWith("http")) {
toast.error("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("Connected");
} else {
toast.error("Could not connect");
}
},
onError: () => {
toast.error("Could not connect");
},
});
const onSave = (newVal: string) => {
saveMutation.mutate(newVal);
};
// useEffect(() => {
// navigation.setOptions({
// title: "Optimized Server",
// headerRight: () =>
// saveMutation.isPending ? (
// <ActivityIndicator size={"small"} color={"white"} />
// ) : (
// <TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
// <Text className="text-blue-500">Save</Text>
// </TouchableOpacity>
// ),
// });
// }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
return (
<View className="p-4">
<JellyseerrSettings />
</View>
);
}

View File

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

View File

@@ -0,0 +1,103 @@
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import {
Linking,
Switch,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { toast } from "sonner-native";
export default function page() {
const navigation = useNavigation();
const [settings, updateSettings] = 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("Saved");
};
const handleOpenLink = () => {
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
};
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)}>
<Text className="text-blue-500">Save</Text>
</TouchableOpacity>
),
});
}, [navigation, value]);
if (!settings) return null;
return (
<View className="px-4">
<ListGroup>
<ListItem
title={"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>
</ListGroup>
<View
className={`mt-2 ${
settings.searchEngine === "Marlin" ? "" : "opacity-50"
}`}
>
<View className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4">
<View
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
>
<Text className="mr-4">URL</Text>
<TextInput
editable={settings.searchEngine === "Marlin"}
className="text-white"
placeholder="http(s)://domain.org:port"
value={value}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={(text) => setValue(text)}
/>
</View>
</View>
<Text className="px-4 text-xs text-neutral-500 mt-1">
Enter the URL for the Marlin server. The URL should include http or
https and optionally the port.{" "}
<Text className="text-blue-500" onPress={handleOpenLink}>
Read more about Marlin.
</Text>
</Text>
</View>
</View>
);
}

View File

@@ -0,0 +1,80 @@
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";
export default function page() {
const navigation = useNavigation();
const [api] = useAtom(apiAtom);
const [settings, updateSettings] = useSettings();
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
useState<string>(settings?.optimizedVersionsServerUrl || "");
const saveMutation = useMutation({
mutationFn: async (newVal: string) => {
if (newVal.length === 0 || !newVal.startsWith("http")) {
toast.error("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("Connected");
} else {
toast.error("Could not connect");
}
},
onError: () => {
toast.error("Could not connect");
},
});
const onSave = (newVal: string) => {
saveMutation.mutate(newVal);
};
useEffect(() => {
navigation.setOptions({
title: "Optimized Server",
headerRight: () =>
saveMutation.isPending ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
<Text className="text-blue-500">Save</Text>
</TouchableOpacity>
),
});
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
return (
<View className="p-4">
<OptimizedServerForm
value={optimizedVersionsServerUrl}
onChangeValue={setOptimizedVersionsServerUrl}
/>
</View>
);
}

View File

@@ -0,0 +1,135 @@
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 { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { Linking, Switch, View } from "react-native";
export default function page() {
const navigation = useNavigation();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [settings, updateSettings] = useSettings();
const handleOpenLink = () => {
Linking.openURL(
"https://github.com/lostb1t/jellyfin-plugin-collection-import"
);
};
const queryClient = useQueryClient();
const {
data: mediaListCollections,
isLoading: isLoadingMediaListCollections,
} = useQuery({
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["sf_promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
return response.data.Items ?? [];
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 0,
});
if (!settings) return null;
return (
<View className="px-4 pt-4">
<ListGroup title={"Enable plugin"} className="">
<ListItem
title={"Enable Popular Lists"}
onPress={() => {
updateSettings({ usePopularPlugin: true });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<Switch
value={settings.usePopularPlugin}
onValueChange={(value) => {
updateSettings({ usePopularPlugin: value });
}}
/>
</ListItem>
</ListGroup>
<Text className="px-4 text-xs text-neutral-500 mt-1">
Popular Lists is a plugin that enables you to show custom Jellyfin lists
on the Streamyfin home page.{" "}
<Text className="text-blue-500" onPress={handleOpenLink}>
Read more about Popular Lists.
</Text>
</Text>
{settings.usePopularPlugin && (
<>
{!isLoadingMediaListCollections ? (
<>
{mediaListCollections?.length === 0 ? (
<Text className="text-xs opacity-50 p-4">
No collections found. Add some in Jellyfin.
</Text>
) : (
<>
<ListGroup title="Media List Collections" className="mt-4">
{mediaListCollections?.map((mlc) => (
<ListItem key={mlc.Id} title={mlc.Name}>
<Switch
value={settings.mediaListCollectionIds?.includes(
mlc.Id!
)}
onValueChange={(value) => {
if (!settings.mediaListCollectionIds) {
updateSettings({
mediaListCollectionIds: [mlc.Id!],
});
return;
}
updateSettings({
mediaListCollectionIds:
settings.mediaListCollectionIds.includes(
mlc.Id!
)
? settings.mediaListCollectionIds.filter(
(id) => id !== mlc.Id
)
: [
...settings.mediaListCollectionIds,
mlc.Id!,
],
});
}}
/>
</ListItem>
))}
</ListGroup>
<Text className="px-4 text-xs text-neutral-500 mt-1">
Select the lists you want displayed on the home screen.
</Text>
</>
)}
</>
) : (
<Loader />
)}
</>
)}
</View>
);
}

View File

@@ -104,9 +104,12 @@ const page: React.FC = () => {
"CanDelete",
"MediaSourceCount",
],
// true is needed for merged versions
recursive: true,
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => parseInt(year)),
includeItemTypes: ["Movie", "Series", "MusicAlbum"],
});
return response.data || null;

View File

@@ -1,11 +1,7 @@
import { Text } from "@/components/common/Text";
import { ItemContent } from "@/components/ItemContent";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import {
getMediaInfoApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
@@ -48,20 +44,25 @@ const Page: React.FC = () => {
});
const fadeOut = (callback: any) => {
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
setTimeout(() => {
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
}, 100);
};
const fadeIn = (callback: any) => {
opacity.value = withTiming(1, { duration: 300 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
setTimeout(() => {
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
}, 100);
};
useEffect(() => {
if (item) {
fadeOut(() => {});
@@ -84,14 +85,24 @@ const Page: React.FC = () => {
style={[animatedStyle]}
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
>
<View className="h-[350px] bg-transparent rounded-lg mb-4 w-full"></View>
<View className="h-6 bg-neutral-900 rounded mb-1 w-12"></View>
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-1/2"></View>
<View className="h-12 bg-neutral-900 rounded-lg w-2/3 mb-10"></View>
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-full"></View>
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-1/4"></View>
<View
style={{
height: item?.Type === "Episode" ? 300 : 450,
}}
className="bg-transparent rounded-lg mb-4 w-full"
></View>
<View className="h-6 bg-neutral-900 rounded mb-4 w-14"></View>
<View className="h-10 bg-neutral-900 rounded-lg mb-2 w-1/2"></View>
<View className="h-3 bg-neutral-900 rounded mb-3 w-8"></View>
<View className="flex flex-row space-x-1 mb-8">
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
</View>
<View className="h-3 bg-neutral-900 rounded w-2/3 mb-1"></View>
<View className="h-10 bg-neutral-900 rounded-lg w-full mb-2"></View>
<View className="h-12 bg-neutral-900 rounded-lg w-full mb-2"></View>
<View className="h-24 bg-neutral-900 rounded-lg mb-1 w-full"></View>
</Animated.View>
{item && <ItemContent item={item} />}
</View>

View File

@@ -0,0 +1,309 @@
import React, { useCallback, useRef, useState } from "react";
import { useLocalSearchParams } from "expo-router";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { Text } from "@/components/common/Text";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { Image } from "expo-image";
import { TouchableOpacity, View} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { OverviewText } from "@/components/OverviewText";
import { GenreTags } from "@/components/GenreTags";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import { useQuery } from "@tanstack/react-query";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { Button } from "@/components/Button";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetModal, BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import {
IssueType,
IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue";
import * as DropdownMenu from "zeego/dropdown-menu";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { JellyserrRatings } from "@/components/Ratings";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const {
mediaTitle,
releaseYear,
canRequest: canRequestString,
posterSrc,
...result
} = params as unknown as {
mediaTitle: string;
releaseYear: number;
canRequest: string;
posterSrc: string;
} & Partial<MovieResult | TvResult>;
const canRequest = canRequestString === "true";
const { jellyseerrApi, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>();
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 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 () => {
requestMedia(mediaTitle, {
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),
},
refetch
)
},
[details, result, requestMedia]
);
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: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${result.backdropPath}`,
}}
/>
) : (
<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>
{canRequest ? (
<Button color="purple" onPress={request}>
Request
</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",
}}
>
Report issue
</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}
/>
)}
</View>
</View>
</ParallaxScrollView>
<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">
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">
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]
: "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>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="(optional) 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">
Submit
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</View>
);
};
export default Page;

View File

@@ -1,13 +1,21 @@
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { HourHeader } from "@/components/livetv/HourHeader";
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
import { TAB_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React, { useCallback, useMemo, useState } from "react";
import { Button, Dimensions, ScrollView, View } from "react-native";
import {
Button,
Dimensions,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const HOUR_HEIGHT = 30;
@@ -78,8 +86,6 @@ export default function page() {
const screenWidth = Dimensions.get("window").width;
const memoizedChannels = useMemo(() => channels?.Items || [], [channels]);
const [scrollX, setScrollX] = useState(0);
const handleNextPage = useCallback(() => {
@@ -100,24 +106,15 @@ export default function page() {
paddingRight: insets.right,
paddingBottom: 16,
}}
style={{
marginBottom: TAB_HEIGHT,
}}
>
<View className="flex flex-row bg-neutral-800 w-full items-end">
<Button
title="Previous"
onPress={handlePrevPage}
disabled={currentPage === 1}
/>
<Button
title="Next"
onPress={handleNextPage}
disabled={
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
}
/>
</View>
<PageButtons
currentPage={currentPage}
onPrevPage={handlePrevPage}
onNextPage={handleNextPage}
isNextDisabled={
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
}
/>
<View className="flex flex-row">
<View className="flex flex-col w-[64px]">
@@ -166,3 +163,57 @@ export default function page() {
</ScrollView>
);
}
interface PageButtonsProps {
currentPage: number;
onPrevPage: () => void;
onNextPage: () => void;
isNextDisabled: boolean;
}
const PageButtons: React.FC<PageButtonsProps> = ({
currentPage,
onPrevPage,
onNextPage,
isNextDisabled,
}) => {
return (
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
<TouchableOpacity
onPress={onPrevPage}
disabled={currentPage === 1}
className="flex flex-row items-center"
>
<Ionicons
name="chevron-back"
size={24}
color={currentPage === 1 ? "gray" : "white"}
/>
<Text
className={`ml-1 ${
currentPage === 1 ? "text-gray-500" : "text-white"
}`}
>
Previous
</Text>
</TouchableOpacity>
<Text className="text-white">Page {currentPage}</Text>
<TouchableOpacity
onPress={onNextPage}
disabled={isNextDisabled}
className="flex flex-row items-center"
>
<Text
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
>
Next
</Text>
<Ionicons
name="chevron-forward"
size={24}
color={isNextDisabled ? "gray" : "white"}
/>
</TouchableOpacity>
</View>
);
};

View File

@@ -5,10 +5,7 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import React from "react";
import {
ScrollView,
View
} from "react-native";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function page() {
@@ -27,9 +24,6 @@ export default function page() {
paddingBottom: 16,
paddingTop: 8,
}}
style={{
marginBottom: TAB_HEIGHT,
}}
>
<View className="flex flex-col space-y-2">
<ScrollingCollectionList

View File

@@ -1,21 +1,21 @@
import { Text } from "@/components/common/Text";
import { AddToFavorites } from "@/components/AddToFavorites";
import { DownloadItems } from "@/components/DownloadItem";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { NextUp } from "@/components/series/NextUp";
import { SeasonPicker } from "@/components/series/SeasonPicker";
import { SeriesHeader } from "@/components/series/SeriesHeader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Ionicons } from "@expo/vector-icons";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import {useLocalSearchParams, useNavigation} from "expo-router";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, {useEffect} from "react";
import { useMemo } from "react";
import React, { useEffect, useMemo } from "react";
import { View } from "react-native";
import {DownloadItems} from "@/components/DownloadItem";
import {MaterialCommunityIcons} from "@expo/vector-icons";
import {getTvShowsApi} from "@jellyfin/sdk/lib/utils/api";
const page: React.FC = () => {
const navigation = useNavigation();
@@ -36,7 +36,6 @@ const page: React.FC = () => {
userId: user?.Id,
itemId: seriesId,
}),
enabled: !!seriesId && !!api,
staleTime: 60 * 1000,
});
@@ -60,7 +59,7 @@ const page: React.FC = () => {
[item]
);
const {data: allEpisodes, isLoading} = useQuery({
const { data: allEpisodes, isLoading } = useQuery({
queryKey: ["AllEpisodes", item?.Id],
queryFn: async () => {
const res = await getTvShowsApi(api!).getEpisodes({
@@ -69,34 +68,42 @@ const page: React.FC = () => {
enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview"],
});
return res?.data.Items || []
return res?.data.Items || [];
},
enabled: !!api && !!user?.Id && !!item?.Id
staleTime: 60,
enabled: !!api && !!user?.Id && !!item?.Id,
});
useEffect(() => {
navigation.setOptions({
headerRight: () => (
(!isLoading && allEpisodes && allEpisodes.length > 0) && (
headerRight: () =>
!isLoading &&
item &&
allEpisodes &&
allEpisodes.length > 0 && (
<View className="flex flex-row items-center space-x-2">
<AddToFavorites item={item} type="series" />
<DownloadItems
size="large"
title="Download Series"
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
<MaterialCommunityIcons name="folder-download" size={24} color="white"/>
<Ionicons name="download" size={22} color="white" />
)}
DownloadedIconComponent={() => (
<MaterialCommunityIcons name="folder-check" size={26} color="#9333ea"/>
<Ionicons
name="checkmark-done-outline"
size={24}
color="#9333ea"
/>
)}
/>
</View>
)
)
})
}, [allEpisodes, isLoading]);
if (!item || !backdropUrl)
return null;
),
});
}, [allEpisodes, isLoading, item]);
if (!item || !backdropUrl) return null;
return (
<ParallaxScrollView
@@ -130,10 +137,7 @@ const page: React.FC = () => {
}
>
<View className="flex flex-col pt-4">
<View className="px-4 py-4">
<Text className="text-3xl font-bold">{item?.Name}</Text>
<Text className="">{item?.Overview}</Text>
</View>
<SeriesHeader item={item} />
<View className="mb-4">
<NextUp seriesId={seriesId} />
</View>

View File

@@ -32,6 +32,7 @@ import {
import {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getFilterApi,
@@ -41,8 +42,6 @@ import {
import { FlashList } from "@shopify/flash-list";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
const Page = () => {
const searchParams = useLocalSearchParams();
const { libraryId } = searchParams as { libraryId: string };
@@ -141,6 +140,20 @@ const Page = () => {
}): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !library) return null;
let itemType: BaseItemKind | undefined;
// This fix makes sure to only return 1 type of items, if defined.
// This is because the underlying directory some times contains other types, and we don't want to show them.
if (library.CollectionType === "movies") {
itemType = "Movie";
} else if (library.CollectionType === "tvshows") {
itemType = "Series";
} else if (library.CollectionType === "boxsets") {
itemType = "BoxSet";
} else if (library.CollectionType === "music") {
itemType = "MusicAlbum";
}
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: libraryId,
@@ -149,12 +162,14 @@ const Page = () => {
sortBy: [sortBy[0], "SortName", "ProductionYear"],
sortOrder: [sortOrder[0]],
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
recursive: false,
// true is needed for merged versions
recursive: true,
imageTypeLimit: 1,
fields: ["PrimaryImageAspectRatio", "SortName"],
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => parseInt(year)),
includeItemTypes: itemType ? [itemType] : undefined,
});
return response.data || null;

View File

@@ -19,6 +19,9 @@ export default function IndexLayout() {
headerLargeTitle: true,
headerTitle: "Library",
headerBlurEffect: "prominent",
headerLargeStyle: {
backgroundColor: "black",
},
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerRight: () => (

View File

@@ -1,4 +1,7 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import {
commonScreenOptions,
nestedTabPageScreenOptions,
} from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router";
import { Platform } from "react-native";
@@ -11,6 +14,9 @@ export default function SearchLayout() {
headerShown: true,
headerLargeTitle: true,
headerTitle: "Search",
headerLargeStyle: {
backgroundColor: "black",
},
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
@@ -29,6 +35,7 @@ export default function SearchLayout() {
headerShadowVisible: false,
}}
/>
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
</Stack>
);
}

View File

@@ -20,6 +20,7 @@ import axios from "axios";
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, {
PropsWithChildren,
useCallback,
useEffect,
useLayoutEffect,
@@ -29,6 +30,15 @@ import React, {
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Tag } from "@/components/GenreTags";
import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
import { sortBy } from "lodash";
type SearchType = "Library" | "Discover";
const exampleSearches = [
"Lord of the rings",
@@ -45,6 +55,7 @@ export default function search() {
const { q, prev } = params as { q: string; prev: Href<string> };
const [searchType, setSearchType] = useState<SearchType>("Library");
const [search, setSearch] = useState<string>("");
const [debouncedSearch] = useDebounce(search, 500);
@@ -53,6 +64,7 @@ export default function search() {
const [user] = useAtom(userAtom);
const [settings] = useSettings();
const { jellyseerrApi } = useJellyseerr();
const searchEngine = useMemo(() => {
return settings?.searchEngine || "Jellyfin";
@@ -83,6 +95,7 @@ export default function search() {
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
} else {
if (!settings?.marlinServerUrl) return [];
const url = `${
settings.marlinServerUrl
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
@@ -90,6 +103,7 @@ export default function search() {
.join("&includeItemTypes=")}`;
const response1 = await axios.get(url);
const ids = response1.data.ids;
if (!ids || !ids.length) return [];
@@ -132,9 +146,51 @@ export default function search() {
query: debouncedSearch,
types: ["Movie"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: jellyseerrResults, isFetching: j1 } = useQuery({
queryKey: ["search", "jellyseerrResults", debouncedSearch],
queryFn: async () => {
const response = await jellyseerrApi?.search({
query: new URLSearchParams(debouncedSearch).toString(),
page: 1, // todo: maybe rework page & page-size if first results are not enough...
language: "en",
});
return response?.results;
},
enabled:
!!jellyseerrApi &&
searchType === "Discover" &&
debouncedSearch.length > 0,
});
const { data: jellyseerrDiscoverSettings, isFetching: j2 } = useQuery({
queryKey: ["search", "jellyseerrDiscoverSettings", debouncedSearch],
queryFn: async () => jellyseerrApi?.discoverSettings(),
enabled:
!!jellyseerrApi &&
searchType === "Discover" &&
debouncedSearch.length == 0,
});
const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.MOVIE
) as MovieResult[],
[jellyseerrResults]
);
const jellyseerrTvResults: TvResult[] | undefined = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.TV
) as TvResult[],
[jellyseerrResults]
);
const { data: series, isFetching: l2 } = useQuery({
queryKey: ["search", "series", debouncedSearch],
queryFn: () =>
@@ -142,7 +198,7 @@ export default function search() {
query: debouncedSearch,
types: ["Series"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: episodes, isFetching: l3 } = useQuery({
@@ -152,7 +208,7 @@ export default function search() {
query: debouncedSearch,
types: ["Episode"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: collections, isFetching: l7 } = useQuery({
@@ -162,7 +218,7 @@ export default function search() {
query: debouncedSearch,
types: ["BoxSet"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: actors, isFetching: l8 } = useQuery({
@@ -172,7 +228,7 @@ export default function search() {
query: debouncedSearch,
types: ["Person"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: artists, isFetching: l4 } = useQuery({
@@ -182,7 +238,7 @@ export default function search() {
query: debouncedSearch,
types: ["MusicArtist"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: albums, isFetching: l5 } = useQuery({
@@ -192,7 +248,7 @@ export default function search() {
query: debouncedSearch,
types: ["MusicAlbum"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: songs, isFetching: l6 } = useQuery({
@@ -202,7 +258,7 @@ export default function search() {
query: debouncedSearch,
types: ["Audio"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const noResults = useMemo(() => {
@@ -214,13 +270,25 @@ export default function search() {
episodes?.length ||
series?.length ||
collections?.length ||
actors?.length
actors?.length ||
jellyseerrMovieResults?.length ||
jellyseerrTvResults?.length
);
}, [artists, episodes, albums, songs, movies, series, collections, actors]);
}, [
artists,
episodes,
albums,
songs,
movies,
series,
collections,
actors,
jellyseerrResults,
]);
const loading = useMemo(() => {
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8;
}, [l1, l2, l3, l4, l5, l6, l7, l8]);
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8 || j1 || j2;
}, [l1, l2, l3, l4, l5, l6, l7, l8, j1, j2]);
return (
<>
@@ -245,6 +313,28 @@ export default function search() {
/>
</View>
)}
{jellyseerrApi && (
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
<TouchableOpacity onPress={() => setSearchType("Library")}>
<Tag
text="Library"
textClass="p-1"
className={
searchType === "Library" ? "bg-purple-600" : undefined
}
/>
</TouchableOpacity>
<TouchableOpacity onPress={() => setSearchType("Discover")}>
<Tag
text="Discover"
textClass="p-1"
className={
searchType === "Discover" ? "bg-purple-600" : undefined
}
/>
</TouchableOpacity>
</View>
)}
{!!q && (
<View className="px-4 flex flex-col space-y-2">
<Text className="text-neutral-500 ">
@@ -252,130 +342,153 @@ export default function search() {
</Text>
</View>
)}
<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>
)}
/>
{searchType === "Library" && (
<>
<SearchItemWrapper
header="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="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="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="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="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>
)}
/>
<SearchItemWrapper
ids={artists?.map((m) => m.Id!)}
header="Artists"
renderItem={(item: BaseItemDto) => (
<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: BaseItemDto) => (
<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: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.AlbumId} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
</>
)}
{searchType === "Discover" && (
<>
<SearchItemWrapper
header="Request Movies"
items={jellyseerrMovieResults}
renderItem={(item: MovieResult) => (
<JellyseerrPoster item={item} key={item.id} />
)}
/>
<SearchItemWrapper
header="Request Series"
items={jellyseerrTvResults}
renderItem={(item: TvResult) => (
<JellyseerrPoster item={item} key={item.id} />
)}
/>
</>
)}
{loading ? (
<View className="mt-4 flex justify-center items-center">
<Loader />
@@ -389,7 +502,7 @@ export default function search() {
"{debouncedSearch}"
</Text>
</View>
) : debouncedSearch.length === 0 ? (
) : debouncedSearch.length === 0 && searchType === "Library" ? (
<View className="mt-4 flex flex-col items-center space-y-2">
{exampleSearches.map((e) => (
<TouchableOpacity
@@ -401,6 +514,15 @@ export default function search() {
</TouchableOpacity>
))}
</View>
) : debouncedSearch.length === 0 && searchType === "Discover" ? (
<View className="flex flex-col">
{sortBy?.(
jellyseerrDiscoverSettings?.filter((s) => s.enabled),
"order"
).map((slide) => (
<DiscoverSlide key={slide.id} slide={slide} />
))}
</View>
) : null}
</View>
</ScrollView>
@@ -408,13 +530,19 @@ export default function search() {
);
}
type Props = {
type Props<T> = {
ids?: string[] | null;
renderItem: (item: BaseItemDto) => React.ReactNode;
items?: T[];
renderItem: (item: any) => React.ReactNode;
header?: string;
};
const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
const SearchItemWrapper = <T extends unknown>({
ids,
items,
renderItem,
header,
}: PropsWithChildren<Props<T>>) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -444,7 +572,7 @@ const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
staleTime: Infinity,
});
if (!data) return null;
if (!data && (!items || items.length === 0)) return null;
return (
<>
@@ -454,7 +582,11 @@ const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
className="px-4 mb-2"
showsHorizontalScrollIndicator={false}
>
{data.map((item) => renderItem(item))}
{data && data?.length > 0
? data.map((item) => renderItem(item))
: items && items?.length > 0
? items.map((i) => renderItem(i))
: undefined}
</ScrollView>
</>
);

View File

@@ -48,7 +48,10 @@ export default function TabLayout() {
Platform.OS == "android"
? ({ color, focused, size }) =>
require("@/assets/icons/house.fill.png")
: () => ({ sfSymbol: "house" }),
: ({ focused }) =>
focused
? { sfSymbol: "house.fill" }
: { sfSymbol: "house" },
}}
/>
<NativeTabs.Screen
@@ -59,7 +62,26 @@ export default function TabLayout() {
Platform.OS == "android"
? ({ color, focused, size }) =>
require("@/assets/icons/magnifyingglass.png")
: () => ({ sfSymbol: "magnifyingglass" }),
: ({ focused }) =>
focused
? { sfSymbol: "magnifyingglass" }
: { sfSymbol: "magnifyingglass" },
}}
/>
<NativeTabs.Screen
name="(favorites)"
options={{
title: "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
@@ -70,7 +92,10 @@ export default function TabLayout() {
Platform.OS == "android"
? ({ color, focused, size }) =>
require("@/assets/icons/server.rack.png")
: () => ({ sfSymbol: "rectangle.stack" }),
: ({ focused }) =>
focused
? { sfSymbol: "rectangle.stack.fill" }
: { sfSymbol: "rectangle.stack" },
}}
/>
<NativeTabs.Screen
@@ -81,8 +106,11 @@ export default function TabLayout() {
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
tabBarIcon:
Platform.OS == "android"
? () => require("@/assets/icons/list.png")
: () => ({ sfSymbol: "list.dash" }),
? ({ focused }) => require("@/assets/icons/list.png")
: ({ focused }) =>
focused
? { sfSymbol: "list.dash.fill" }
: { sfSymbol: "list.dash" },
}}
/>
</NativeTabs>

View File

@@ -43,6 +43,7 @@ import {
View,
AppState,
AppStateStatus,
Platform,
} from "react-native";
import { useSharedValue } from "react-native-reanimated";
import settings from "../(tabs)/(home)/settings";
@@ -103,7 +104,6 @@ export default function page() {
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
console.log("Offline:", offline);
if (offline) {
const item = await getDownloadedItem(itemId);
if (item) return item.item;
@@ -125,16 +125,8 @@ export default function page() {
isLoading: isLoadingStreamUrl,
isError: isErrorStreamUrl,
} = useQuery({
queryKey: [
"stream-url",
itemId,
audioIndex,
subtitleIndex,
mediaSourceId,
bitrateValue,
],
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
queryFn: async () => {
console.log("Offline:", offline);
if (offline) {
const data = await getDownloadedItem(itemId);
if (!data?.mediaSource) return null;
@@ -201,8 +193,6 @@ export default function page() {
playSessionId: stream.sessionId,
});
}
console.log("Actually marked as paused");
} else {
videoRef.current?.play();
if (!offline && stream) {
@@ -254,6 +244,7 @@ export default function page() {
videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]);
// TODO: unused should remove.
const reportPlaybackStart = useCallback(async () => {
if (offline) return;
@@ -287,8 +278,6 @@ export default function page() {
if (!item?.Id || !stream) return;
console.log("onProgress ~", currentTimeInTicks, isPlaying);
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
@@ -300,7 +289,7 @@ export default function page() {
playSessionId: stream.sessionId,
});
},
[item?.Id, isPlaying, api, isPlaybackStopped]
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
);
useOrientation();
@@ -346,7 +335,6 @@ export default function page() {
React.useCallback(() => {
return async () => {
stop();
console.log("Unmounted");
};
}, [])
);
@@ -356,10 +344,8 @@ export default function page() {
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (appState.match(/inactive|background/) && nextAppState === "active") {
console.log("App has come to the foreground!");
// Handle app coming to the foreground
} 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();
@@ -449,7 +435,7 @@ export default function page() {
position: "relative",
flexDirection: "column",
justifyContent: "center",
opacity: showControls ? 0.5 : 1,
opacity: showControls ? (Platform.OS === "android" ? 0.7 : 0.5) : 1,
}}
>
<VlcPlayerView
@@ -502,7 +488,7 @@ export default function page() {
enableTrickplay={true}
getAudioTracks={videoRef.current?.getAudioTracks}
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
offline={false}
offline={offline}
setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack}

View File

@@ -169,18 +169,15 @@ export default function page() {
);
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();

View File

@@ -30,7 +30,7 @@ import React, {
useRef,
useState,
} from "react";
import { BackHandler, View } from "react-native";
import { View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, {
OnProgressData,
@@ -38,6 +38,7 @@ import Video, {
SelectedTrackType,
VideoRef,
} from "react-native-video";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
const Player = () => {
const api = useAtomValue(apiAtom);
@@ -53,6 +54,7 @@ const Player = () => {
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);
@@ -111,19 +113,14 @@ const Player = () => {
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,
audioIndex,
subtitleIndex,
bitrateValue,
mediaSourceId,
],
queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId, audioIndex],
queryFn: async () => {
if (!api) {
@@ -324,25 +321,21 @@ const Player = () => {
SelectedTrack | undefined
>(undefined);
// Set intial Subtitle Track.
// We will only select external tracks if they are are text based. Else it should be burned in already.
const textSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle" && sub.IsTextSubtitleStream
) || [];
const uniqueTextSubs = Array.from(
new Set(textSubs.map((sub) => sub.DisplayTitle))
).map((title) => textSubs.find((sub) => sub.DisplayTitle === title));
const chosenSubtitleTrack = textSubs.find(
(sub) => sub.Index === subtitleIndex
);
useEffect(() => {
if (chosenSubtitleTrack && selectedTextTrack === undefined) {
console.log("Setting selected text track", chosenSubtitleTrack);
if (selectedTextTrack === undefined) {
const subtitleHelper = new SubtitleHelper(
stream?.mediaSource.MediaStreams ?? []
);
const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex(
subtitleIndex!
);
// Most likely the subtitle is burned in.
if (embeddedTrackIndex === -1) return;
setSelectedTextTrack({
type: SelectedTrackType.INDEX,
value: uniqueTextSubs.indexOf(chosenSubtitleTrack),
value: embeddedTrackIndex,
});
}
}, [embededTextTracks]);
@@ -435,7 +428,6 @@ const Player = () => {
setIsBuffering(e.isBuffering);
}}
onAudioTracks={(e) => {
console.log("onAudioTracks: ", e.audioTracks);
setAudioTracks(
e.audioTracks.map((t) => ({
index: t.index,
@@ -489,7 +481,6 @@ const Player = () => {
}}
getAudioTracks={getAudioTracks}
setAudioTrack={(i) => {
console.log("setAudioTrack ~", i);
setSelectedAudioTrack({
type: SelectedTrackType.INDEX,
value: i,

View File

@@ -1,3 +1,5 @@
import "@/augmentations";
import { Text } from "@/components/common/Text";
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
getOrSetDeviceId,
@@ -35,7 +37,7 @@ import * as SplashScreen from "expo-splash-screen";
import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai";
import { useEffect, useRef } from "react";
import { Appearance, AppState } from "react-native";
import { Appearance, AppState, TouchableOpacity } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated";
@@ -317,7 +319,7 @@ function Layout() {
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack>
<Stack.Screen
name="(auth)/(tabs)"
options={{
@@ -336,7 +338,11 @@ function Layout() {
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name="+not-found" />
</Stack>

View File

@@ -1,19 +1,21 @@
import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { PreviousServersList } from "@/components/PreviousServersList";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
SafeAreaView,
TouchableOpacity,
View,
} from "react-native";
@@ -65,6 +67,23 @@ const Login: React.FC = () => {
})();
}, [_apiUrl, _username, _password]);
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerTitle: serverName,
headerLeft: () =>
api?.basePath ? (
<TouchableOpacity
onPress={() => {
removeServer();
}}
>
<Ionicons name="chevron-back" size={24} color="white" />
</TouchableOpacity>
) : null,
});
}, [serverName, navigation, api?.basePath]);
const [loading, setLoading] = useState<boolean>(false);
const handleLogin = async () => {
@@ -102,43 +121,26 @@ const Login: React.FC = () => {
* - Sets loadingServerCheck state to true at the beginning and false at the end.
* - Logs errors and timeout information to the console.
*/
async function checkUrl(url: string) {
url = url.endsWith("/") ? url.slice(0, -1) : url;
const checkUrl = useCallback(async (url: string) => {
setLoadingServerCheck(true);
const protocols = ["https://", "http://"];
const timeout = 2000; // 2 seconds timeout for long 404 responses
try {
for (const protocol of protocols) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const response = await fetch(`${url}/System/Info/Public`, {
mode: "cors",
});
try {
const response = await fetch(`${protocol}${url}/System/Info/Public`, {
mode: "cors",
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok) {
const data = (await response.json()) as PublicSystemInfo;
setServerName(data.ServerName || "");
return `${protocol}${url}`;
}
} catch (e) {
const error = e as Error;
if (error.name === "AbortError") {
console.error(`Request to ${protocol}${url} timed out`);
} else {
console.error(`Error checking ${protocol}${url}:`, error);
}
}
if (response.ok) {
const data = (await response.json()) as PublicSystemInfo;
setServerName(data.ServerName || "");
return url;
}
return undefined;
} finally {
setLoadingServerCheck(false);
}
}
}, []);
/**
* Handles the connection attempt to a Jellyfin server.
@@ -156,12 +158,10 @@ const Login: React.FC = () => {
* - 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();
const result = await checkUrl(
url.startsWith("http") ? new URL(url).host : url
);
const result = await checkUrl(url);
if (result === undefined) {
Alert.alert(
@@ -171,8 +171,8 @@ const Login: React.FC = () => {
return;
}
setServer({ address: result });
};
setServer({ address: url });
}, []);
const handleQuickConnect = async () => {
try {
@@ -196,38 +196,21 @@ const Login: React.FC = () => {
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1, height: "100%" }}
>
<View className="flex flex-col w-full h-full relative items-center justify-center">
<View className="px-4 -mt-20">
<View className="mb-4">
<Text className="text-3xl font-bold mb-1">
{serverName || "Streamyfin"}
</Text>
<View className="bg-neutral-900 rounded-xl p-4 mb-2 flex flex-row items-center justify-between">
<Text className="">URL</Text>
<Text numberOfLines={1} className="shrink">
{api.basePath}
</Text>
</View>
<Button
color="black"
onPress={() => {
removeServer();
}}
justify="between"
iconLeft={
<Ionicons
name="arrow-back-outline"
size={18}
color={"white"}
/>
}
>
Change server
</Button>
</View>
<View className="flex flex-col h-full relative items-center justify-center">
<View className="px-4 -mt-20 w-full">
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold">Log in</Text>
<Text className="text-2xl font-bold -mb-2">
Log in
<>
{serverName ? (
<>
{" to "}
<Text className="text-purple-600">{serverName}</Text>
</>
) : null}
</>
</Text>
<Text className="text-xs text-neutral-400">{api.basePath}</Text>
<Input
placeholder="Username"
onChangeText={(text) =>
@@ -301,7 +284,7 @@ const Login: React.FC = () => {
/>
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="text-neutral-500">
Connect to your Jellyfin server
Enter the URL to your Jellyfin server
</Text>
<Input
placeholder="Server URL"
@@ -313,6 +296,14 @@ const Login: React.FC = () => {
textContentType="URL"
maxLength={500}
/>
<Text className="text-xs text-neutral-500 ml-4">
Make sure to include http or https
</Text>
<PreviousServersList
onServerSelect={(s) => {
handleConnect(s.address);
}}
/>
</View>
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
<Button

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

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

3
augmentations/index.ts Normal file
View File

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

17
augmentations/mmkv.ts Normal file
View File

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

37
augmentations/number.ts Normal file
View File

@@ -0,0 +1,37 @@
declare global {
interface Number {
bytesToReadable(): string;
secondsToMilliseconds(): number;
minutesToMilliseconds(): number;
hoursToMilliseconds(): number;
}
}
Number.prototype.bytesToReadable = function () {
const bytes = this.valueOf();
const gb = bytes / 1e9;
if (gb >= 1) return `${gb.toFixed(0)} GB`;
const mb = bytes / 1024.0 / 1024.0;
if (mb >= 1) return `${mb.toFixed(0)} MB`;
const kb = bytes / 1024.0;
if (kb >= 1) return `${kb.toFixed(0)} KB`;
return `${bytes.toFixed(2)} B`;
};
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 {};

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

@@ -3,7 +3,8 @@ import React, { PropsWithChildren, ReactNode, useMemo } from "react";
import { Text, TouchableOpacity, View } from "react-native";
import { Loader } from "./Loader";
interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
export interface ButtonProps
extends React.ComponentProps<typeof TouchableOpacity> {
onPress?: () => void;
className?: string;
textClassName?: string;
@@ -46,7 +47,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
<TouchableOpacity
className={`
p-3 rounded-xl items-center justify-center
${loading || (disabled && "opacity-50")}
${(loading || disabled) && "opacity-50"}
${colorClasses}
${className}
`}

View File

@@ -10,6 +10,7 @@ import GoogleCast, {
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
import { RoundButton } from "./RoundButton";
interface Props extends ViewProps {
width?: number;
@@ -33,6 +34,7 @@ export const Chromecast: React.FC<Props> = ({
useEffect(() => {
(async () => {
if (!discoveryManager) {
console.warn("DiscoveryManager is not initialized");
return;
}
@@ -53,51 +55,32 @@ export const Chromecast: React.FC<Props> = ({
if (background === "transparent")
return (
<>
<TouchableOpacity
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
className="rounded-full h-10 w-10 flex items-center justify-center b"
{...props}
>
<Feather name="cast" size={22} color={"white"} />
</TouchableOpacity>
<AndroidCastButton />
</>
);
if (Platform.OS === "android")
return (
<TouchableOpacity
<RoundButton
size="large"
className="mr-2"
background={false}
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
className="rounded-full h-10 w-10 flex items-center justify-center bg-neutral-800/80"
{...props}
>
<AndroidCastButton />
<Feather name="cast" size={22} color={"white"} />
</TouchableOpacity>
</RoundButton>
);
return (
<TouchableOpacity
<RoundButton
size="large"
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
{...props}
>
<BlurView
intensity={100}
className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center"
{...props}
>
<Feather name="cast" size={22} color={"white"} />
</BlurView>
<AndroidCastButton />
</TouchableOpacity>
<Feather name="cast" size={22} color={"white"} />
</RoundButton>
);
};

View File

@@ -21,7 +21,7 @@ import {
import { Href, router, useFocusEffect } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
import { Alert, View, ViewProps } from "react-native";
import { toast } from "sonner-native";
import { AudioTrackSelector } from "./AudioTrackSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector";
@@ -30,18 +30,25 @@ import { Text } from "./common/Text";
import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle";
import { RoundButton } from "./RoundButton";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
interface DownloadProps extends ViewProps {
items: BaseItemDto[];
MissingDownloadIconComponent: () => React.ReactElement;
DownloadedIconComponent: () => React.ReactElement;
title?: string;
subtitle?: string;
size?: "default" | "large";
}
export const DownloadItems: React.FC<DownloadProps> = ({
items,
MissingDownloadIconComponent,
DownloadedIconComponent,
title = "Download",
subtitle = "",
size = "default",
...props
}) => {
const [api] = useAtom(apiAtom);
@@ -71,9 +78,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
[settings]
);
/**
* Bottom sheet
*/
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const handlePresentModalPress = useCallback(() => {
@@ -86,18 +90,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
bottomSheetModalRef.current?.dismiss();
}, []);
// region computed
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
const pendingItems = useMemo(
const itemsNotDownloaded = useMemo(
() =>
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
[items, downloadedFiles]
);
const isDownloaded = useMemo(() => {
if (!downloadedFiles) return false;
return pendingItems.length == 0;
}, [downloadedFiles, pendingItems]);
const allItemsDownloaded = useMemo(() => {
if (items.length === 0) return false;
return itemsNotDownloaded.length === 0;
}, [items, itemsNotDownloaded]);
const itemsProcesses = useMemo(
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
[processes, itemIds]
@@ -116,13 +120,10 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const itemsQueued = useMemo(() => {
return (
pendingItems.length > 0 &&
pendingItems.every((p) => queue.some((q) => p.Id == q.item.Id))
itemsNotDownloaded.length > 0 &&
itemsNotDownloaded.every((p) => queue.some((q) => p.Id == q.item.Id))
);
}, [queue, pendingItems]);
// endregion computed
// region helper functions
}, [queue, itemsNotDownloaded]);
const navigateToDownloads = () => router.push("/downloads");
const onDownloadedPress = () => {
@@ -141,17 +142,17 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (pendingItems.some((i) => !i.Id)) {
if (itemsNotDownloaded.some((i) => !i.Id)) {
throw new Error("No item id");
}
closeModal();
if (usingOptimizedServer) initiateDownload(...pendingItems);
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
else {
queueActions.enqueue(
queue,
setQueue,
...pendingItems.map((item) => ({
...itemsNotDownloaded.map((item) => ({
id: item.Id!,
execute: async () => await initiateDownload(item),
item,
@@ -164,27 +165,22 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}, [
queue,
setQueue,
pendingItems,
itemsNotDownloaded,
usingOptimizedServer,
userCanDownload,
// Need to be reference at the time async lambda is created for initiateDownload
maxBitrate,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
]);
/**
* Start download
*/
const initiateDownload = useCallback(
async (...items: BaseItemDto[]) => {
if (
!api ||
!user?.Id ||
items.some((p) => !p.Id) ||
(pendingItems.length === 1 && !selectedMediaSource?.Id)
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
) {
throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item"
@@ -195,7 +191,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
let subtitleIndex: number | undefined = selectedSubtitleStream;
for (const item of items) {
if (pendingItems.length > 1) {
if (itemsNotDownloaded.length > 1) {
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
item,
settings!
@@ -238,7 +234,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
[
api,
user?.Id,
pendingItems,
itemsNotDownloaded,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
@@ -260,58 +256,61 @@ export const DownloadItems: React.FC<DownloadProps> = ({
),
[]
);
// endregion helper functions
// Allow to select & set settings for single download
useFocusEffect(
useCallback(() => {
if (!settings) return;
if (pendingItems.length !== 1) return;
if (itemsNotDownloaded.length !== 1) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(items[0], settings);
// 4. Set states
setSelectedMediaSource(mediaSource ?? undefined);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [items, pendingItems, settings])
}, [items, itemsNotDownloaded, settings])
);
return (
<View
className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center"
{...props}
>
{processes && itemsProcesses.length > 0 ? (
<TouchableOpacity onPress={navigateToDownloads}>
{progress === 0 ? (
<Loader />
) : (
<View className="-rotate-45">
<ProgressCircle
size={24}
fill={progress}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
/>
</View>
)}
</TouchableOpacity>
) : itemsQueued ? (
<TouchableOpacity onPress={navigateToDownloads}>
<Ionicons name="hourglass" size={24} color="white" />
</TouchableOpacity>
) : isDownloaded ? (
<TouchableOpacity onPress={onDownloadedPress}>
{DownloadedIconComponent()}
</TouchableOpacity>
const renderButtonContent = () => {
if (processes && itemsProcesses.length > 0) {
return progress === 0 ? (
<Loader />
) : (
<TouchableOpacity onPress={handlePresentModalPress}>
{MissingDownloadIconComponent()}
</TouchableOpacity>
)}
<View className="-rotate-45">
<ProgressCircle
size={24}
fill={progress}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
/>
</View>
);
} else if (itemsQueued) {
return <Ionicons name="hourglass" size={24} color="white" />;
} else if (allItemsDownloaded) {
return <DownloadedIconComponent />;
} else {
return <MissingDownloadIconComponent />;
}
};
const onButtonPress = () => {
if (processes && itemsProcesses.length > 0) {
navigateToDownloads();
} else if (itemsQueued) {
navigateToDownloads();
} else if (allItemsDownloaded) {
onDownloadedPress();
} else {
handlePresentModalPress();
}
};
return (
<View {...props}>
<RoundButton size={size} onPress={onButtonPress}>
{renderButtonContent()}
</RoundButton>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
@@ -326,16 +325,21 @@ export const DownloadItems: React.FC<DownloadProps> = ({
>
<BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<Text className="font-bold text-2xl text-neutral-10">
Download options
</Text>
<View>
<Text className="font-bold text-2xl text-neutral-100">
{title}
</Text>
<Text className="text-neutral-300">
{subtitle || `Download ${itemsNotDownloaded.length} items`}
</Text>
</View>
<View className="flex flex-col space-y-2 w-full items-start">
<BitrateSelector
inverted
onChange={setMaxBitrate}
selected={maxBitrate}
/>
{pendingItems.length === 1 && (
{itemsNotDownloaded.length === 1 && (
<>
<MediaSourceSelector
item={items[0]}
@@ -380,11 +384,15 @@ export const DownloadItems: React.FC<DownloadProps> = ({
);
};
export const DownloadSingleItem: React.FC<{ item: BaseItemDto }> = ({
item,
}) => {
export const DownloadSingleItem: React.FC<{
size?: "default" | "large";
item: BaseItemDto;
}> = ({ item, size = "default" }) => {
return (
<DownloadItems
size={size}
title="Download Episode"
subtitle={item.Name!}
items={[item]}
MissingDownloadIconComponent={() => (
<Ionicons name="cloud-download-outline" size={24} color="white" />

View File

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

View File

@@ -13,12 +13,13 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
<View className="mt-2 flex flex-col">
{item.Type === "Episode" ? (
<>
<Text numberOfLines={2} className="">
{item.SeriesName}
<Text numberOfLines={1} className="">
{item.Name}
</Text>
<Text numberOfLines={1} className="text-xs opacity-50">
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}{" "}
{item.Name}
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
{" - "}
{item.SeriesName}
</Text>
</>
) : (

View File

@@ -15,6 +15,7 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom } from "@/providers/JellyfinProvider";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import {
@@ -30,8 +31,10 @@ import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { AddToFavorites } from "./AddToFavorites";
export type SelectedOptions = {
bitrate: Bitrate;
@@ -65,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.
useEffect(() => {
console.log(defaultAudioIndex, defaultSubtitleIndex);
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource,
@@ -87,8 +89,9 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<Chromecast background="blur" width={22} height={22} />
{item.Type !== "Program" && (
<View className="flex flex-row items-center space-x-2">
<DownloadSingleItem item={item} />
<DownloadSingleItem item={item} size="large" />
<PlayedStatus item={item} />
<AddToFavorites item={item} type="item" />
</View>
)}
</View>
@@ -109,6 +112,36 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
const [isTranscoding, setIsTranscoding] = useState(false);
const [previouslyChosenSubtitleIndex, setPreviouslyChosenSubtitleIndex] =
useState<number | undefined>(selectedOptions?.subtitleIndex);
useEffect(() => {
const isTranscoding = Boolean(selectedOptions?.bitrate.value);
if (isTranscoding) {
setPreviouslyChosenSubtitleIndex(selectedOptions?.subtitleIndex);
const subHelper = new SubtitleHelper(
selectedOptions?.mediaSource?.MediaStreams ?? []
);
const newSubtitleIndex = subHelper.getMostCommonSubtitleByName(
selectedOptions?.subtitleIndex
);
setSelectedOptions((prev) => ({
...prev!,
subtitleIndex: newSubtitleIndex ?? -1,
}));
}
if (!isTranscoding && previouslyChosenSubtitleIndex !== undefined) {
setSelectedOptions((prev) => ({
...prev!,
subtitleIndex: previouslyChosenSubtitleIndex,
}));
}
setIsTranscoding(isTranscoding);
}, [selectedOptions?.bitrate]);
if (!selectedOptions) return null;
return (
@@ -187,7 +220,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
className="mr-1"
source={selectedOptions.mediaSource}
onChange={(val) => {
console.log(val);
setSelectedOptions(
(prev) =>
prev && {
@@ -199,6 +231,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
selected={selectedOptions.audioIndex}
/>
<SubtitleTrackSelector
isTranscoding={isTranscoding}
source={selectedOptions.mediaSource}
onChange={(val) =>
setSelectedOptions(
@@ -225,7 +258,9 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<SeasonEpisodesCarousel item={item} loading={loading} />
)}
<OverviewText text={item.Overview} className="px-4 my-4" />
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
<OverviewText text={item.Overview} className="px-4 mb-4" />
{item.Type !== "Program" && (
<>
{item.Type === "Episode" && (
@@ -250,8 +285,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<SimilarItems itemId={item.Id} />
</>
)}
<View className="h-16"></View>
</View>
</ParallaxScrollView>
</View>

View File

@@ -1,10 +1,11 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import { View, ViewProps } from "react-native";
import { GenreTags } from "./GenreTags";
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
import { Ratings } from "./Ratings";
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
import { GenreTags } from "./GenreTags";
import React from "react";
import { ItemActions } from "./series/SeriesActions";
interface Props extends ViewProps {
item?: BaseItemDto | null;
@@ -27,7 +28,10 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
return (
<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" && (
<>
<EpisodeTitleHeader item={item} />

View File

@@ -0,0 +1,238 @@
import { Ionicons } from "@expo/vector-icons";
import {
MediaSourceInfo,
type MediaStream,
} from "@jellyfin/sdk/lib/generated-client";
import React, { useMemo, useRef } from "react";
import { TouchableOpacity, View } from "react-native";
import { Badge } from "./Badge";
import { Text } from "./common/Text";
import {
BottomSheetModal,
BottomSheetBackdropProps,
BottomSheetBackdrop,
BottomSheetView,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { Button } from "./Button";
interface Props {
source?: MediaSourceInfo;
}
export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
return (
<View className="px-4 mt-2 mb-4">
<Text className="text-lg font-bold mb-4">Video</Text>
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
<View className="flex flex-row space-x-2">
<VideoStreamInfo source={source} />
</View>
<Text className="text-purple-600">More details</Text>
</TouchableOpacity>
<BottomSheetModal
ref={bottomSheetModalRef}
snapPoints={["80%"]}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
>
<BottomSheetScrollView>
<View className="flex flex-col space-y-2 p-4 mb-4">
<View className="">
<Text className="text-lg font-bold mb-4">Video</Text>
<View className="flex flex-row space-x-2">
<VideoStreamInfo source={source} />
</View>
</View>
<View className="">
<Text className="text-lg font-bold mb-2">Audio</Text>
<AudioStreamInfo
audioStreams={
source?.MediaStreams?.filter(
(stream) => stream.Type === "Audio"
) || []
}
/>
</View>
<View className="">
<Text className="text-lg font-bold mb-2">Subtitles</Text>
<SubtitleStreamInfo
subtitleStreams={
source?.MediaStreams?.filter(
(stream) => stream.Type === "Subtitle"
) || []
}
/>
</View>
</View>
</BottomSheetScrollView>
</BottomSheetModal>
</View>
);
};
const SubtitleStreamInfo = ({
subtitleStreams,
}: {
subtitleStreams: MediaStream[];
}) => {
return (
<View className="flex flex-col">
{subtitleStreams.map((stream, index) => (
<View key={stream.Index} className="flex flex-col">
<Text className="text-xs mb-3 text-neutral-400">
{stream.DisplayTitle}
</Text>
<View className="flex flex-row flex-wrap gap-2">
<Badge
variant="gray"
iconLeft={
<Ionicons name="language-outline" size={16} color="white" />
}
text={stream.Language}
/>
<Badge
variant="gray"
text={stream.Codec}
iconLeft={
<Ionicons name="layers-outline" size={16} color="white" />
}
/>
</View>
</View>
))}
</View>
);
};
const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
return (
<View className="flex flex-col">
{audioStreams.map((audioStreams, index) => (
<View key={index} className="flex flex-col">
<Text className="mb-3 text-neutral-400 text-xs">
{audioStreams.DisplayTitle}
</Text>
<View className="flex-row flex-wrap gap-2">
<Badge
variant="gray"
iconLeft={
<Ionicons name="language-outline" size={16} color="white" />
}
text={audioStreams.Language}
/>
<Badge
variant="gray"
iconLeft={
<Ionicons
name="musical-notes-outline"
size={16}
color="white"
/>
}
text={audioStreams.Codec}
/>
<Badge
variant="gray"
iconLeft={<Ionicons name="mic-outline" size={16} color="white" />}
text={audioStreams.ChannelLayout}
/>
<Badge
variant="gray"
iconLeft={
<Ionicons name="speedometer-outline" size={16} color="white" />
}
text={formatBitrate(audioStreams.BitRate)}
/>
</View>
</View>
))}
</View>
);
};
const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
if (!source) return null;
const videoStream = useMemo(() => {
return source.MediaStreams?.find(
(stream) => stream.Type === "Video"
) as MediaStream;
}, [source.MediaStreams]);
if (!videoStream) return null;
return (
<View className="flex-row flex-wrap gap-2">
<Badge
variant="gray"
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
text={formatFileSize(source.Size)}
/>
<Badge
variant="gray"
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
text={`${videoStream.Width}x${videoStream.Height}`}
/>
<Badge
variant="gray"
iconLeft={
<Ionicons name="color-palette-outline" size={16} color="white" />
}
text={videoStream.VideoRange}
/>
<Badge
variant="gray"
iconLeft={
<Ionicons name="code-working-outline" size={16} color="white" />
}
text={videoStream.Codec}
/>
<Badge
variant="gray"
iconLeft={
<Ionicons name="speedometer-outline" size={16} color="white" />
}
text={formatBitrate(videoStream.BitRate)}
/>
<Badge
variant="gray"
iconLeft={<Ionicons name="play-outline" size={16} color="white" />}
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
/>
</View>
);
};
const formatFileSize = (bytes?: number | null) => {
if (!bytes) return "N/A";
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 Byte";
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
};
const formatBitrate = (bitrate?: number | null) => {
if (!bitrate) return "N/A";
const sizes = ["bps", "Kbps", "Mbps", "Gbps", "Tbps"];
if (bitrate === 0) return "0 bps";
const i = parseInt(Math.floor(Math.log(bitrate) / Math.log(1000)).toString());
return Math.round((bitrate / Math.pow(1000, i)) * 100) / 100 + " " + sizes[i];
};

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

@@ -32,6 +32,7 @@ import Animated, {
import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import * as Haptics from "expo-haptics";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
@@ -78,6 +79,8 @@ export const PlayButton: React.FC<Props> = ({
const onPress = useCallback(async () => {
if (!item) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",

View File

@@ -1,22 +1,15 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
import { Ionicons } from "@expo/vector-icons";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import React from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { View, ViewProps } from "react-native";
import { RoundButton } from "./RoundButton";
interface Props extends ViewProps {
item: BaseItemDto;
}
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const queryClient = useQueryClient();
const invalidateQueries = () => {
@@ -46,44 +39,16 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
});
};
const markAsPlayedStatus = useMarkAsPlayed(item);
return (
<View
className=" bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
{...props}
>
{item.UserData?.Played ? (
<TouchableOpacity
onPress={async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
await markAsNotPlayed({
api: api,
itemId: item?.Id,
userId: user?.Id,
});
invalidateQueries();
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="checkmark-circle" size={24} color="white" />
</View>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
await markAsPlayed({
api: api,
item: item,
userId: user?.Id,
});
invalidateQueries();
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="checkmark-circle-outline" size={24} color="white" />
</View>
</TouchableOpacity>
)}
<View {...props}>
<RoundButton
fillColor={item.UserData?.Played ? "primary" : undefined}
icon={item.UserData?.Played ? "checkmark" : "checkmark"}
onPress={() => markAsPlayedStatus(item.UserData?.Played || false)}
size="large"
/>
</View>
);
};

View File

@@ -0,0 +1,48 @@
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";
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]);
if (!previousServers.length) return null;
return (
<View>
<ListGroup title="previous servers" className="mt-4">
{previousServers.map((s) => (
<ListItem
key={s.address}
onPress={() => onServerSelect(s)}
title={s.address}
showArrow
/>
))}
<ListItem
onPress={() => {
setPreviousServers("[]");
}}
title={"Clear"}
textColor="red"
/>
</ListGroup>
</View>
);
};

View File

@@ -3,6 +3,10 @@ import { View, ViewProps } from "react-native";
import { Badge } from "./Badge";
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useQuery } from "@tanstack/react-query";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
interface Props extends ViewProps {
item?: BaseItemDto | null;
@@ -17,7 +21,7 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
)}
{item.CommunityRating && (
<Badge
text={item.CommunityRating}
text={item.CommunityRating.toFixed(1)}
variant="gray"
iconLeft={<Ionicons name="star" size={14} color="gold" />}
/>
@@ -28,7 +32,11 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
variant="gray"
iconLeft={
<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={{
width: 14,
height: 14,
@@ -40,3 +48,86 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
</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>
)
);
};

114
components/RoundButton.tsx Normal file
View File

@@ -0,0 +1,114 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { PropsWithChildren } from "react";
import {
Platform,
TouchableOpacity,
TouchableOpacityProps,
} from "react-native";
import * as Haptics from "expo-haptics";
interface Props extends TouchableOpacityProps {
onPress?: () => void;
icon?: keyof typeof Ionicons.glyphMap;
background?: boolean;
size?: "default" | "large";
fillColor?: "primary";
hapticFeedback?: boolean;
}
export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
background = true,
icon,
onPress,
children,
size = "default",
fillColor,
hapticFeedback = true,
...props
}) => {
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
const fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
const handlePress = () => {
if (hapticFeedback) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
onPress?.();
};
if (fillColor)
return (
<TouchableOpacity
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...props}
>
{icon ? (
<Ionicons
name={icon}
size={size === "large" ? 22 : 18}
color={"white"}
/>
) : null}
{children ? children : null}
</TouchableOpacity>
);
if (background === false)
return (
<TouchableOpacity
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...props}
>
{icon ? (
<Ionicons
name={icon}
size={size === "large" ? 22 : 18}
color={"white"}
/>
) : null}
{children ? children : null}
</TouchableOpacity>
);
if (Platform.OS === "android")
return (
<TouchableOpacity
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${
fillColor ? fillColorClass : "bg-neutral-800/80"
}`}
{...props}
>
{icon ? (
<Ionicons
name={icon}
size={size === "large" ? 22 : 18}
color={"white"}
/>
) : null}
{children ? children : null}
</TouchableOpacity>
);
return (
<TouchableOpacity onPress={handlePress} {...props}>
<BlurView
intensity={90}
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...props}
>
{icon ? (
<Ionicons
name={icon}
size={size === "large" ? 22 : 18}
color={"white"}
/>
) : null}
{children ? children : null}
</BlurView>
</TouchableOpacity>
);
};

View File

@@ -1,26 +1,34 @@
import { tc } from "@/utils/textTools";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import { Platform, TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
onChange: (value: number) => void;
selected?: number | undefined;
isTranscoding?: boolean;
}
export const SubtitleTrackSelector: React.FC<Props> = ({
source,
onChange,
selected,
isTranscoding,
...props
}) => {
const subtitleStreams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
[source]
);
const subtitleStreams = useMemo(() => {
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
if (isTranscoding && Platform.OS === "ios") {
return subtitleHelper.getUniqueSubtitles();
}
return subtitleHelper.getSubtitles();
}, [source, isTranscoding]);
const selectedSubtitleSteam = useMemo(
() => subtitleStreams.find((x) => x.Index === selected),

View File

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

View File

@@ -1,24 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<Text
style={
[
{
"color": "#11181C",
},
{
"fontSize": 16,
"lineHeight": 24,
},
undefined,
undefined,
undefined,
undefined,
undefined,
]
}
>
Snapshot test!
</Text>
`;

View File

@@ -0,0 +1,75 @@
import { useJellyseerr } from "@/hooks/useJellyseerr";
import {
hasPermission,
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { useRouter, useSegments } from "expo-router";
import React, { PropsWithChildren, useCallback, useMemo } from "react";
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
interface Props extends TouchableOpacityProps {
result: MovieResult | TvResult;
mediaTitle: string;
releaseYear: number;
canRequest: boolean;
posterSrc: string;
}
export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
result,
mediaTitle,
releaseYear,
canRequest,
posterSrc,
children,
...props
}) => {
const router = useRouter();
const segments = useSegments();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const from = segments[2];
const autoApprove = useMemo(() => {
return (
jellyseerrUser &&
hasPermission(Permission.AUTO_APPROVE, jellyseerrUser.permissions, {
type: "or",
})
);
}, [jellyseerrApi, jellyseerrUser]);
const request = useCallback(
() =>
requestMedia(mediaTitle, {
mediaId: result.id,
mediaType: result.mediaType,
}),
[jellyseerrApi, result]
);
if (from === "(home)" || from === "(search)" || from === "(libraries)")
return (
<>
<TouchableOpacity
onPress={() => {
router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
params: {
...result,
mediaTitle,
releaseYear,
// @ts-expect-error
canRequest,
posterSrc,
},
});
}}
{...props}
>
{children}
</TouchableOpacity>
</>
);
};

View File

@@ -1,5 +1,8 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as Haptics from "expo-haptics";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter, useSegments } from "expo-router";
import { PropsWithChildren } from "react";
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
@@ -8,8 +11,11 @@ interface Props extends TouchableOpacityProps {
item: BaseItemDto;
}
export const itemRouter = (item: BaseItemDto, from: string) => {
if (item.CollectionType === "livetv") {
export const itemRouter = (
item: BaseItemDto | BaseItemPerson,
from: string
) => {
if ("CollectionType" in item && item.CollectionType === "livetv") {
return `/(auth)/(tabs)/${from}/livetv`;
}
@@ -29,7 +35,7 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
}
if (item.Type === "Person") {
if (item.Type === "Person" || item.Type === "Actor") {
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
}
@@ -45,6 +51,10 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
}
if (item.Type === "Playlist") {
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
}
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
};
@@ -58,11 +68,17 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
const from = segments[2];
if (from === "(home)" || from === "(search)" || from === "(libraries)")
const markAsPlayedStatus = useMarkAsPlayed(item);
if (
from === "(home)" ||
from === "(search)" ||
from === "(libraries)" ||
from === "(favorites)"
)
return (
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const url = itemRouter(item, from);
// @ts-ignore
router.push(url);

View File

@@ -1,5 +1,5 @@
import { Text } from "@/components/common/Text";
import { bytesToReadable, useDownload } from "@/providers/DownloadProvider";
import { useDownload } from "@/providers/DownloadProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useEffect, useMemo, useState } from "react";
import { TextProps } from "react-native";
@@ -29,7 +29,7 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
s += size;
}
}
setSize(bytesToReadable(s));
setSize(s.bytesToReadable());
}, [itemIds]);
const sizeText = useMemo(() => {

View File

@@ -91,6 +91,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
<Text className="text-xs text-neutral-500">
{runtimeTicksToSeconds(item.RunTimeTicks)}
</Text>
<DownloadSize items={[item]} />
</View>
</View>

View File

@@ -97,7 +97,9 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
/>
</View>
)}
<ItemCardText item={item} />
<View className="w-28">
<ItemCardText item={item} />
</View>
<DownloadSize items={[item]} />
</TouchableOpacity>
);

View File

@@ -0,0 +1,119 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import { View } from "react-native";
import { ScrollingCollectionList } from "./ScrollingCollectionList";
import { useCallback } from "react";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
export const Favorites = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const fetchFavoritesByType = useCallback(
async (itemType: BaseItemKind) => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id!,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: ["IsFavorite"],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
excludeLocationTypes: ["Virtual"],
enableTotalRecordCount: false,
limit: 20,
includeItemTypes: [itemType],
});
return response.data.Items || [];
},
[api, user]
);
const fetchFavoriteSeries = useCallback(
() => fetchFavoritesByType("Series"),
[fetchFavoritesByType]
);
const fetchFavoriteMovies = useCallback(
() => fetchFavoritesByType("Movie"),
[fetchFavoritesByType]
);
const fetchFavoriteEpisodes = useCallback(
() => fetchFavoritesByType("Episode"),
[fetchFavoritesByType]
);
const fetchFavoriteVideos = useCallback(
() => fetchFavoritesByType("Video"),
[fetchFavoritesByType]
);
const fetchFavoriteBoxsets = useCallback(
() => fetchFavoritesByType("BoxSet"),
[fetchFavoritesByType]
);
const fetchFavoritePlaylists = useCallback(
() => fetchFavoritesByType("Playlist"),
[fetchFavoritesByType]
);
const fetchFavoriteMusicAlbum = useCallback(
() => fetchFavoritesByType("MusicAlbum"),
[fetchFavoritesByType]
);
const fetchFavoriteAudio = useCallback(
() => fetchFavoritesByType("Audio"),
[fetchFavoritesByType]
);
return (
<View className="flex flex-co gap-y-4">
<ScrollingCollectionList
queryFn={fetchFavoriteSeries}
queryKey={["home", "favorites", "series"]}
title="Series"
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteMovies}
queryKey={["home", "favorites", "movies"]}
title="Movies"
hideIfEmpty
orientation="vertical"
/>
<ScrollingCollectionList
queryFn={fetchFavoriteEpisodes}
queryKey={["home", "favorites", "episodes"]}
title="Episodes"
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteVideos}
queryKey={["home", "favorites", "videos"]}
title="Videos"
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteBoxsets}
queryKey={["home", "favorites", "boxsets"]}
title="Boxsets"
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoritePlaylists}
queryKey={["home", "favorites", "playlists"]}
title="Playlists"
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteMusicAlbum}
queryKey={["home", "favorites", "musicAlbums"]}
title="Music Albums"
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteAudio}
queryKey={["home", "favorites", "audio"]}
title="Audio"
hideIfEmpty
/>
</View>
);
};

View File

@@ -84,21 +84,27 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
const width = Dimensions.get("screen").width;
if (settings?.usePopularPlugin === false) return null;
if (l1 || l2) return null;
if (!popularItems) return null;
return (
<View className="flex flex-col items-center" {...props}>
<View className="flex flex-col items-center mt-2" {...props}>
<Carousel
autoPlay={true}
autoPlayInterval={3000}
loop={true}
ref={ref}
autoPlay={false}
loop={true}
snapEnabled={true}
mode="parallax"
modeConfig={{
parallaxScrollingScale: 0.86,
parallaxScrollingOffset: 100,
}}
width={width}
height={204}
data={popularItems}
onProgressChange={progress}
renderItem={({ item, index }) => <RenderItem item={item} />}
renderItem={({ item, index }) => <RenderItem key={index} item={item} />}
/>
<Pagination.Basic
progress={progress}

View File

@@ -18,6 +18,7 @@ interface Props extends ViewProps {
disabled?: boolean;
queryKey: QueryKey;
queryFn: QueryFunction<BaseItemDto[]>;
hideIfEmpty?: boolean;
}
export const ScrollingCollectionList: React.FC<Props> = ({
@@ -26,10 +27,9 @@ export const ScrollingCollectionList: React.FC<Props> = ({
disabled = false,
queryFn,
queryKey,
hideIfEmpty = false,
...props
}) => {
// console.log(queryKey);
const { data, isLoading } = useQuery({
queryKey: queryKey,
queryFn,
@@ -41,8 +41,10 @@ export const ScrollingCollectionList: React.FC<Props> = ({
if (disabled || !title) return null;
if (hideIfEmpty === true && data?.length === 0) return null;
return (
<View {...props} className="">
<View {...props}>
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
{title}
</Text>
@@ -82,15 +84,13 @@ export const ScrollingCollectionList: React.FC<Props> = ({
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{data?.map((item, index) => (
{data?.map((item) => (
<TouchableItemRouter
item={item}
key={index}
className={`
mr-2
${orientation === "horizontal" ? "w-44" : "w-28"}
`}
key={item.Id}
className={`mr-2
${orientation === "horizontal" ? "w-44" : "w-28"}
`}
>
{item.Type === "Episode" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />

View File

@@ -0,0 +1,72 @@
import {useEffect, useState} from "react";
import {MediaStatus} from "@/utils/jellyseerr/server/constants/media";
import {MaterialCommunityIcons} from "@expo/vector-icons";
import {TouchableOpacity, View, ViewProps} from "react-native";
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
interface Props {
mediaStatus?: MediaStatus;
showRequestIcon: boolean;
onPress?: () => void;
}
const JellyseerrIconStatus: React.FC<Props & ViewProps> = ({
mediaStatus,
showRequestIcon,
onPress,
...props
}) => {
const [badgeIcon, setBadgeIcon] = useState<keyof typeof MaterialCommunityIcons.glyphMap>();
const [badgeStyle, setBadgeStyle] = useState<string>();
// Match similar to what Jellyseerr is currently using
// https://github.com/Fallenbagel/jellyseerr/blob/8a097d5195749c8d1dca9b473b8afa96a50e2fe2/src/components/Common/StatusBadgeMini/index.tsx#L33C1-L62C4
useEffect(() => {
switch (mediaStatus) {
case MediaStatus.PROCESSING:
setBadgeStyle('bg-indigo-500 border-indigo-400 ring-indigo-400 text-indigo-100');
setBadgeIcon('clock');
break;
case MediaStatus.AVAILABLE:
setBadgeStyle('bg-purple-500 border-green-400 ring-green-400 text-green-100');
setBadgeIcon('check')
break;
case MediaStatus.PENDING:
setBadgeStyle('bg-yellow-500 border-yellow-400 ring-yellow-400 text-yellow-100');
setBadgeIcon('bell')
break;
case MediaStatus.BLACKLISTED:
setBadgeStyle('bg-red-500 border-white-400 ring-white-400 text-white');
setBadgeIcon('eye-off')
break;
case MediaStatus.PARTIALLY_AVAILABLE:
setBadgeStyle('bg-green-500 border-green-400 ring-green-400 text-green-100');
setBadgeIcon("minus");
break;
default:
if (showRequestIcon) {
setBadgeStyle('bg-green-600');
setBadgeIcon("plus")
}
break;
}
}, [mediaStatus, showRequestIcon, setBadgeStyle, setBadgeIcon])
return (
badgeIcon &&
<TouchableOpacity onPress={onPress} disabled={onPress == undefined}>
<View
className={`${badgeStyle ?? 'bg-purple-600'} rounded-full h-6 w-6 flex items-center justify-center ${props.className}`}
{...props}
>
<MaterialCommunityIcons
name={badgeIcon}
size={18}
color="white"
/>
</View>
</TouchableOpacity>
)
}
export default JellyseerrIconStatus;

View File

@@ -0,0 +1,103 @@
import React, { useMemo } from "react";
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import {
DiscoverEndpoint,
Endpoints,
useJellyseerr,
} from "@/hooks/useJellyseerr";
import { useInfiniteQuery } from "@tanstack/react-query";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Text } from "@/components/common/Text";
import { FlashList } from "@shopify/flash-list";
import { View } from "react-native";
interface Props {
slide: DiscoverSlider;
}
const DiscoverSlide: React.FC<Props> = ({ slide }) => {
const { jellyseerrApi } = useJellyseerr();
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "discover", slide.id],
queryFn: async ({ pageParam }) => {
let endpoint: DiscoverEndpoint | undefined = undefined;
let params: any = {
page: Number(pageParam),
};
switch (slide.type) {
case DiscoverSliderType.TRENDING:
endpoint = Endpoints.DISCOVER_TRENDING;
break;
case DiscoverSliderType.POPULAR_MOVIES:
case DiscoverSliderType.UPCOMING_MOVIES:
endpoint = Endpoints.DISCOVER_MOVIES;
if (slide.type === DiscoverSliderType.UPCOMING_MOVIES)
params = {
...params,
primaryReleaseDateGte: new Date().toISOString().split("T")[0],
};
break;
case DiscoverSliderType.POPULAR_TV:
case DiscoverSliderType.UPCOMING_TV:
endpoint = Endpoints.DISCOVER_TV;
if (slide.type === DiscoverSliderType.UPCOMING_TV)
params = {
...params,
firstAirDateGte: new Date().toISOString().split("T")[0],
};
break;
}
return endpoint ? jellyseerrApi?.discover(endpoint, params) : null;
},
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
1,
enabled: !!jellyseerrApi,
staleTime: 0,
});
const flatData = useMemo(
() =>
data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
[data]
);
return (
flatData &&
flatData?.length > 0 && (
<View className="mb-4">
<Text className="font-bold text-lg mb-2 px-4">
{DiscoverSliderType[slide.type].toString().toTitle()}
</Text>
<FlashList
horizontal
contentContainerStyle={{
paddingLeft: 16,
}}
showsHorizontalScrollIndicator={false}
keyExtractor={(item) => item!!.id.toString()}
estimatedItemSize={250}
data={flatData}
onEndReachedThreshold={1}
onEndReached={() => {
if (hasNextPage) fetchNextPage();
}}
renderItem={({ item }) =>
item ? (
<JellyseerrPoster item={item as MovieResult | TvResult} />
) : (
<></>
)
}
/>
</View>
)
);
};
export default DiscoverSlide;

View File

@@ -5,6 +5,7 @@ import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { Ionicons } from "@expo/vector-icons";
import {
BaseItemDto,
BaseItemKind,
CollectionType,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
@@ -50,18 +51,52 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
[library]
);
const itemType = useMemo(() => {
let _itemType: BaseItemKind | undefined;
if (library.CollectionType === "movies") {
_itemType = "Movie";
} else if (library.CollectionType === "tvshows") {
_itemType = "Series";
} else if (library.CollectionType === "boxsets") {
_itemType = "BoxSet";
} else if (library.CollectionType === "music") {
_itemType = "MusicAlbum";
}
return _itemType;
}, [library.CollectionType]);
const itemTypeName = useMemo(() => {
let nameStr: string;
if (library.CollectionType === "movies") {
nameStr = "movies";
} else if (library.CollectionType === "tvshows") {
nameStr = "series";
} else if (library.CollectionType === "boxsets") {
nameStr = "box sets";
} else if (library.CollectionType === "music") {
nameStr = "albums";
} else {
nameStr = "items";
}
return nameStr;
}, [library.CollectionType]);
const { data: itemsCount } = useQuery({
queryKey: ["library-count", library.Id],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: library.Id,
recursive: true,
limit: 0,
includeItemTypes: itemType ? [itemType] : undefined,
});
return response.data.TotalRecordCount;
},
staleTime: 1000 * 60 * 60,
});
if (!url) return null;
@@ -80,7 +115,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
</Text>
{settings?.libraryOptions?.showStats && (
<Text className="font-bold text-xs text-neutral-500 text-start ml-auto">
{itemsCount} items
{itemsCount} {itemTypeName}
</Text>
)}
</View>
@@ -109,6 +144,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
width: "100%",
height: "100%",
}}
cachePolicy={"memory-disk"}
/>
<View
style={{
@@ -128,7 +164,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
)}
{settings?.libraryOptions?.showStats && (
<Text className="font-bold text-xs text-start px-4">
{itemsCount} items
{itemsCount} {itemTypeName}
</Text>
)}
</View>
@@ -145,7 +181,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
</Text>
{settings?.libraryOptions?.showStats && (
<Text className="font-bold text-xs text-neutral-500 text-start px-4">
{itemsCount} items
{itemsCount} {itemTypeName}
</Text>
)}
</View>

View File

@@ -0,0 +1,58 @@
import {
PropsWithChildren,
Children,
isValidElement,
cloneElement,
ReactElement,
} from "react";
import { StyleSheet, View, ViewProps, ViewStyle } from "react-native";
import { ListItem } from "./ListItem";
import { Text } from "../common/Text";
interface Props extends ViewProps {
title?: string | null | undefined;
description?: ReactElement;
}
export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
title,
children,
description,
...props
}) => {
const childrenArray = Children.toArray(children);
return (
<View {...props}>
<Text className="ml-4 mb-1 uppercase text-[#8E8D91] text-xs">
{title}
</Text>
<View
style={[]}
className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900"
>
{Children.map(childrenArray, (child, index) => {
if (isValidElement<{ style?: ViewStyle }>(child)) {
return cloneElement(child as any, {
style: StyleSheet.compose(
child.props.style,
index < childrenArray.length - 1
? styles.borderBottom
: undefined
),
});
}
return child;
})}
</View>
{description && <View className="pl-4 mt-1">{description}</View>}
</View>
);
};
const styles = StyleSheet.create({
borderBottom: {
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#3D3C40",
},
});

View File

@@ -0,0 +1,124 @@
import { PropsWithChildren, ReactNode } from "react";
import {
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { Text } from "../common/Text";
import { Ionicons } from "@expo/vector-icons";
interface Props extends TouchableOpacityProps, ViewProps {
title?: string | null | undefined;
value?: string | null | undefined;
children?: ReactNode;
iconAfter?: ReactNode;
icon?: keyof typeof Ionicons.glyphMap;
showArrow?: boolean;
textColor?: "default" | "blue" | "red";
onPress?: () => void;
}
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
title,
value,
iconAfter,
children,
showArrow = false,
icon,
textColor = "default",
onPress,
disabled = false,
...props
}) => {
if (onPress)
return (
<TouchableOpacity
disabled={disabled}
onPress={onPress}
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 ${
disabled ? "opacity-50" : ""
}`}
{...props}
>
<ListItemContent
title={title}
value={value}
icon={icon}
textColor={textColor}
showArrow={showArrow}
iconAfter={iconAfter}
>
{children}
</ListItemContent>
</TouchableOpacity>
);
return (
<View
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 ${
disabled ? "opacity-50" : ""
}`}
{...props}
>
<ListItemContent
title={title}
value={value}
icon={icon}
textColor={textColor}
showArrow={showArrow}
iconAfter={iconAfter}
>
{children}
</ListItemContent>
</View>
);
};
const ListItemContent = ({
title,
textColor,
icon,
value,
showArrow,
iconAfter,
children,
...props
}: Props) => {
return (
<>
<View className="flex flex-row items-center w-full">
{icon && (
<View className="border border-neutral-800 rounded-md h-8 w-8 flex items-center justify-center mr-2">
<Ionicons name="person-circle-outline" size={18} color="white" />
</View>
)}
<Text
className={
textColor === "blue"
? "text-[#0584FE]"
: textColor === "red"
? "text-red-600"
: "text-white"
}
numberOfLines={1}
>
{title}
</Text>
{value && (
<View className="ml-auto items-end">
<Text selectable className=" text-[#9899A1]" numberOfLines={1}>
{value}
</Text>
</View>
)}
{children && <View className="ml-auto">{children}</View>}
{showArrow && (
<View className={children ? "ml-1" : "ml-auto"}>
<Ionicons name="chevron-forward" size={18} color="#5A5960" />
</View>
)}
</View>
{iconAfter}
</>
);
};

View File

@@ -61,7 +61,7 @@ export const MediaListSection: React.FC<Props> = ({
return (
<View {...props}>
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
{collection.Name}
</Text>
<InfiniteHorizontalScroll

View File

@@ -0,0 +1,92 @@
import {View, ViewProps} from "react-native";
import {Image} from "expo-image";
import {MaterialCommunityIcons} from "@expo/vector-icons";
import {Text} from "@/components/common/Text";
import {useEffect, useMemo, useState} from "react";
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {MediaStatus, MediaType} from "@/utils/jellyseerr/server/constants/media";
import {useJellyseerr} from "@/hooks/useJellyseerr";
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter";
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
interface Props extends ViewProps {
item: MovieResult | TvResult;
}
const JellyseerrPoster: React.FC<Props> = ({
item,
...props
}) => {
const {jellyseerrUser, jellyseerrApi} = useJellyseerr();
// const imageSource =
const imageSrc = useMemo(() =>
item.posterPath ?
`https://image.tmdb.org/t/p/w300_and_h450_face${item.posterPath}`
: jellyseerrApi?.axios?.defaults.baseURL + `/images/overseerr_poster_not_found_logo_top.png`,
[item, jellyseerrApi]
)
const title = useMemo(() => item.mediaType === MediaType.MOVIE ? item.title : item.name, [item])
const releaseYear = useMemo(() =>
new Date(item.mediaType === MediaType.MOVIE ? item.releaseDate : item.firstAirDate).getFullYear(),
[item]
)
const showRequestButton = useMemo(() =>
jellyseerrUser && hasPermission(
[
Permission.REQUEST,
item.mediaType === 'movie'
? Permission.REQUEST_MOVIE
: Permission.REQUEST_TV,
],
jellyseerrUser.permissions,
{type: 'or'}
),
[item, jellyseerrUser]
)
const canRequest = useMemo(() => {
const status = item?.mediaInfo?.status
return showRequestButton && !status || status === MediaStatus.UNKNOWN
}, [item])
return (
<TouchableJellyseerrRouter
result={item}
mediaTitle={title}
releaseYear={releaseYear}
canRequest={canRequest}
posterSrc={imageSrc}
>
<View className="flex flex-col w-28 mr-2">
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]">
<Image
key={item.id}
id={item.id.toString()}
source={{uri: imageSrc}}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "10/15",
width: "100%",
}}
/>
<JellyseerrIconStatus
className="absolute bottom-1 right-1"
showRequestIcon={canRequest}
mediaStatus={item?.mediaInfo?.status}
/>
</View>
<View className="mt-2 flex flex-col">
<Text numberOfLines={2}>{title}</Text>
<Text className="text-xs opacity-50">{releaseYear}</Text>
</View>
</View>
</TouchableJellyseerrRouter>
)
}
export default JellyseerrPoster;

View File

@@ -4,13 +4,14 @@ import {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { router, useSegments } from "expo-router";
import { useAtom } from "jotai";
import React, { useMemo } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import Poster from "../posters/Poster";
import { itemRouter } from "../common/TouchableItemRouter";
interface Props extends ViewProps {
item?: BaseItemDto | null;
@@ -19,6 +20,8 @@ interface Props extends ViewProps {
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom);
const segments = useSegments();
const from = segments[2];
const destinctPeople = useMemo(() => {
const people: BaseItemPerson[] = [];
@@ -33,6 +36,8 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
return people;
}, [item?.People]);
if (!from) return null;
return (
<View {...props} className="flex flex-col">
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
@@ -44,7 +49,9 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
renderItem={(i) => (
<TouchableOpacity
onPress={() => {
router.push(`/actors/${i.Id}`);
const url = itemRouter(i, from);
// @ts-ignore
router.push(url);
}}
className="flex flex-col w-28"
>

View File

@@ -0,0 +1,277 @@
import { Text } from "@/components/common/Text";
import React, { useCallback, useMemo, useState } from "react";
import { Alert, TouchableOpacity, View } from "react-native";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { FlashList } from "@shopify/flash-list";
import { orderBy } from "lodash";
import { Tags } from "@/components/GenreTags";
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
import Season from "@/utils/jellyseerr/server/entity/Season";
import {
MediaStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import { Ionicons } from "@expo/vector-icons";
import { RoundButton } from "@/components/RoundButton";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { TvResult } from "@/utils/jellyseerr/server/models/Search";
import {QueryObserverResult, RefetchOptions, useQuery} from "@tanstack/react-query";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Image } from "expo-image";
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import { Loader } from "../Loader";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
const JellyseerrSeasonEpisodes: React.FC<{
details: TvDetails;
seasonNumber: number;
}> = ({ details, seasonNumber }) => {
const { jellyseerrApi } = useJellyseerr();
const { data: seasonWithEpisodes, isLoading } = useQuery({
queryKey: ["jellyseerr", details.id, "season", seasonNumber],
queryFn: async () => jellyseerrApi?.tvSeason(details.id, seasonNumber),
enabled: details.seasons.filter((s) => s.seasonNumber !== 0).length > 0,
});
return (
<HorizontalScroll
horizontal
loading={isLoading}
showsHorizontalScrollIndicator={false}
estimatedItemSize={50}
data={seasonWithEpisodes?.episodes}
keyExtractor={(item) => item.id}
renderItem={(item, index) => (
<RenderItem key={index} item={item} index={index} />
)}
/>
);
};
const RenderItem = ({ item, index }: any) => {
const { jellyseerrApi } = useJellyseerr();
const [imageError, setImageError] = useState(false);
return (
<View className="flex flex-col w-44 mt-2">
<View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
{!imageError ? (
<Image
key={item.id}
id={item.id}
source={{
uri: jellyseerrApi?.tvStillImageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
onError={(e) => {
setImageError(true);
}}
/>
) : (
<View className="flex flex-col w-full h-full 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="shrink mt-1">
<Text numberOfLines={2} className="">
{item.name}
</Text>
<Text numberOfLines={1} className="text-xs text-neutral-500">
{`S${item.seasonNumber}:E${item.episodeNumber}`}
</Text>
</View>
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
{item.overview}
</Text>
</View>
);
};
const JellyseerrSeasons: React.FC<{
isLoading: boolean;
result?: TvResult;
details?: TvDetails;
refetch: (options?: (RefetchOptions | undefined)) => Promise<QueryObserverResult<TvDetails | MovieDetails | undefined, Error>>;
}> = ({ isLoading, result, details, refetch }) => {
if (!details) return null;
const { jellyseerrApi, requestMedia } = useJellyseerr();
const [seasonStates, setSeasonStates] = useState<{
[key: number]: boolean;
}>();
const seasons = useMemo(() => {
const mediaInfoSeasons = details?.mediaInfo?.seasons?.filter(
(s: Season) => s.seasonNumber !== 0
);
const requestedSeasons = details?.mediaInfo?.requests?.flatMap(
(r: MediaRequest) => r.seasons
);
return details.seasons?.map((season) => {
return {
...season,
status:
// What our library status is
mediaInfoSeasons?.find(
(mediaSeason: Season) =>
mediaSeason.seasonNumber === season.seasonNumber
)?.status ??
// What our request status is
requestedSeasons?.find(
(s: Season) => s.seasonNumber === season.seasonNumber
)?.status ??
// Otherwise set it as unknown
MediaStatus.UNKNOWN,
};
});
}, [details]);
const allSeasonsAvailable = useMemo(
() => seasons?.every((season) => season.status === MediaStatus.AVAILABLE),
[seasons]
);
const requestAll = useCallback(() => {
if (details && jellyseerrApi) {
requestMedia(result?.name!!, {
mediaId: details.id,
mediaType: MediaType.TV,
tvdbId: details.externalIds?.tvdbId,
seasons: seasons
.filter(
(s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0
)
.map((s) => s.seasonNumber),
});
}
}, [jellyseerrApi, seasons, details]);
const promptRequestAll = useCallback(
() =>
Alert.alert("Confirm", "Are you sure you want to request all seasons?", [
{
text: "Cancel",
style: "cancel",
},
{
text: "Yes",
onPress: requestAll,
},
]),
[requestAll]
);
const requestSeason = useCallback(async (canRequest: Boolean, seasonNumber: number) => {
if (canRequest) {
requestMedia(
`${result?.name!!}, Season ${seasonNumber}`,
{
mediaId: details.id,
mediaType: MediaType.TV,
tvdbId: details.externalIds?.tvdbId,
seasons: [seasonNumber],
},
refetch
)
}
}, [requestMedia]);
if (isLoading)
return (
<View>
<View className="flex flex-row justify-between items-end px-4">
<Text className="text-lg font-bold mb-2">Seasons</Text>
{!allSeasonsAvailable && (
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
<Ionicons name="bag-add" color="white" size={26} />
</RoundButton>
)}
</View>
<Loader />
</View>
);
return (
<FlashList
data={orderBy(
details.seasons.filter((s) => s.seasonNumber !== 0),
"seasonNumber",
"desc"
)}
ListHeaderComponent={() => (
<View className="flex flex-row justify-between items-end px-4">
<Text className="text-lg font-bold mb-2">Seasons</Text>
{!allSeasonsAvailable && (
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
<Ionicons name="bag-add" color="white" size={26} />
</RoundButton>
)}
</View>
)}
ItemSeparatorComponent={() => <View className="h-2" />}
estimatedItemSize={250}
renderItem={({ item: season }) => (
<>
<TouchableOpacity
onPress={() =>
setSeasonStates((prevState) => ({
...prevState,
[season.seasonNumber]: !prevState?.[season.seasonNumber],
}))
}
className="px-4"
>
<View
className="flex flex-row justify-between items-center bg-gray-100/10 rounded-xl z-20 h-12 w-full px-4"
key={season.id}
>
<Tags
textClass=""
tags={[
`Season ${season.seasonNumber}`,
`${season.episodeCount} Episodes`,
]}
/>
{[0].map(() => {
const canRequest =
seasons?.find((s) => s.seasonNumber === season.seasonNumber)
?.status === MediaStatus.UNKNOWN;
return (
<JellyseerrIconStatus
key={0}
onPress={() => requestSeason(canRequest, season.seasonNumber)}
className={canRequest ? "bg-gray-700/40" : undefined}
mediaStatus={
seasons?.find(
(s) => s.seasonNumber === season.seasonNumber
)?.status
}
showRequestIcon={canRequest}
/>
);
})}
</View>
</TouchableOpacity>
{seasonStates?.[season.seasonNumber] && (
<JellyseerrSeasonEpisodes
key={season.seasonNumber}
details={details}
seasonNumber={season.seasonNumber}
/>
)}
</>
)}
/>
);
};
export default JellyseerrSeasons;

View File

@@ -11,6 +11,7 @@ import { Text } from "../common/Text";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { FlashList } from "@shopify/flash-list";
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
const [user] = useAtom(userAtom);
@@ -43,10 +44,14 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
return (
<View>
<Text className="text-lg font-bold mb-2 px-4">Next up</Text>
<HorizontalScroll
<Text className="text-lg font-bold px-4 mb-2">Next up</Text>
<FlashList
contentContainerStyle={{ paddingLeft: 16 }}
horizontal
estimatedItemSize={172}
showsHorizontalScrollIndicator={false}
data={items}
renderItem={(item, index) => (
renderItem={({ item, index }) => (
<TouchableItemRouter
item={item}
key={index}

View File

@@ -19,7 +19,7 @@ type SeasonKeys = {
};
export type SeasonIndexState = {
[seriesId: string]: number | null | undefined;
[seriesId: string]: number | string | null | undefined;
};
export const SeasonDropdown: React.FC<Props> = ({

View File

@@ -30,7 +30,10 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
const [user] = useAtom(userAtom);
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const seasonIndex = seasonIndexState[item.Id ?? ""];
const seasonIndex = useMemo(
() => seasonIndexState[item.Id ?? ""],
[item, seasonIndexState]
);
const { data: seasons } = useQuery({
queryKey: ["seasons", item.Id],
@@ -53,19 +56,28 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
return response.data.Items;
},
staleTime: 60,
enabled: !!api && !!user?.Id && !!item.Id,
});
const selectedSeasonId: string | null = useMemo(
() =>
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
[seasons, seasonIndex]
);
const selectedSeasonId: string | null = useMemo(() => {
const season: BaseItemDto = seasons?.find(
(s: BaseItemDto) =>
s.IndexNumber === seasonIndex || s.Name === seasonIndex
);
if (!season?.Id) return null;
return season.Id!;
}, [seasons, seasonIndex]);
const { data: episodes, isFetching } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId],
queryFn: async () => {
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
return [];
}
const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.Id,
userId: user.Id,
@@ -74,6 +86,12 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
fields: ["MediaSources", "MediaStreams", "Overview"],
});
if (res.data.TotalRecordCount === 0)
console.warn(
"No episodes found for season with ID ~",
selectedSeasonId
);
return res.data.Items;
},
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
@@ -118,32 +136,28 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
seasons={seasons}
state={seasonIndexState}
onSelect={(season) => {
if (!item.Id) return;
setSeasonIndexState((prev) => ({
...prev,
[item.Id ?? ""]: season.IndexNumber,
[item.Id!]: season.IndexNumber ?? season.Name,
}));
}}
/>
<DownloadItems
className="ml-2"
items={episodes || []}
MissingDownloadIconComponent={() => (
<MaterialCommunityIcons
name="download-multiple"
size={20}
color="white"
/>
)}
DownloadedIconComponent={() => (
<MaterialCommunityIcons
name="check-all"
size={20}
color="#9333ea"
/>
)}
/>
{episodes?.length || 0 > 0 ? (
<DownloadItems
title="Download Season"
className="ml-2"
items={episodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name="download" size={20} color="white" />
)}
DownloadedIconComponent={() => (
<Ionicons name="download" size={20} color="#9333ea" />
)}
/>
) : null}
</View>
<View className="px-4 flex flex-col my-4">
<View className="px-4 flex flex-col mt-4">
{isFetching ? (
<View
style={{
@@ -193,6 +207,13 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
</TouchableItemRouter>
))
)}
{(episodes?.length || 0) === 0 ? (
<View className="flex flex-col">
<Text className="text-neutral-500">
No episodes for this season
</Text>
</View>
) : null}
</View>
</View>
);

View File

@@ -0,0 +1,32 @@
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useCallback, useMemo } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
interface Props extends ViewProps {
item: BaseItemDto;
}
export const ItemActions = ({ item, ...props }: Props) => {
const router = useRouter();
const trailerLink = useMemo(() => item.RemoteTrailers?.[0]?.Url, [item]);
const openTrailer = useCallback(async () => {
if (!trailerLink) return;
const encodedTrailerLink = encodeURIComponent(trailerLink);
router.push(`/trailer/page?url=${encodedTrailerLink}`);
}, [router, trailerLink]);
return (
<View className="" {...props}>
{trailerLink && (
<TouchableOpacity onPress={openTrailer}>
<Ionicons name="film-outline" size={24} color="white" />
</TouchableOpacity>
)}
</View>
);
};

View File

@@ -0,0 +1,64 @@
import { View } from "react-native";
import { Text } from "../common/Text";
import { Ratings } from "../Ratings";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useMemo } from "react";
import { ItemActions } from "./SeriesActions";
interface Props {
item: BaseItemDto;
}
export const SeriesHeader = ({ item }: Props) => {
const startYear = useMemo(() => {
if (item?.StartDate) {
return new Date(item.StartDate)
.toLocaleDateString("sv-SE", {
calendar: "gregory",
year: "numeric",
})
.toString()
.trim();
}
return item.ProductionYear?.toString().trim();
}, [item]);
const endYear = useMemo(() => {
if (item.EndDate) {
return new Date(item.EndDate)
.toLocaleDateString("sv-SE", {
calendar: "gregory",
year: "numeric",
})
.toString()
.trim();
}
return "";
}, [item]);
const yearString = useMemo(() => {
if (startYear && endYear) {
if (startYear === endYear) return startYear;
return `${startYear} - ${endYear}`;
}
if (startYear) {
return startYear;
}
if (endYear) {
return endYear;
}
return "";
}, [startYear, endYear]);
return (
<View className="px-4 py-4">
<Text className="text-3xl font-bold">{item?.Name}</Text>
<Text className="">{yearString}</Text>
<View className="flex flex-row items-center justify-between">
<Ratings item={item} className="mb-2" />
<ItemActions item={item} />
</View>
<Text className="">{item?.Overview}</Text>
</View>
);
};

View File

@@ -0,0 +1,91 @@
import { TouchableOpacity, View, ViewProps } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { useMedia } from "./MediaContext";
import { Switch } from "react-native-gesture-handler";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { Ionicons } from "@expo/vector-icons";
interface Props extends ViewProps {}
export const AudioToggles: React.FC<Props> = ({ ...props }) => {
const media = useMedia();
const { settings, updateSettings } = media;
const cultures = media.cultures;
if (!settings) return null;
return (
<View {...props}>
<ListGroup
title={"Audio"}
description={
<Text className="text-[#8E8D91] text-xs">
Choose a default audio language.
</Text>
}
>
<ListItem title={"Set Audio Track From Previous Item"}>
<Switch
value={settings.rememberAudioSelections}
onValueChange={(value) =>
updateSettings({ rememberAudioSelections: value })
}
/>
</ListItem>
<ListItem title="Audio language">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3 ">
<Text className="mr-1 text-[#8E8D91]">
{settings?.defaultAudioLanguage?.DisplayName || "None"}
</Text>
<Ionicons
name="chevron-expand-sharp"
size={18}
color="#5A5960"
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Languages</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-audio"}
onSelect={() => {
updateSettings({
defaultAudioLanguage: null,
});
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{cultures?.map((l) => (
<DropdownMenu.Item
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
onSelect={() => {
updateSettings({
defaultAudioLanguage: l,
});
}}
>
<DropdownMenu.ItemTitle>
{l.DisplayName}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem>
</ListGroup>
</View>
);
};

View File

@@ -0,0 +1,111 @@
import { Stepper } from "@/components/inputs/Stepper";
import { useDownload } from "@/providers/DownloadProvider";
import { Settings, useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import React from "react";
import { Switch, TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const DownloadSettings: React.FC = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
const { setProcesses } = useDownload();
const router = useRouter();
const queryClient = useQueryClient();
if (!settings) return null;
return (
<View {...props} className="mb-4">
<ListGroup title="Downloads">
<ListItem title="Download method">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{settings.downloadMethod === "remux"
? "Default"
: "Optimized"}
</Text>
<Ionicons
name="chevron-expand-sharp"
size={18}
color="#5A5960"
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Methods</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ downloadMethod: "remux" });
setProcesses([]);
}}
>
<DropdownMenu.ItemTitle>Default</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ downloadMethod: "optimized" });
setProcesses([]);
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Optimized</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem>
<ListItem
title="Remux max download"
disabled={settings.downloadMethod !== "remux"}
>
<Stepper
value={settings.remuxConcurrentLimit}
step={1}
min={1}
max={4}
onUpdate={(value) =>
updateSettings({
remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"],
})
}
/>
</ListItem>
<ListItem
title="Auto download"
disabled={settings.downloadMethod !== "optimized"}
>
<Switch
disabled={settings.downloadMethod !== "optimized"}
value={settings.autoDownload}
onValueChange={(value) => updateSettings({ autoDownload: value })}
/>
</ListItem>
<ListItem
disabled={settings.downloadMethod !== "optimized"}
onPress={() => router.push("/settings/optimized-server/page")}
showArrow
title="Optimized Versions Server"
></ListItem>
</ListGroup>
</View>
);
};

View File

@@ -0,0 +1,210 @@
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { View } from "react-native";
import { Text } from "../common/Text";
import { useCallback, useRef, useState } from "react";
import { Input } from "../common/Input";
import { ListItem } from "../list/ListItem";
import { Loader } from "../Loader";
import { useSettings } from "@/utils/atoms/settings";
import { Button } from "../Button";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useAtom } from "jotai";
import { toast } from "sonner-native";
import { useMutation } from "@tanstack/react-query";
import { ListGroup } from "../list/ListGroup";
export const JellyseerrSettings = () => {
const {
jellyseerrApi,
jellyseerrUser,
setJellyseerrUser,
clearAllJellyseerData,
} = useJellyseerr();
const [user] = useAtom(userAtom);
const [settings, updateSettings] = useSettings();
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
useState<boolean>(false);
const [jellyseerrPassword, setJellyseerrPassword] = useState<
string | undefined
>(undefined);
const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState<
string | undefined
>(settings?.jellyseerrServerUrl || undefined);
const loginToJellyseerrMutation = useMutation({
mutationFn: async () => {
if (!jellyseerrServerUrl || !user?.Name || !jellyseerrPassword) {
throw new Error("Missing required information for login");
}
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
return jellyseerrTempApi.login(user.Name, jellyseerrPassword);
},
onSuccess: (user) => {
setJellyseerrUser(user);
updateSettings({ jellyseerrServerUrl });
},
onError: () => {
toast.error("Failed to login");
},
onSettled: () => {
setJellyseerrPassword(undefined);
},
});
const testJellyseerrServerUrlMutation = useMutation({
mutationFn: async () => {
if (!jellyseerrServerUrl || jellyseerrApi) return null;
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
return jellyseerrTempApi.test();
},
onSuccess: (result) => {
if (result && result.isValid) {
if (result.requiresPass) {
setPromptForJellyseerrPass(true);
} else {
updateSettings({ jellyseerrServerUrl });
}
} else {
setPromptForJellyseerrPass(false);
setjellyseerrServerUrl(undefined);
clearAllJellyseerData();
}
},
});
const clearData = () => {
clearAllJellyseerData().finally(() => {
setjellyseerrServerUrl(undefined);
setPromptForJellyseerrPass(false);
});
};
return (
<View className="">
<View>
{jellyseerrUser ? (
<>
<ListGroup title={"Jellyseerr"}>
<ListItem
title="Total media requests"
value={jellyseerrUser?.requestCount?.toString()}
/>
<ListItem
title="Movie quota limit"
value={
jellyseerrUser?.movieQuotaLimit?.toString() ?? "Unlimited"
}
/>
<ListItem
title="Movie quota days"
value={
jellyseerrUser?.movieQuotaDays?.toString() ?? "Unlimited"
}
/>
<ListItem
title="TV quota limit"
value={jellyseerrUser?.tvQuotaLimit?.toString() ?? "Unlimited"}
/>
<ListItem
title="TV quota days"
value={jellyseerrUser?.tvQuotaDays?.toString() ?? "Unlimited"}
/>
</ListGroup>
<View className="p-4">
<Button color="red" onPress={clearData}>
Reset Jellyseerr config
</Button>
</View>
</>
) : (
<View className="flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900">
<Text className="text-xs text-red-600 mb-2">
This integration is in its early stages. Expect things to change.
</Text>
<Text className="font-bold mb-1">Server URL</Text>
<View className="flex flex-col shrink mb-2">
<Text className="text-xs text-gray-600">
Example: http(s)://your-host.url
</Text>
<Text className="text-xs text-gray-600">
(add port if required)
</Text>
</View>
<Input
placeholder="Jellyseerr URL..."
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl}
defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={setjellyseerrServerUrl}
editable={!testJellyseerrServerUrlMutation.isPending}
/>
<Button
loading={testJellyseerrServerUrlMutation.isPending}
disabled={testJellyseerrServerUrlMutation.isPending}
color={promptForJellyseerrPass ? "red" : "purple"}
className="h-12 mt-2"
onPress={() => {
if (promptForJellyseerrPass) {
clearData();
return;
}
testJellyseerrServerUrlMutation.mutate();
}}
style={{
marginBottom: 8,
}}
>
{promptForJellyseerrPass ? "Clear" : "Save"}
</Button>
<View
pointerEvents={promptForJellyseerrPass ? "auto" : "none"}
style={{
opacity: promptForJellyseerrPass ? 1 : 0.5,
}}
>
<Text className="font-bold mb-2">Password</Text>
<Input
autoFocus={true}
focusable={true}
placeholder={`Enter password for Jellyfin user ${user?.Name}`}
value={jellyseerrPassword}
keyboardType="default"
secureTextEntry={true}
returnKeyType="done"
autoCapitalize="none"
textContentType="password"
onChangeText={setJellyseerrPassword}
editable={
!loginToJellyseerrMutation.isPending &&
promptForJellyseerrPass
}
/>
<Button
loading={loginToJellyseerrMutation.isPending}
disabled={loginToJellyseerrMutation.isPending}
color="purple"
className="h-12 mt-2"
onPress={() => loginToJellyseerrMutation.mutate()}
>
Login
</Button>
</View>
</View>
)}
</View>
</View>
);
};

View File

@@ -0,0 +1,150 @@
import { Settings, useSettings } from "@/utils/atoms/settings";
import { useAtomValue } from "jotai";
import React, {
createContext,
useContext,
ReactNode,
useEffect,
useState,
} from "react";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getLocalizationApi, getUserApi } from "@jellyfin/sdk/lib/utils/api";
import {
CultureDto,
UserDto,
UserConfiguration,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery, useQueryClient } from "@tanstack/react-query";
interface MediaContextType {
settings: Settings | null;
updateSettings: (update: Partial<Settings>) => void;
user: UserDto | undefined;
cultures: CultureDto[];
}
const MediaContext = createContext<MediaContextType | undefined>(undefined);
export const useMedia = () => {
const context = useContext(MediaContext);
if (!context) {
throw new Error("useMedia must be used within a MediaProvider");
}
return context;
};
export const MediaProvider = ({ children }: { children: ReactNode }) => {
const [settings, updateSettings] = useSettings();
const api = useAtomValue(apiAtom);
const queryClient = useQueryClient();
const updateSetingsWrapper = (update: Partial<Settings>) => {
const updateUserConfiguration = async (
update: Partial<UserConfiguration>
) => {
if (api && user) {
try {
await getUserApi(api).updateUserConfiguration({
userConfiguration: {
...user.Configuration,
...update,
},
});
queryClient.invalidateQueries({ queryKey: ["authUser"] });
} catch (error) {}
}
};
updateSettings(update);
let updatePayload = {
SubtitleMode: update?.subtitleMode ?? settings?.subtitleMode,
PlayDefaultAudioTrack:
update?.playDefaultAudioTrack ?? settings?.playDefaultAudioTrack,
RememberAudioSelections:
update?.rememberAudioSelections ?? settings?.rememberAudioSelections,
RememberSubtitleSelections:
update?.rememberSubtitleSelections ??
settings?.rememberSubtitleSelections,
} as Partial<UserConfiguration>;
updatePayload.AudioLanguagePreference =
update?.defaultAudioLanguage === null
? ""
: update?.defaultAudioLanguage?.ThreeLetterISOLanguageName ||
settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName ||
"";
updatePayload.SubtitleLanguagePreference =
update?.defaultSubtitleLanguage === null
? ""
: update?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ||
settings?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ||
"";
updateUserConfiguration(updatePayload);
};
const { data: user } = useQuery({
queryKey: ["authUser"],
queryFn: async () => {
if (!api) return;
const userApi = await getUserApi(api).getCurrentUser();
return userApi.data;
},
enabled: !!api,
staleTime: 0,
});
const { data: cultures = [], isFetched: isCulturesFetched } = useQuery({
queryKey: ["cultures"],
queryFn: async () => {
if (!api) return [];
const localizationApi = await getLocalizationApi(api).getCultures();
const cultures = localizationApi.data;
return cultures;
},
enabled: !!api,
staleTime: 43200000, // 12 hours
});
// Set default settings from user configuration.s
useEffect(() => {
if (!user || cultures.length === 0) return;
const userSubtitlePreference =
user?.Configuration?.SubtitleLanguagePreference;
const userAudioPreference = user?.Configuration?.AudioLanguagePreference;
const subtitlePreference = cultures.find(
(x) => x.ThreeLetterISOLanguageName === userSubtitlePreference
);
const audioPreference = cultures.find(
(x) => x.ThreeLetterISOLanguageName === userAudioPreference
);
updateSettings({
defaultSubtitleLanguage: subtitlePreference,
defaultAudioLanguage: audioPreference,
subtitleMode: user?.Configuration?.SubtitleMode,
playDefaultAudioTrack: user?.Configuration?.PlayDefaultAudioTrack,
rememberAudioSelections: user?.Configuration?.RememberAudioSelections,
rememberSubtitleSelections:
user?.Configuration?.RememberSubtitleSelections,
});
}, [user, isCulturesFetched]);
if (!api) return null;
return (
<MediaContext.Provider
value={{
settings,
updateSettings: updateSetingsWrapper,
user,
cultures,
}}
>
{children}
</MediaContext.Provider>
);
};

View File

@@ -1,9 +1,9 @@
import { useSettings } from "@/utils/atoms/settings";
import React from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { Text } from "../common/Text";
import { LANGUAGES } from "@/constants/Languages";
import { TextInput } from "react-native-gesture-handler";
interface Props extends ViewProps {}
@@ -12,232 +12,61 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
if (!settings) return null;
const renderSkipControl = (
value: number,
onDecrease: () => void,
onIncrease: () => void
) => (
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={onDecrease}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
{value}s
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={onIncrease}
>
<Text>+</Text>
</TouchableOpacity>
</View>
);
return (
<View>
<Text className="text-lg font-bold mb-2">Media</Text>
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Audio language</Text>
<Text className="text-xs opacity-50">
Choose a default audio language.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>{settings?.defaultAudioLanguage?.label || "None"}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Languages</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-audio"}
onSelect={() => {
updateSettings({
defaultAudioLanguage: null,
});
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{LANGUAGES.map((l) => (
<DropdownMenu.Item
key={l.value}
onSelect={() => {
updateSettings({
defaultAudioLanguage: l,
});
}}
>
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Subtitle language</Text>
<Text className="text-xs opacity-50">
Choose a default subtitle language.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{settings?.defaultSubtitleLanguage?.label || "None"}
</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Languages</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-subs"}
onSelect={() => {
updateSettings({
defaultSubtitleLanguage: null,
});
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{LANGUAGES.map((l) => (
<DropdownMenu.Item
key={l.value}
onSelect={() => {
updateSettings({
defaultSubtitleLanguage: l,
});
}}
>
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View {...props}>
<ListGroup title="Media Controls">
<ListItem title="Forward Skip Length">
{renderSkipControl(
settings.forwardSkipTime,
() =>
updateSettings({
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
}),
() =>
updateSettings({
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
})
)}
</ListItem>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Subtitle Size</Text>
<Text className="text-xs opacity-50">
Choose a default subtitle size for direct play (only works for
some subtitle formats).
</Text>
</View>
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() =>
updateSettings({
subtitleSize: Math.max(0, settings.subtitleSize - 5),
})
}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
{settings.subtitleSize}
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() =>
updateSettings({
subtitleSize: Math.min(120, settings.subtitleSize + 5),
})
}
>
<Text>+</Text>
</TouchableOpacity>
</View>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Forward skip length</Text>
<Text className="text-xs opacity-50">
Choose length in seconds when skipping in video playback.
</Text>
</View>
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() =>
updateSettings({
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
})
}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
{settings.forwardSkipTime}s
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() =>
updateSettings({
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
})
}
>
<Text>+</Text>
</TouchableOpacity>
</View>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Rewind length</Text>
<Text className="text-xs opacity-50">
Choose length in seconds when skipping in video playback.
</Text>
</View>
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() =>
updateSettings({
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
})
}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
{settings.rewindSkipTime}s
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() =>
updateSettings({
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
})
}
>
<Text>+</Text>
</TouchableOpacity>
</View>
</View>
</View>
<ListItem title="Rewind Length">
{renderSkipControl(
settings.rewindSkipTime,
() =>
updateSettings({
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
}),
() =>
updateSettings({
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
})
)}
</ListItem>
</ListGroup>
</View>
);
};

View File

@@ -0,0 +1,43 @@
import { TextInput, View, Linking } from "react-native";
import { Text } from "../common/Text";
interface Props {
value: string;
onChangeValue: (value: string) => void;
}
export const OptimizedServerForm: React.FC<Props> = ({
value,
onChangeValue,
}) => {
const handleOpenLink = () => {
Linking.openURL("https://github.com/streamyfin/optimized-versions-server");
};
return (
<View>
<View className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4">
<View className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}>
<Text className="mr-4">URL</Text>
<TextInput
className="text-white"
placeholder="http(s)://domain.org:port"
value={value}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={(text) => onChangeValue(text)}
/>
</View>
</View>
<Text className="px-4 text-xs text-neutral-500 mt-1">
Enter the URL for the optimize server. The URL should include http or
https and optionally the port.{" "}
<Text className="text-blue-500" onPress={handleOpenLink}>
Read more about the optimize server.
</Text>
</Text>
</View>
);
};

View File

@@ -0,0 +1,183 @@
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import {
BACKGROUND_FETCH_TASK,
registerBackgroundFetchAsync,
unregisterBackgroundFetchAsync,
} from "@/utils/background-tasks";
import { Ionicons } from "@expo/vector-icons";
import * as BackgroundFetch from "expo-background-fetch";
import * as ScreenOrientation from "expo-screen-orientation";
import * as TaskManager from "expo-task-manager";
import React, { useEffect } from "react";
import { Linking, Switch, TouchableOpacity, ViewProps } from "react-native";
import { toast } from "sonner-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
interface Props extends ViewProps {}
export const OtherSettings: React.FC = () => {
const [settings, updateSettings] = useSettings();
/********************
* Background task
*******************/
const checkStatusAsync = async () => {
await BackgroundFetch.getStatusAsync();
return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
};
useEffect(() => {
(async () => {
const registered = await checkStatusAsync();
if (settings?.autoDownload === true && !registered) {
registerBackgroundFetchAsync();
toast.success("Background downloads enabled");
} else if (settings?.autoDownload === false && registered) {
unregisterBackgroundFetchAsync();
toast.info("Background downloads disabled");
} else if (settings?.autoDownload === true && registered) {
// Don't to anything
} else if (settings?.autoDownload === false && !registered) {
// Don't to anything
} else {
updateSettings({ autoDownload: false });
}
})();
}, [settings?.autoDownload]);
/**********************
*********************/
if (!settings) return null;
return (
<ListGroup title="Other" className="mb-4">
<ListItem title="Auto rotate">
<Switch
value={settings.autoRotate}
onValueChange={(value) => updateSettings({ autoRotate: value })}
/>
</ListItem>
<ListItem title="Video orientation" disabled={settings.autoRotate}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
</Text>
<Ionicons name="chevron-expand-sharp" size={18} color="#5A5960" />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.DEFAULT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.DEFAULT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.PORTRAIT_UP,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.PORTRAIT_UP
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="3"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="4"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem>
<ListItem title="Safe area in controls">
<Switch
value={settings.safeAreaInControlsEnabled}
onValueChange={(value) =>
updateSettings({ safeAreaInControlsEnabled: value })
}
/>
</ListItem>
<ListItem
title="Show Custom Menu Links"
onPress={() =>
Linking.openURL(
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
)
}
>
<Switch
value={settings.showCustomMenuLinks}
onValueChange={(value) =>
updateSettings({ showCustomMenuLinks: value })
}
/>
</ListItem>
</ListGroup>
);
};

View File

@@ -0,0 +1,35 @@
import { useSettings } from "@/utils/atoms/settings";
import { useRouter } from "expo-router";
import React from "react";
import { View } from "react-native";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const PluginSettings = () => {
const [settings, updateSettings] = useSettings();
const router = useRouter();
if (!settings) return null;
return (
<View>
<ListGroup title="Plugins">
<ListItem
onPress={() => router.push("/settings/jellyseerr/page")}
title={"Jellyseerr"}
showArrow
/>
<ListItem
onPress={() => router.push("/settings/marlin-search/page")}
title="Marlin Search"
showArrow
/>
<ListItem
onPress={() => router.push("/settings/popular-lists/page")}
title="Popular Lists"
showArrow
/>
</ListGroup>
</View>
);
};

View File

@@ -0,0 +1,59 @@
import { Alert, View, ViewProps } from "react-native";
import { Text } from "../common/Text";
import { ListItem } from "../list/ListItem";
import { Button } from "../Button";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { useAtom } from "jotai";
import Constants from "expo-constants";
import Application from "expo-application";
import { ListGroup } from "../list/ListGroup";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import * as Haptics from "expo-haptics";
interface Props extends ViewProps {}
export const QuickConnect: React.FC<Props> = ({ ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
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");
}
}
}
);
};
return (
<View {...props}>
<ListGroup title={"Quick Connect"}>
<ListItem
onPress={openQuickConnectAuthCodeInput}
title="Authorize Quick Connect"
textColor="blue"
></ListItem>
</ListGroup>
</View>
);
};

View File

@@ -1,601 +0,0 @@
import { useDownload } from "@/providers/DownloadProvider";
import {
apiAtom,
getOrSetDeviceId,
userAtom,
} from "@/providers/JellyfinProvider";
import {ScreenOrientationEnum, Settings, useSettings} from "@/utils/atoms/settings";
import {
BACKGROUND_FETCH_TASK,
registerBackgroundFetchAsync,
unregisterBackgroundFetchAsync,
} from "@/utils/background-tasks";
import { getStatistics } from "@/utils/optimize-server";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import * as BackgroundFetch from "expo-background-fetch";
import * as ScreenOrientation from "expo-screen-orientation";
import * as TaskManager from "expo-task-manager";
import { useAtom } from "jotai";
import {useEffect, useState} from "react";
import {
Linking,
Switch,
TouchableOpacity,
View,
ViewProps,
} from "react-native";
import { toast } from "sonner-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { Text } from "../common/Text";
import { Loader } from "../Loader";
import { MediaToggles } from "./MediaToggles";
import {Stepper} from "@/components/inputs/Stepper";
interface Props extends ViewProps {}
export const SettingToggles: React.FC<Props> = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
const { setProcesses } = useDownload();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [marlinUrl, setMarlinUrl] = useState<string>("");
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
useState<string>(settings?.optimizedVersionsServerUrl || "");
const queryClient = useQueryClient();
/********************
* Background task
*******************/
const checkStatusAsync = async () => {
await BackgroundFetch.getStatusAsync();
return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
};
useEffect(() => {
(async () => {
const registered = await checkStatusAsync();
if (settings?.autoDownload === true && !registered) {
registerBackgroundFetchAsync();
toast.success("Background downloads enabled");
} else if (settings?.autoDownload === false && registered) {
unregisterBackgroundFetchAsync();
toast.info("Background downloads disabled");
} else if (settings?.autoDownload === true && registered) {
// Don't to anything
} else if (settings?.autoDownload === false && !registered) {
// Don't to anything
} else {
updateSettings({ autoDownload: false });
}
})();
}, [settings?.autoDownload]);
/**********************
*********************/
const {
data: mediaListCollections,
isLoading: isLoadingMediaListCollections,
} = useQuery({
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["sf_promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
return response.data.Items ?? [];
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 0,
});
if (!settings) return null;
return (
<View {...props}>
{/* <View>
<Text className="text-lg font-bold mb-2">Look and feel</Text>
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800 opacity-50">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="shrink">
<Text className="font-semibold">Coming soon</Text>
<Text className="text-xs opacity-50 max-w-[90%]">
Options for changing the look and feel of the app.
</Text>
</View>
<Switch disabled />
</View>
</View>
</View> */}
<MediaToggles />
<View>
<Text className="text-lg font-bold mb-2">Other</Text>
<View className="flex flex-col rounded-xl overflow-hidden divide-y-2 divide-solid divide-neutral-800">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="shrink">
<Text className="font-semibold">Auto rotate</Text>
<Text className="text-xs opacity-50">
Important on android since the video player orientation is
locked to the app orientation.
</Text>
</View>
<Switch
value={settings.autoRotate}
onValueChange={(value) => updateSettings({ autoRotate: value })}
/>
</View>
<View
pointerEvents={settings.autoRotate ? "none" : "auto"}
className={`
${
settings.autoRotate
? "opacity-50 pointer-events-none"
: "opacity-100"
}
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Video orientation</Text>
<Text className="text-xs opacity-50">
Set the full screen video player orientation.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.DEFAULT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.DEFAULT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.PORTRAIT_UP,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.PORTRAIT_UP
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="3"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="4"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-col">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col">
<Text className="font-semibold">Use popular lists plugin</Text>
<Text className="text-xs opacity-50">Made by: lostb1t</Text>
<TouchableOpacity
onPress={() => {
Linking.openURL(
"https://github.com/lostb1t/jellyfin-plugin-media-lists"
);
}}
>
<Text className="text-xs text-purple-600">More info</Text>
</TouchableOpacity>
</View>
<Switch
value={settings.usePopularPlugin}
onValueChange={(value) =>
updateSettings({ usePopularPlugin: value })
}
/>
</View>
{settings.usePopularPlugin && (
<View className="flex flex-col py-2 bg-neutral-900">
{mediaListCollections?.map((mlc) => (
<View
key={mlc.Id}
className="flex flex-row items-center justify-between bg-neutral-900 px-4 py-2"
>
<View className="flex flex-col">
<Text className="font-semibold">{mlc.Name}</Text>
</View>
<Switch
value={settings.mediaListCollectionIds?.includes(mlc.Id!)}
onValueChange={(value) => {
if (!settings.mediaListCollectionIds) {
updateSettings({
mediaListCollectionIds: [mlc.Id!],
});
return;
}
updateSettings({
mediaListCollectionIds:
settings.mediaListCollectionIds.includes(mlc.Id!)
? settings.mediaListCollectionIds.filter(
(id) => id !== mlc.Id
)
: [...settings.mediaListCollectionIds, mlc.Id!],
});
}}
/>
</View>
))}
{isLoadingMediaListCollections && (
<View className="flex flex-row items-center justify-center bg-neutral-900 p-4">
<Loader />
</View>
)}
{mediaListCollections?.length === 0 && (
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<Text className="text-xs opacity-50">
No collections found. Add some in Jellyfin.
</Text>
</View>
)}
</View>
)}
</View>
<View className="flex flex-col">
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Search engine</Text>
<Text className="text-xs opacity-50">
Choose the search engine you want to use.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>{settings.searchEngine}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Jellyfin</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ searchEngine: "Marlin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Marlin</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
{settings.searchEngine === "Marlin" && (
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
<View className="flex flex-row items-center space-x-2">
<View className="grow">
<Input
placeholder="Marlin Server URL..."
defaultValue={settings.marlinServerUrl}
value={marlinUrl}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={(text) => setMarlinUrl(text)}
/>
</View>
<Button
color="purple"
className="shrink w-16 h-12"
onPress={() => {
updateSettings({
marlinServerUrl: marlinUrl.endsWith("/")
? marlinUrl
: marlinUrl + "/",
});
}}
>
Save
</Button>
</View>
{settings.marlinServerUrl && (
<Text className="text-neutral-500 mt-2">
Current: {settings.marlinServerUrl}
</Text>
)}
</View>
)}
</View>
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="shrink">
<Text className="font-semibold">Show Custom Menu Links</Text>
<Text className="text-xs opacity-50">
Show custom menu links defined inside your Jellyfin web config.json file
</Text>
<TouchableOpacity
onPress={() =>
Linking.openURL("https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links")
}
>
<Text className="text-xs text-purple-600">More info</Text>
</TouchableOpacity>
</View>
<Switch
value={settings.showCustomMenuLinks}
onValueChange={(value) => updateSettings({ showCustomMenuLinks: value })}
/>
</View>
</View>
</View>
<View className="mt-4">
<Text className="text-lg font-bold mb-2">Downloads</Text>
<View className="flex flex-col rounded-xl overflow-hidden divide-y-2 divide-solid divide-neutral-800">
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Download method</Text>
<Text className="text-xs opacity-50">
Choose the download method to use. Optimized requires the
optimized server.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{settings.downloadMethod === "remux"
? "Default"
: "Optimized"}
</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Methods</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ downloadMethod: "remux" });
setProcesses([]);
}}
>
<DropdownMenu.ItemTitle>Default</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ downloadMethod: "optimized" });
setProcesses([]);
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Optimized</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View
pointerEvents={
settings.downloadMethod === "remux" ? "auto" : "none"
}
className={`
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
${
settings.downloadMethod === "remux"
? "opacity-100"
: "opacity-50"
}`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Remux max download</Text>
<Text className="text-xs opacity-50 shrink">
This is the total media you want to be able to download at the same time.
</Text>
</View>
<Stepper
value={settings.remuxConcurrentLimit}
step={1}
min={1}
max={4}
onUpdate={(value) => updateSettings({remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"]})}
/>
</View>
<View
pointerEvents={
settings.downloadMethod === "optimized" ? "auto" : "none"
}
className={`
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
${
settings.downloadMethod === "optimized"
? "opacity-100"
: "opacity-50"
}`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Auto download</Text>
<Text className="text-xs opacity-50 shrink">
This will automatically download the media file when it's
finished optimizing on the server.
</Text>
</View>
<Switch
value={settings.autoDownload}
onValueChange={(value) => updateSettings({ autoDownload: value })}
/>
</View>
<View
pointerEvents={
settings.downloadMethod === "optimized" ? "auto" : "none"
}
className={`
${
settings.downloadMethod === "optimized"
? "opacity-100"
: "opacity-50"
}`}
>
<View className="flex flex-col bg-neutral-900 px-4 py-4">
<View className="flex flex-col shrink mb-2">
<View className="flex flex-row justify-between items-center">
<Text className="font-semibold">
Optimized versions server
</Text>
</View>
<Text className="text-xs opacity-50">
Set the URL for the optimized versions server for downloads.
</Text>
</View>
<View></View>
<View className="flex flex-col">
<Input
placeholder="Optimized versions server URL..."
value={optimizedVersionsServerUrl}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={(text) => setOptimizedVersionsServerUrl(text)}
/>
<Button
color="purple"
className="h-12 mt-2"
onPress={async () => {
updateSettings({
optimizedVersionsServerUrl:
optimizedVersionsServerUrl.length === 0
? null
: optimizedVersionsServerUrl.endsWith("/")
? optimizedVersionsServerUrl
: optimizedVersionsServerUrl + "/",
});
const res = await getStatistics({
url: settings?.optimizedVersionsServerUrl,
authHeader: api?.accessToken,
deviceId: await getOrSetDeviceId(),
});
if (res) {
toast.success("Connected");
} else toast.error("Could not connect");
}}
>
Save
</Button>
</View>
</View>
</View>
</View>
</View>
</View>
);
};

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