Compare commits

..

452 Commits

Author SHA1 Message Date
Fredrik Burmester
bd24f59199 fix: websockets now work globally with vlc and transcoded player
does not disconnect and reconnect every time you open and close the player
2024-12-08 13:59:16 +01:00
Fredrik Burmester
5afb677b3a chore 2024-12-08 11:57:47 +01:00
Fredrik Burmester
3db12bd76a Merge pull request #261 from Alexk2309/fix/refactor-vlc-media-player
Refactored perfomance change for IOS
2024-12-07 16:53:02 +01:00
Alex Kim
26305c2983 Used debouncing for trick play and stop rendering trickplay, while not sliding 2024-12-08 02:48:23 +11:00
Alex Kim
9c02fa2e72 Refactored perfomance change for IOS 2024-12-07 23:04:21 +11:00
Fredrik Burmester
b08ec474a4 Merge pull request #259 from herrrta/fix/remux-download
Fix queue being downloaded all at once
2024-12-07 09:28:49 +01:00
Fredrik Burmester
416fb24ac0 Merge pull request #257 from Alexk2309/fix/pause-video-when-exiting-ios
Made pause video on app exit and added some performance changes for android
2024-12-07 09:28:08 +01:00
Alex Kim
0d2b15e5af Removed unneccessary print statement 2024-12-07 18:17:28 +11:00
Alex Kim
ef036cb362 Fixed for android and added some peformance changes for android 2024-12-07 18:11:41 +11:00
herrrta
006e457d23 # Fix queue being downloaded all at once 2024-12-06 19:54:43 -05:00
Alex Kim
832a717585 Improve performance of android version 2024-12-07 06:35:25 +11:00
Alex Kim
39f86a9eb1 Added android fix 2024-12-07 06:01:04 +11:00
Alex Kim
38445c6959 Fixed issue for IOS and android 2024-12-07 05:41:46 +11:00
Alex Kim
24320541c7 Made pause video on app exit on IOS 2024-12-07 04:15:16 +11:00
Fredrik Burmester
ee4e9fe347 Merge pull request #256 from Alexk2309/fix/optimize-direct-video-player-ios
Optimized direct player for IOS
2024-12-06 17:28:43 +01:00
Alex Kim
6d43b34f66 Optimized direct player for IOS 2024-12-07 03:14:25 +11:00
retardgerman
63cf7eb622 specified versions in dropdown menu 2024-12-06 14:16:53 +01:00
Fredrik Burmester
32130f1a9c Merge pull request #255 from Alexk2309/feature/prefetch-trick-play-on-video-start
Prefetch trick-play images
2024-12-06 08:39:01 +01:00
Fredrik Burmester
7f458f2f0b Merge pull request #254 from Alexk2309/hotfix/fixed-bugs-for-new-controls-ui
Bug fixes for controls
2024-12-06 08:38:48 +01:00
Alex Kim
6ec6c6daa0 Added feature to prefetch trick-play images on video start rather than downloading it while scrubbing 2024-12-06 17:41:42 +11:00
Alex Kim
02a48fd958 Refactored code, so that way the skip intro button is not using absolute positioning 2024-12-06 16:42:58 +11:00
Alex Kim
04c4dfd13a Fixed bugs for skip intro button still being able to be clicked once it is gone past the time frame 2024-12-06 15:08:33 +11:00
retardgerman
40bdb10653 add dropdown menu for App version 2024-12-05 22:59:30 +01:00
Fredrik Burmester
f16c486bfb Merge pull request #252 from Alexk2309/hotfix/control-ui-fix-skip-intro-button-overlapping-with-safe-area
Fixed skip intro button overlapping with safe area
2024-12-05 20:35:27 +01:00
Alex Kim
19fc00e314 Fixed skip intro button overlapping with safe area 2024-12-06 06:14:53 +11:00
Fredrik Burmester
c51965016c Merge pull request #251 from Alexk2309/feature/intergration-for-ios-file-provider
File System Support
2024-12-05 19:28:27 +01:00
Alex Kim
3bcf73f0dd Changed app ios settings 2024-12-06 05:11:26 +11:00
retardgerman
1ecef4be67 Update bug_report.yml 2024-12-05 18:29:59 +01:00
Fredrik Burmester
387525f9c3 Merge pull request #249 from Alexk2309/feature/subtitle-size-change
Added the ability to change subtitle size
2024-12-05 18:10:46 +01:00
Fredrik Burmester
cf182d8473 Merge branch 'master' into feature/subtitle-size-change 2024-12-05 18:10:38 +01:00
Fredrik Burmester
f0e3321a16 Merge pull request #248 from Alexk2309/feature/control-ui-change
Feature/control UI change
2024-12-05 18:09:38 +01:00
Fredrik Burmester
96c76e2b08 Merge pull request #247 from herrrta/feat/multiple-remux-downloads
Multiple Remux downloads
2024-12-05 18:09:27 +01:00
retardgerman
aaa07d93cf better bug_report.yml 2024-12-05 17:52:20 +01:00
Alex Kim
0716bba6ec Updated setting description 2024-12-06 03:47:58 +11:00
retardgerman
15476f3686 correct bug_report.yml 2024-12-05 17:44:15 +01:00
Alex Kim
97cf9185d3 Added the ability to change subtitle size 2024-12-06 03:40:48 +11:00
retardgerman
c11ad17ca5 fixed bug_report.yml 2024-12-05 17:34:20 +01:00
retardgerman
b0d563bc48 new bug_report.yml 2024-12-05 17:25:55 +01:00
retardgerman
909fc84ec0 Update bug_report.yml 2024-12-05 17:15:36 +01:00
retardgerman
0400597061 finally fixed bug_report.yml 2024-12-05 17:13:01 +01:00
retardgerman
b44a5fbbba fixed bug_report.yml 2024-12-05 17:09:13 +01:00
retardgerman
a5f6ba27b1 reworked bug submit handling 2024-12-05 17:08:32 +01:00
Alex Kim
ece1b8f2b9 Removed consoled log for changing brightness 2024-12-06 03:02:11 +11:00
Alex Kim
beb6702112 merge with master 2024-12-06 02:53:28 +11:00
Alex Kim
98c0ed4ad5 Removed pink background from slider 2024-12-06 02:50:16 +11:00
Alex Kim
b3f471bfa6 Added brightness slider 2024-12-06 01:17:24 +11:00
herrrta
1a10f0debf # Multiple Remux downloads
- Added stepper component
- Disabled more download settings based on download method
- refactored useRemuxHlsToMp4.ts to allow for multiple remux downloads
2024-12-05 01:27:55 -05:00
Fredrik Burmester
ac266c6956 Merge pull request #243 from herrrta/feat/show-overall-download-size
Show app usage
2024-12-03 16:23:24 +01:00
herrrta
b23a50914c Show app usage
- Added app usage to settings
- add more readable size formats
2024-12-03 09:56:07 -05:00
Fredrik Burmester
5c4a419d22 Merge branch 'pr/242' 2024-12-03 14:53:27 +01:00
Fredrik Burmester
3d034864f9 chore 2024-12-03 11:59:25 +01:00
Fredrik Burmester
ea183c426b fix: play new item when pressing play button 2024-12-03 11:59:22 +01:00
Fredrik Burmester
92be991cf7 fix: subtitles burn in for chromecast 2024-12-03 11:59:13 +01:00
herrrta
b73c29221a New delete options & storage visibility
- Added react-native-progress dependency
- Added bottom sheet to downloads page to handle actions for deleting items by type
- Added ability to long press to delete a single series
- Added ability to delete by season
- Refactored delete helpers in DownloadProvider.tsx
- Display storage usage inside downloads & settings page
- Fixed Delete all downloaded files from delting user data in mmkv
2024-12-02 22:37:59 -05:00
Alex Kim
880a739dd4 Reworked controls to have pause, and skip not in the slider anymore 2024-12-03 03:56:55 +11:00
Fredrik Burmester
69ffdc2ddf fix: try to fix #239 2024-12-02 12:04:24 +01:00
Fredrik Burmester
d686bd8c7b fix: tab bar icon not hiding 2024-12-02 11:44:52 +01:00
Fredrik Burmester
c8a60e735b Merge branch 'pr/238' 2024-12-02 11:35:22 +01:00
Fredrik Burmester
05f7574e60 fix: hide tab bar icon 2024-12-02 11:34:57 +01:00
Fredrik Burmester
11b880863c Revert "feat: cache item size"
This reverts commit aec172d8f5.
2024-12-02 11:34:49 +01:00
Fredrik Burmester
aec172d8f5 feat: cache item size 2024-12-02 11:13:05 +01:00
herrrta
7b52528d72 # Persist DownloadedItem size when downloading or when reading file for the first time 2024-12-01 18:07:34 -05:00
Fredrik Burmester
5fd1d9080e chore 2024-12-01 23:08:21 +01:00
Fredrik Burmester
5cc0f381fa chore 2024-12-01 22:26:40 +01:00
Fredrik Burmester
0f547deb39 Merge branch 'pr/210' 2024-12-01 21:18:20 +01:00
Fredrik Burmester
5aeb80348a chore 2024-12-01 21:16:53 +01:00
Fredrik Burmester
1dfc0ac762 Merge branch 'pr/233' 2024-12-01 21:01:59 +01:00
Fredrik Burmester
2b8aee442a Merge branch 'pr/232' 2024-12-01 21:01:54 +01:00
Fredrik Burmester
3e45adfeb5 Merge branch 'pr/231' 2024-12-01 21:01:45 +01:00
herrrta
b41363d347 # Allow option for viewing custom menu links
- Added new 'Other' setting to toggle new tab visibility
- Added new Tab to show custom links
- Added icon asset for list
2024-12-01 14:26:49 -05:00
herrrta
2d5a27c015 # Add Button to download whole series/Season
- Refactored DownloadItem.tsx to be compatible with multiple items
- Updated queueActions.enqueue signature to be compatible with array of jobs
- Added download button beside season dropdown to download entire season
- Added download button to series page to download entire series
2024-12-01 14:23:38 -05:00
herrrta
b5c6403e2d # Add download size to offline media downloads
- Added getDownloadSize helper function to display media size
 in MB or GB when appropriate
2024-12-01 14:23:29 -05:00
herrrta
7eb7d17fa9 # New downloads page for downloaded TV-Series
- Renamed downloads.tsx to index.tsx
- Added new downloads/series.tsx page
- Downloading now saves series primary image
- Downloads index page now shows series primary image with downloaded episode counter
- Updated EpisodeCard.tsx to display more information
- Moved season dropdown from SeasonPicker.tsx into its own component SeasonDropdown.tsx
- Updated navigation in DownloadItem.tsx to direct to series page when a downloaded episode is clicked
2024-12-01 14:23:12 -05:00
Fredrik Burmester
3d8875208f Merge branch 'pr/235' 2024-12-01 17:48:13 +01:00
Alex Kim
e4cfb52dab Added change to show actual device name rather than platform 2024-12-02 03:41:25 +11:00
Alex Kim
879e79cc47 Fixed some edge cases with dropdown 2024-12-02 03:21:26 +11:00
Fredrik Burmester
b9abe3e7f7 fix: stop playback on back button 2024-12-01 14:20:00 +01:00
Fredrik Burmester
383062ac0d fix: cache invalidation issue 2024-12-01 10:11:24 +01:00
Fredrik Burmester
3a507b6d1b fix: correct initialization of query client (based on docs) 2024-12-01 09:46:15 +01:00
Fredrik Burmester
500005afa8 fix: burn in subs for downloads 2024-11-30 10:18:09 +01:00
Fredrik Burmester
b638743497 fix: corrected ffmpeg command 2024-11-29 09:43:54 +01:00
Fredrik Burmester
73aae1d260 fix: don't return undefined 2024-11-29 09:43:39 +01:00
Fredrik Burmester
b84e95dc54 fix: add more remux debug logging 2024-11-29 09:05:40 +01:00
Fredrik Burmester
5292d89303 chore 2024-11-28 14:55:29 +01:00
Fredrik Burmester
acd14279f4 fix: make sure always max bitrate is selected 2024-11-28 10:21:30 +01:00
Fredrik Burmester
945d553cae fix: start at 0 for downloaded content 2024-11-28 10:15:07 +01:00
Fredrik Burmester
c33890fb38 fix: download all embeded audio and subtitle tracks, not just default 2024-11-28 10:14:57 +01:00
Fredrik Burmester
c718f53109 chore 2024-11-28 09:53:16 +01:00
Fredrik Burmester
18552bf622 Merge branch 'pr/226' 2024-11-27 12:54:24 +01:00
Fredrik Burmester
ec5c367438 Merge branch 'feature/vlc-support-android' of https://github.com/Alexk2309/streamyfin into pr/226 2024-11-27 12:25:46 +01:00
Fredrik Burmester
ba38fe6c03 fix: stop playback when exit 2024-11-27 12:25:39 +01:00
Alex Kim
a37da8f667 Added crash fix for subtitle disable 2024-11-27 22:07:48 +11:00
Fredrik Burmester
8b0b3d8abc fix: inf loop dep 2024-11-27 11:38:31 +01:00
Fredrik Burmester
d113729b6f fix: refetch on focus 2024-11-27 09:57:03 +01:00
Fredrik Burmester
e6ea5d13d4 fix: progress data not updating due to enabled being set
https://stackoverflow.com/a/72230424
2024-11-27 09:36:52 +01:00
Fredrik Burmester
c911a3c38a chore 2024-11-27 09:35:53 +01:00
Fredrik Burmester
a1a895815a fix: playback stopped dep 2024-11-27 09:35:26 +01:00
Fredrik Burmester
ea06efb82e fix: playback stopped dep 2024-11-27 09:35:17 +01:00
Fredrik Burmester
8a655c04b2 fix: key error 2024-11-27 08:59:41 +01:00
Fredrik Burmester
2db4effef5 fix: loader position 2024-11-27 08:59:31 +01:00
Fredrik Burmester
88a3bdd891 chore 2024-11-26 22:27:08 +01:00
Fredrik Burmester
6df20f516c fix: pressin to show controls 2024-11-26 22:27:05 +01:00
Fredrik Burmester
1fdf45daa7 fix: try to fix invalidate cache in prod 2024-11-26 21:54:35 +01:00
Fredrik Burmester
e8f4ee2264 chore 2024-11-26 21:24:42 +01:00
Fredrik Burmester
81d4e778e3 fix: lower opacity for video when show controls 2024-11-26 21:24:39 +01:00
Fredrik Burmester
025ce45e33 fix: routing 2024-11-25 21:59:48 +01:00
Fredrik Burmester
4f72cacbc0 fix: remove items 2024-11-25 21:59:38 +01:00
Fredrik Burmester
8c909e17bd Merge branch 'feature/vlc-support-android' of https://github.com/Alexk2309/streamyfin into pr/226 2024-11-25 17:11:13 +01:00
Fredrik Burmester
98fbf71ff8 feat: closes pr/181 2024-11-25 17:11:08 +01:00
Alex Kim
bf0c8a8007 Fixed refresh not refetching next-up and continue watching 2024-11-26 02:55:42 +11:00
Fredrik Burmester
44e5436c3b fix: video not quitting when leaving player route 2024-11-25 16:54:49 +01:00
Fredrik Burmester
d22f047f2b fix: controls safe areas 2024-11-25 15:58:08 +01:00
Fredrik Burmester
7f9dd4e14e fix: header padding 2024-11-25 08:22:04 +01:00
Alex Kim
e82890d7ff Fixed reloading issue 2024-11-25 15:53:15 +11:00
Alex Kim
0054095b20 Fixed bug with dupe subtitle names for transcoded content 2024-11-25 15:35:05 +11:00
Fredrik Burmester
d218d0b1c2 Merge branch 'feature/vlc-support-android' of https://github.com/Alexk2309/streamyfin into pr/226 2024-11-24 19:35:40 +01:00
Fredrik Burmester
93d117640a fix: change to mmkv and fix downloads with VLC 2024-11-24 19:34:49 +01:00
Alex Kim
d4009040d8 Fixed bug in code 2024-11-25 04:51:20 +11:00
Alex Kim
3d8e4a07ce Subtitle and audio now show which one is selected 2024-11-25 04:49:10 +11:00
Alex Kim
726301aca8 Merge branch 'feature/vlc-support-android' of github.com:Alexk2309/streamyfin into feature/vlc-support-android 2024-11-25 03:50:11 +11:00
Alex Kim
887ef10265 Implemented sorting subtitles in the correct order 2024-11-25 03:49:37 +11:00
Alex Kim
d47dd633c7 Updated IOS to work with new react-video transcoded player 2024-11-25 03:32:28 +11:00
Alex Kim
835484b367 Made transcoding content use react-native-video insted 2024-11-25 03:22:04 +11:00
Fredrik Burmester
335765993d fix: header issue 2024-11-24 10:51:01 +01:00
Fredrik Burmester
734772fb92 chore 2024-11-24 10:50:19 +01:00
Fredrik Burmester
56b37a1ec1 Merge branch 'pr/226' into feat/downloads-with-vlc 2024-11-24 10:38:10 +01:00
Fredrik Burmester
6a50eb9044 fix: offline playback using player component 2024-11-24 10:36:06 +01:00
Fredrik Burmester
3dee8ba2e3 chore: remove unused files 2024-11-24 09:40:26 +01:00
Fredrik Burmester
dc73677876 fix: chosenAudioTrack should not be undefined ? 2024-11-24 09:39:14 +01:00
Fredrik Burmester
0633d60186 chore 2024-11-24 00:12:14 +01:00
Fredrik Burmester
55f8af7069 wip 2024-11-23 22:42:04 +01:00
Alex Kim
02f4e4a16b Added disable option when on image based subg on transcoding 2024-11-24 01:59:22 +11:00
Alex Kim
c56b80889f Fixed up style 2024-11-24 01:15:01 +11:00
Alex Kim
ad2bfd8f28 Got preselect audio selection for IOS and android for direct play working 2024-11-24 01:02:00 +11:00
Alex Kim
0418cffba1 Added subtitle preselection for IOS 2024-11-24 00:43:07 +11:00
Alex Kim
6a29a10d82 Finished 2024-11-24 00:00:14 +11:00
Alex Kim
c5077953a8 Got preselect subtitles working for Android 2024-11-23 22:37:16 +11:00
Alex Kim
0e720aa8cf In progress of handling subtitles for transcoded streams 2024-11-23 06:17:38 +11:00
Fredrik Burmester
4699ee9c18 fix: updated device profile 2024-11-21 21:59:59 +01:00
Alex Kim
a7dd74e7ab Updated comments 2024-11-22 04:52:10 +11:00
Alex Kim
2a52499a75 Fixed HLS starting from the earliest segment for android 2024-11-22 04:51:11 +11:00
Alex Kim
a3f8087ccc Fixed HLS starting from the earliest segment 2024-11-22 01:58:59 +11:00
Fredrik Burmester
73acca6c21 fix: correct order of methods 2024-11-21 11:41:04 +01:00
Fredrik Burmester
f2367d3f68 fix: show correct list of subs 2024-11-21 10:24:26 +01:00
Fredrik Burmester
868c046cd2 fix: external subs not showing up due to isExternal not showing true when delivery url is present 2024-11-21 10:04:03 +01:00
Alex Kim
52b5b2875c WIP 2024-11-21 16:09:56 +11:00
Alex Kim
1aed133a67 Fixed not direct playing 2024-11-19 13:35:33 +11:00
Alex Kim
f127ee2976 Fixed destuctor bug 2024-11-19 02:55:01 +11:00
Alex Kim
72410d2729 Fixed next up not working 2024-11-18 20:21:34 +11:00
Fredrik Burmester
dcf59ac18e fix: ios header right padding 2024-11-18 09:24:43 +01:00
Fredrik Burmester
6b7bbf716c chore: remove unused code 2024-11-18 09:13:18 +01:00
Fredrik Burmester
6224f8b92d fix: use correct device profile for android 2024-11-18 09:09:25 +01:00
Fredrik Burmester
3843bf1fcd chore: remove unused code 2024-11-18 09:09:10 +01:00
Fredrik Burmester
5c44db183a chore 2024-11-18 08:55:34 +01:00
Fredrik Burmester
2350f4e294 fix: bitrate value not set on play start 2024-11-18 08:54:21 +01:00
Alex Kim
7ce3bc6e92 Fixed plugin not resolved issue 2024-11-18 18:07:59 +11:00
Alex Kim
21fbe1adae Try fix android issue for player seelct 2024-11-18 18:00:30 +11:00
Fredrik Burmester
cef1327fcb fix: hide/show now works + added back dropdown menu 2024-11-17 21:36:52 +01:00
Fredrik Burmester
a5677aae86 fix: move logic back into page
no need for separate ios and android components not that the player is combined
2024-11-17 21:36:40 +01:00
Fredrik Burmester
44a7ec238f fix: show distinct people 2024-11-17 21:36:13 +01:00
Alex Kim
34d7ab5f1e Fixed not starting at the correct posistion when playing video 2024-11-18 04:24:58 +11:00
Alex Kim
991f58cf73 SetSubtitle URL works now 2024-11-18 04:03:11 +11:00
Alex Kim
558480ea9d Got working subtitles/audio 2024-11-18 03:06:29 +11:00
Alex Kim
6b751cf154 Attempt to get events working 2024-11-17 20:52:07 +11:00
Alex Kim
e010c8229c Downgraded version for better compabilitiy 2024-11-17 18:35:20 +11:00
Alex Kim
128c369e55 Attempt to remove weird stretching 2024-11-17 14:18:45 +11:00
Alex Kim
0b0afb448d WIP 2024-11-17 05:48:29 +11:00
Fredrik Burmester
3d20b7956f wip 2024-11-11 09:18:49 +01:00
Fredrik Burmester
1fdf7ca42f wip 2024-11-10 23:29:21 +01:00
Fredrik Burmester
865fbdf834 wip 2024-11-10 22:36:03 +01:00
Fredrik Burmester
8ed81fbe23 wip 2024-11-10 17:03:15 +01:00
Fredrik Burmester
817e2b3d85 wip 2024-11-10 15:21:30 +01:00
Fredrik Burmester
fff880e708 fix: offline vlc playback not working 2024-11-10 11:54:22 +01:00
Fredrik Burmester
f2bcd2c675 fix: report playback stopped 2024-11-10 10:58:45 +01:00
Fredrik Burmester
00a296cee6 fix: not setting start position on video start 2024-11-10 10:55:22 +01:00
Fredrik Burmester
33b94105c2 chore 2024-11-10 10:16:31 +01:00
Fredrik Burmester
a23e370deb chore 2024-11-10 10:13:34 +01:00
Fredrik Burmester
d95833335e chore: merge things 2024-11-10 10:11:00 +01:00
Fredrik Burmester
5e91f45e3d chore 2024-11-10 09:32:48 +01:00
Fredrik Burmester
b8111babd2 Merge branch 'feat/native-tabbar' into pr/178 2024-11-10 09:31:53 +01:00
Fredrik Burmester
87092fed48 fix: working material3 theme 2024-11-10 09:21:51 +01:00
Fredrik Burmester
65a6ab9972 wip 2024-11-04 22:08:01 +00:00
Fredrik Burmester
8943708ff5 fix: update versions and use native-edge-to-edge 2024-11-04 21:35:26 +00:00
Alex Kim
c0b2579fdd Removed debug print statement 2024-11-04 00:19:58 +11:00
Alex Kim
272b8b914f Fixed incorrect time shown when downloading 2024-11-04 00:15:57 +11:00
Alex Kim
4eb7d0f151 Fixed skip intro skipping more than the video length 2024-11-03 23:18:02 +11:00
Fredrik Burmester
229670e829 fix(android): buffer state and video not loading 2024-11-01 16:54:20 +01:00
retardgerman
341a0f21d7 fix: change Icon Patch (#211) 2024-10-30 10:51:07 +01:00
Mateusz Kukieła
91b4e403e6 feat: add MacOS fullscreen support 2024-10-29 11:26:15 +01:00
Alex Kim
152d3a9c1c Fixed next up and previous episodes 2024-10-29 02:26:07 +11:00
Alex Kim
ad43ee7585 Fixed subtitle sizes 2024-10-29 00:49:22 +11:00
Alex Kim
a1fe226d22 Added hours for trickplay time 2024-10-28 23:28:59 +11:00
Alex Kim
0bc7bbed5a Fixed inproper conversion of secondsToMs 2024-10-28 21:46:46 +11:00
Alex Kim
0f178a502b Fixed intro skipper for VLC 2024-10-28 21:40:09 +11:00
Alex Kim
db20fffeb5 Fixed trick play for VLC 2024-10-28 21:12:42 +11:00
Fredrik Burmester
9ca71dc7fc Merge branch 'feature/vlc-support' of https://github.com/Alexk2309/streamyfin into pr/178 2024-10-27 15:47:07 +01:00
Fredrik Burmester
0117c87a55 fix: wrong time conversion in report playback progress 2024-10-27 15:46:44 +01:00
Fredrik Burmester
120fd4a32b wip 2024-10-27 15:21:59 +01:00
Fredrik Burmester
06e657dc4d wip 2024-10-27 14:14:27 +01:00
Fredrik Burmester
f5857e2162 fix: native pill look on android 2024-10-27 12:37:46 +01:00
Alex Kim
30280db810 WIP bug fixing 2024-10-27 22:30:30 +11:00
Fredrik Burmester
d1221dae83 chore 2024-10-27 11:24:49 +01:00
Fredrik Burmester
b99c18c5ac chore 2024-10-27 11:23:24 +01:00
Fredrik Burmester
25544eb157 chore 2024-10-27 11:22:58 +01:00
Fredrik Burmester
0f1ee174a0 chore 2024-10-27 11:21:59 +01:00
Fredrik Burmester
786f91ab4d Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-10-27 09:04:50 +01:00
Fredrik Burmester
5baa2a3697 chore: update deps 2024-10-27 09:04:37 +01:00
Alex Kim
b9375c1d7b Fixed race condition issue when playing media 2024-10-27 00:34:51 +11:00
retardgerman
d393bc0ac5 Typo fixed #206 2024-10-24 18:02:37 +02:00
Fredrik Burmester
ef9ed647c9 fix: don't include android build 2024-10-21 16:06:34 +02:00
Fredrik Burmester
68d32bd0de wip 2024-10-21 16:05:36 +02:00
Fredrik Burmester
ba76f2444d wip 2024-10-19 21:20:50 +02:00
Fredrik Burmester
d9fde3ba79 wip 2024-10-19 21:20:11 +02:00
Fredrik Burmester
f5b05bf32d wip 2024-10-19 13:20:38 +02:00
Fredrik Burmester
f71eb0be5a wip 2024-10-19 09:18:03 +02:00
Fredrik Burmester
3989d5e525 wip 2024-10-18 23:16:32 +02:00
Fredrik Burmester
4ad67f7f77 wip 2024-10-18 22:36:02 +02:00
Fredrik Burmester
39c49d4cdb wip 2024-10-18 22:27:26 +02:00
Fredrik Burmester
6e669b2aa9 fix: pause playback while seeking 2024-10-17 09:10:09 +02:00
Fredrik Burmester
ac4ce2934c fix: start position and errors 2024-10-16 18:58:45 +02:00
Fredrik Burmester
6a4fe83fbb chore 2024-10-16 08:50:22 +02:00
Fredrik Burmester
04e31e8628 fix: ignore android build of vlc player
since we don't play to have vlc for android right now
2024-10-16 08:23:45 +02:00
Fredrik Burmester
0fb2a6d32b chore: renaming refactoring 2024-10-16 08:21:57 +02:00
Fredrik Burmester
fcffee1981 feat: support start time 2024-10-16 08:21:42 +02:00
Fredrik Burmester
951a9d08ba fix: platform sepcific playing 2024-10-15 22:41:19 +02:00
Fredrik Burmester
3916c94f36 wip 2024-10-15 20:00:17 +02:00
Fredrik Burmester
c7901c759a chore 2024-10-15 13:12:25 +02:00
Fredrik Burmester
e852e40503 Merge branch 'master' into pr/178 2024-10-15 13:12:21 +02:00
Fredrik Burmester
ac9bcbcb9f fix: always use native device profile 2024-10-15 13:08:03 +02:00
Fredrik Burmester
9e5aa16a7d feat: select audio 2024-10-15 08:31:36 +02:00
Fredrik Burmester
ae963751cf wip 2024-10-15 08:01:12 +02:00
Fredrik Burmester
13d4117cc1 wip: external subs and cleanup 2024-10-15 07:32:25 +02:00
Fredrik Burmester
3807f847fd wip 2024-10-14 18:30:01 +02:00
Fredrik Burmester
67be97d857 wip: subtitles and onVideoLoad stuff 2024-10-14 11:14:34 +02:00
Alex Kim
af9f722b53 Removed duplicate Invalidate queries for next-up 2024-10-14 19:24:13 +11:00
Fredrik Burmester
092f5e73d7 Merge branch 'feature/vlc-support' of https://github.com/Alexk2309/streamyfin into pr/178 2024-10-13 20:18:38 +02:00
Alex Kim
7fe7e4e321 Fixed buffering issue 2024-10-14 05:17:52 +11:00
Fredrik Burmester
d41040e6d3 chore: keep up to date with master 2024-10-13 19:25:32 +02:00
Fredrik Burmester
a71832c6e5 feat: initial subtitle support 2024-10-13 17:59:47 +02:00
Fredrik Burmester
eefd1d9d13 fix: local playback 2024-10-13 16:07:03 +02:00
Fredrik Burmester
bbd12c540a fix: dark overlay not disapearing 2024-10-13 14:56:50 +02:00
Fredrik Burmester
0c436408e7 wip 2024-10-13 13:59:27 +02:00
Fredrik Burmester
ebc77f4ee2 Merge branch 'master' into feat/native-tabbar 2024-10-13 11:54:22 +02:00
Fredrik Burmester
d6ee1807f3 Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-10-13 11:39:54 +02:00
Fredrik Burmester
0d7c3cb9da fix: again remove animations because it causes no-tap-register bug 2024-10-13 11:39:51 +02:00
Fredrik Burmester
fd252247aa fix: screen rotation issues 2024-10-13 11:39:25 +02:00
Fredrik Burmester
c12af2efe9 Merge pull request #183 from simoncaron/feat/hide-zero-hour
feat: Hide 0h on play button when media length is lower than an hour
2024-10-13 08:51:27 +02:00
Simon Caron
04b24ee86b feat: Hide 0h on play button when media length is lower than an hour 2024-10-12 14:27:51 -04:00
Alex Kim
43d64bc3d0 Solved buffering issue when paused and scrubbing 2024-10-13 01:54:41 +11:00
Fredrik Burmester
f7401bd60c fix: app crashing on video exit 2024-10-12 16:09:59 +02:00
Fredrik Burmester
6a3d0ae296 fix: play states working 2024-10-12 15:53:25 +02:00
Fredrik Burmester
5c3da8b01a fix: don't clip bottom - allow blur 2024-10-12 15:14:52 +02:00
Fredrik Burmester
46c4b3e1d8 Merge branch 'master' into feat/native-tabbar 2024-10-12 15:03:31 +02:00
Fredrik Burmester
43d251fcda chore 2024-10-12 15:02:46 +02:00
Fredrik Burmester
fed3725733 fix: use better screen dimensions 2024-10-12 15:02:23 +02:00
Fredrik Burmester
f5be204ac8 fix: cache item count 2024-10-12 15:02:10 +02:00
Fredrik Burmester
ba6322bb1f wip 2024-10-12 13:41:09 +02:00
Fredrik Burmester
bf8687a473 wip 2024-10-12 12:55:45 +02:00
Alex Kim
09c6ad47d5 Update visual playback when exiting video 2024-10-12 19:07:16 +11:00
Fredrik Burmester
091a8ff6c3 fix: show more info in debug component 2024-10-11 22:24:23 +02:00
Fredrik Burmester
cab5693ced fix: debounce updates when seeking 2024-10-11 22:20:17 +02:00
Fredrik Burmester
be867a3b10 working 2024-10-11 22:10:47 +02:00
Fredrik Burmester
093fdcda45 Merge pull request #177 from fredrikburmester/fix/video-rotation-bug
fix: video rotation bug by updating screen dimensions dynamically using an event listener
2024-10-11 19:03:35 +02:00
Fredrik Burmester
eeaa027579 fix: turn into hook 2024-10-11 19:03:15 +02:00
Fredrik Burmester
a4c20981cf Merge pull request #174 from jakequade/fix/issue-159-chromecast-button-android
fix: casting broken on 0.17.0
2024-10-11 19:01:41 +02:00
Alex Kim
57354e6b06 WIP 2024-10-12 03:00:26 +11:00
Fredrik Burmester
63965c9e64 fix: video rotation bug 2024-10-11 16:41:44 +02:00
Fredrik Burmester
c5f39f6f8a chore: no need for these props any more 2024-10-11 12:04:15 +02:00
Fredrik Burmester
eb841601f6 fix: use correct url for chromecast streaming 2024-10-11 12:04:15 +02:00
jakequade
3f5ce6dc43 use cast button rather than feather icon for casting 2024-10-11 20:34:01 +11:00
Fredrik Burmester
2ed29e5a18 wip 2024-10-10 23:11:40 +02:00
Fredrik Burmester
380172c5ac Merge branch 'master' into feat/native-tabbar 2024-10-10 23:11:35 +02:00
Fredrik Burmester
b73a33b05b chore 2024-10-10 17:27:21 +02:00
Fredrik Burmester
e3baa2f58b fix: rotation issues 2024-10-10 17:27:17 +02:00
Alex Kim
8be1e2df0c Push to remote repo 2024-10-10 23:40:01 +11:00
Fredrik Burmester
ef7fbc985f chore 2024-10-10 10:10:24 +02:00
Fredrik Burmester
381c6701f2 chore: version bump 2024-10-10 07:56:23 +02:00
Fredrik Burmester
71da79ee6a chore 2024-10-09 20:23:53 +02:00
Fredrik Burmester
5cff323871 feat: go to next episode on end 2024-10-09 20:23:50 +02:00
Fredrik Burmester
39b7c66d34 fix: don't crash app when no media source found for unmatched items 2024-10-09 20:23:40 +02:00
Fredrik Burmester
57201f8606 wip 2024-10-09 20:06:30 +02:00
Fredrik Burmester
0a098bf26e chore 2024-10-09 07:28:53 +02:00
Fredrik Burmester
f6cb90e5dc feat: add logo to login 2024-10-09 07:28:49 +02:00
Fredrik Burmester
eba9163ce8 wip 2024-10-08 22:24:52 +02:00
Fredrik Burmester
4b166cf1d8 first commit 2024-10-08 21:21:12 +02:00
Fredrik Burmester
b878e93dec Merge pull request #163 from Alexk2309/master
Removed resumable items from next up
2024-10-08 19:55:28 +02:00
Fredrik Burmester
66cd36a899 feat: native selectable text for titles 2024-10-08 19:53:48 +02:00
Fredrik Burmester
91b926e6c2 fix: larger tap area 2024-10-08 19:53:38 +02:00
Fredrik Burmester
d4cc7499c0 Merge pull request #165 from fredrikburmester/refactor/player
Refactor: Update the player logic (video, music, live-tv)
2024-10-08 18:54:43 +02:00
Fredrik Burmester
317e719460 wip 2024-10-08 18:43:25 +02:00
Alex Kim
6012f8c8d2 Removed resumable items from next up 2024-10-09 02:40:11 +11:00
Fredrik Burmester
ec0843d737 wip 2024-10-08 15:39:44 +02:00
Fredrik Burmester
a5b4f6cc78 wip 2024-10-07 10:00:16 +02:00
Fredrik Burmester
4b60de4d43 fix: padding 2024-10-07 08:18:24 +02:00
Fredrik Burmester
aa56749402 wip 2024-10-06 22:48:31 +02:00
Fredrik Burmester
d6f02bd970 wip 2024-10-06 16:33:29 +02:00
Fredrik Burmester
862e783de1 wip 2024-10-06 15:11:06 +02:00
Fredrik Burmester
0233862fc1 wip 2024-10-06 13:03:16 +02:00
Fredrik Burmester
cc242a971f Merge branch 'master' into refactor/player 2024-10-06 09:48:42 +02:00
Fredrik Burmester
4fc3044838 Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-10-05 21:28:11 +02:00
Fredrik Burmester
df6cd17099 chore 2024-10-05 21:28:07 +02:00
Fredrik Burmester
5e8a0a9fa9 Merge pull request #156 from fredrikburmester/feat/live-tv
feat: live-tv support
2024-10-05 20:01:49 +02:00
Fredrik Burmester
005938a421 chore 2024-10-05 20:01:34 +02:00
Fredrik Burmester
81aafa26d4 chore: small fixes 2024-10-05 19:19:34 +02:00
Fredrik Burmester
0080874213 fix: pages 2024-10-05 19:19:28 +02:00
Fredrik Burmester
aa89c66e6e Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-10-05 13:26:46 +02:00
Fredrik Burmester
b1e2020b43 feat: new icons 2024-10-05 13:26:44 +02:00
retardgerman
5ab53738d5 fix: international App Store link 2024-10-05 13:18:49 +02:00
Fredrik Burmester
95de03f8b1 chore 2024-10-05 13:15:33 +02:00
Fredrik Burmester
48570489d5 chore 2024-10-05 12:38:02 +02:00
Fredrik Burmester
2c14a18e53 chore 2024-10-05 12:22:35 +02:00
Fredrik Burmester
200ccc6070 feat: guide grid view 2024-10-05 12:18:47 +02:00
Fredrik Burmester
1c20a3453f fix: streaming live tv now works 2024-10-05 10:24:49 +02:00
Fredrik Burmester
bf1efd7ca2 chore 2024-10-05 09:29:42 +02:00
Fredrik Burmester
387add4c83 first commit 2024-10-05 09:17:54 +02:00
Fredrik Burmester
d064622055 fix: add key 2024-10-05 08:38:24 +02:00
Fredrik Burmester
a04296f395 fix: bottom overlapp 2024-10-05 08:38:10 +02:00
Fredrik Burmester
eb11b928af fix: update device profile 2024-10-04 16:51:26 +02:00
Fredrik Burmester
4fd67091ea fix: change to ISO 639-2 Code 2024-10-04 16:48:33 +02:00
Fredrik Burmester
57c911cc94 feat: add star history 2024-10-04 16:43:58 +02:00
Fredrik Burmester
61255e6dc4 Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-10-04 13:29:19 +02:00
Fredrik Burmester
d9037e72f0 chore 2024-10-04 13:29:16 +02:00
Fredrik Burmester
b25cdce702 first commit 2024-10-04 12:08:45 +02:00
Fredrik Burmester
a85d5bbc92 Update README.md 2024-10-04 08:25:09 +02:00
Fredrik Burmester
097c428d41 chore 2024-10-03 21:44:54 +02:00
Fredrik Burmester
c55d498592 Merge pull request #151 from Alexk2309/master
Added support for more libraries to use the recently added section
2024-10-03 21:39:46 +02:00
Fredrik Burmester
1ce85a9d38 Merge branch 'master' into master 2024-10-03 21:39:27 +02:00
Fredrik Burmester
027c69bb7e fix: refactor 2024-10-03 21:37:20 +02:00
Fredrik Burmester
7a20b6db7d Merge pull request #142 from Simon-Eklundh/master
set downloads button to green if downloads exist
2024-10-03 20:04:23 +02:00
Fredrik Burmester
0e8f6dc0cc fix: use hook and purple color 2024-10-03 20:02:14 +02:00
Fredrik Burmester
bc3bdbf4c5 Merge branch 'master' into pr/142 2024-10-03 19:53:09 +02:00
Fredrik Burmester
2bea483f08 Merge pull request #140 from fredrikburmester/feat/background-downloads
[Feature] Background downloads
2024-10-03 19:27:53 +02:00
Fredrik Burmester
559d8474bc wip 2024-10-03 18:26:59 +02:00
Fredrik Burmester
83548da2c5 wip 2024-10-03 07:46:09 +02:00
Fredrik Burmester
b21a1cd18e wip 2024-10-03 07:37:37 +02:00
Fredrik Burmester
60981504fc wip 2024-10-02 22:07:13 +02:00
Alex Kim
f4bf0b2773 Fixed bug with useMemo changed size 2024-10-03 05:54:37 +10:00
Alex Kim
8138b37e7a Added support for more libaries to use the recently added section 2024-10-03 05:35:07 +10:00
Fredrik Burmester
1df7d8e8fe wip 2024-10-01 22:59:33 +02:00
Fredrik Burmester
0acc1f03f0 wip 2024-10-01 17:42:09 +02:00
Fredrik Burmester
dd1f02a13b fix: manual download button 2024-10-01 14:16:02 +02:00
Fredrik Burmester
c5c5252b89 fix: music screen fixes #149 2024-10-01 10:12:51 +02:00
Fredrik Burmester
679d6078e2 fix: remove failed process 2024-09-30 22:49:47 +02:00
Fredrik Burmester
329a75a047 fix: download path on android 2024-09-30 22:11:53 +02:00
Fredrik Burmester
5f91712126 fix: keep track of local download progress 2024-09-30 20:48:39 +02:00
Fredrik Burmester
7ce2c90376 wip 2024-09-30 16:34:54 +02:00
Fredrik Burmester
0263ad6109 fix: bottom padding 2024-09-29 23:27:16 +02:00
Fredrik Burmester
05b7872022 fix: use sheet instead of context 2024-09-29 23:25:10 +02:00
Fredrik Burmester
9458d113de wip 2024-09-29 22:56:22 +02:00
Fredrik Burmester
12cb6d4963 wip 2024-09-29 19:43:17 +02:00
Fredrik Burmester
1c2578477a fix: app not reporting playback started on first start 2024-09-29 14:48:51 +02:00
Fredrik Burmester
c7e10a13b5 fix: download progress not showing 2024-09-29 13:28:36 +02:00
Fredrik Burmester
31dbd84bec fix: add back speed to normal downloads 2024-09-29 12:03:37 +02:00
Fredrik Burmester
b6c6bac06a fix: queue now work for normal downloads 2024-09-29 11:59:37 +02:00
Fredrik Burmester
b0f7cfd013 wip 2024-09-29 11:02:13 +02:00
Fredrik Burmester
456048a92c wip 2024-09-29 08:05:22 +02:00
Fredrik Burmester
ff88c45d43 wip 2024-09-28 20:24:39 +02:00
Fredrik Burmester
ddcb410df6 wip 2024-09-28 19:57:56 +02:00
Fredrik Burmester
b3a938b53a wip 2024-09-28 18:32:08 +02:00
Fredrik Burmester
73c43d31ee wip 2024-09-28 16:13:01 +02:00
Fredrik Burmester
41a23d3437 wip 2024-09-28 15:45:00 +02:00
Simon Eklundh
92ebb29808 Merge branch 'master' into master 2024-09-28 10:52:03 +02:00
Simon Eklundh
f46cb97e7f working prototype 2024-09-28 10:42:02 +02:00
Fredrik Burmester
79020c357f wip 2024-09-27 18:44:41 +02:00
Fredrik Burmester
a386c3a47c wip 2024-09-27 18:18:10 +02:00
Fredrik Burmester
a46737442d wip 2024-09-27 17:35:51 +02:00
Fredrik Burmester
41d209f3b7 wip 2024-09-27 15:56:42 +02:00
Fredrik Burmester
d7eb25edf4 Update README.md 2024-09-26 13:34:19 +02:00
Fredrik Burmester
d672882c4b Update README.md 2024-09-26 13:34:03 +02:00
Fredrik Burmester
09dafea4ad chore 2024-09-25 08:28:36 +02:00
Fredrik Burmester
8936a559de feat: fade in player 2024-09-25 08:28:26 +02:00
Fredrik Burmester
7c10c467f3 chore 2024-09-25 07:42:41 +02:00
Fredrik Burmester
eff12b7350 fix: pip 2024-09-25 07:42:36 +02:00
Fredrik Burmester
94b6de6066 fix: open new full screen player 2024-09-25 07:42:32 +02:00
Fredrik Burmester
e82b154032 feat: skip credits 2024-09-24 20:05:04 +02:00
Fredrik Burmester
dd2a869929 chore: refactor 2024-09-24 20:04:58 +02:00
Fredrik Burmester
cd6158e141 chore 2024-09-24 20:04:50 +02:00
Fredrik Burmester
7fd232614b fix: refactor 2024-09-24 18:16:54 +02:00
Fredrik Burmester
af0a5f54d8 fix: design 2024-09-24 18:16:45 +02:00
Fredrik Burmester
3151812325 chore 2024-09-24 18:16:38 +02:00
Fredrik Burmester
9aa0dc0a3d wip: full screen player 2024-09-24 18:16:26 +02:00
Fredrik Burmester
9fcff04c0d chore: hide button 2024-09-24 18:16:00 +02:00
Fredrik Burmester
d1b6a265a1 fix: #135 2024-09-24 18:15:52 +02:00
Fredrik Burmester
ff1decfe2c feat: select skip/rewind time + refactor video player 2024-09-22 23:05:13 +02:00
Fredrik Burmester
a023c91877 feat: add genres to movies and episodes 2024-09-20 07:13:47 +02:00
Fredrik Burmester
27acd5287f fix: smaller card 2024-09-19 22:21:01 +02:00
Fredrik Burmester
ae73dab46d fix: design improvements 2024-09-19 22:17:40 +02:00
Fredrik Burmester
11f53630b5 feat: more from this actor 2024-09-19 22:17:29 +02:00
Fredrik Burmester
b7465a94e9 chore 2024-09-19 21:23:38 +02:00
Fredrik Burmester
cc97acbd4f feat: title selectable 2024-09-19 21:23:32 +02:00
Fredrik Burmester
abf7ec7d69 feat: include size 2024-09-19 21:23:22 +02:00
Fredrik Burmester
4dc9a6a0aa fix: initial load should be true 2024-09-19 21:23:04 +02:00
Fredrik Burmester
dd05ae89c3 fix: small changes 2024-09-19 16:51:13 +02:00
Fredrik Burmester
8a60adc6b2 fix: header size 2024-09-19 15:52:37 +02:00
Fredrik Burmester
51376cc8c1 fix: remove auto-hide controls for now 2024-09-19 13:00:20 +02:00
Fredrik Burmester
4eab1ebff6 chore 2024-09-18 08:42:26 +02:00
Fredrik Burmester
76388a408c fix: throttle 2024-09-18 08:42:23 +02:00
Fredrik Burmester
d3560c287c fix: more languages 2024-09-18 08:42:18 +02:00
Fredrik Burmester
da78ce898c fix: design 2024-09-18 08:42:09 +02:00
Fredrik Burmester
8a999a56a1 chore: component 2024-09-18 08:41:58 +02:00
Fredrik Burmester
669f8d7d1a fix: design 2024-09-18 08:41:48 +02:00
Fredrik Burmester
83bb5db335 fix: routing 2024-09-18 08:41:44 +02:00
Fredrik Burmester
7a5427099c fix: smaller titles 2024-09-18 08:41:28 +02:00
Fredrik Burmester
ee9b3de7d4 feat: set the server name as title 2024-09-17 19:34:32 +02:00
Fredrik Burmester
bcc28d7513 fix: accidental press when scrolling through the carousel 2024-09-17 19:34:18 +02:00
Fredrik Burmester
d093c028d2 chore 2024-09-17 19:33:46 +02:00
Fredrik Burmester
3032813234 Merge branch 'pr/129' 2024-09-17 18:55:06 +02:00
Fredrik Burmester
4a7d8721b3 chore 2024-09-17 18:50:26 +02:00
Fredrik Burmester
f45139ff90 Merge branch 'master' into feat/new-player-design 2024-09-17 18:49:44 +02:00
Fredrik Burmester
65579c88e5 wip 2024-09-17 18:49:11 +02:00
Fredrik Burmester
d716e42c20 wip 2024-09-17 08:53:10 +02:00
Fredrik Burmester
ffe1003710 wip 2024-09-17 08:31:47 +02:00
Fredrik Burmester
5c008f64b5 chore 2024-09-17 07:48:33 +02:00
Fredrik Burmester
721cd093f4 wip: orientation setting 2024-09-16 21:43:50 +02:00
Fredrik Burmester
3577aae7cc fix: improved server check
Included timeout, check url even if http is included, doc strings
2024-09-16 21:15:57 +02:00
Fredrik Burmester
402bdec5ab wip 2024-09-16 18:18:08 +02:00
simon
5e141f27c4 /System/Info/Public instead of /web 2024-09-16 16:37:45 +02:00
Fredrik Burmester
595120229f feat: see forward cached video length 2024-09-15 21:44:40 +02:00
Fredrik Burmester
09363bffdc wip 2024-09-15 21:36:59 +02:00
Fredrik Burmester
c3237571a8 fix: don't show fullscreen player on return from pip 2024-09-15 19:16:40 +02:00
simon
b67a4f1843 fixed some inconsistencies 2024-09-15 18:48:16 +02:00
simon
19a53da8a7 remove need for http/https 2024-09-15 18:45:26 +02:00
Fredrik Burmester
e3c4a291f0 wip 2024-09-15 18:39:20 +02:00
simon
25656cb7f1 remove need for http/https 2024-09-15 18:13:38 +02:00
simon
7f9c893560 remove need for http/https 2024-09-15 18:10:22 +02:00
Fredrik Burmester
ce2e5e0fb8 wip 2024-09-15 17:07:30 +02:00
Fredrik Burmester
c7703df3ce wip 2024-09-15 16:39:26 +02:00
simon
39880a6214 possible a good fix 2024-09-15 12:49:47 +02:00
Fredrik Burmester
b7629f6f2b wip: only full screen view, bar removed 2024-09-15 09:28:43 +02:00
Fredrik Burmester
409e2de6c8 wip: working small + full with transition 2024-09-15 08:56:54 +02:00
Fredrik Burmester
7cb67d73ec Merge branch 'master' into feat/new-player-design 2024-09-14 07:28:23 +03:00
Fredrik Burmester
1fe1438ecf chore: deps 2024-09-14 07:28:05 +03:00
Fredrik Burmester
611f5ae37b Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-09-14 07:22:34 +03:00
retardgerman
d2701254b3 Update bug_report.md 2024-09-13 14:08:15 +02:00
retardgerman
994dd44fc5 Update feature_request.md 2024-09-13 14:07:25 +02:00
Fredrik Burmester
f7e04dfa2d wip 2024-09-13 13:19:29 +03:00
Fredrik Burmester
cd126bb1c7 wip 2024-09-13 13:09:27 +03:00
Fredrik Burmester
ddbfb91260 wip 2024-09-13 13:07:55 +03:00
Fredrik Burmester
caac40c4b1 wip 2024-09-13 13:07:47 +03:00
Fredrik Burmester
2632feb3e8 wip 2024-09-13 12:24:25 +03:00
Fredrik Burmester
778447c1fd wip 2024-09-13 12:23:59 +03:00
Fredrik Burmester
5a1f555703 chore 2024-09-13 12:23:45 +03:00
Fredrik Burmester
2ed18d6588 feat: toast when starting download 2024-09-10 13:31:55 +03:00
Fredrik Burmester
c494b8e9f9 fix: add a test toast button 2024-09-10 13:31:36 +03:00
Fredrik Burmester
354fdd6791 fix: design 2024-09-10 13:31:20 +03:00
Fredrik Burmester
f48e0348ad fix: better play button color 2024-09-10 13:14:29 +03:00
Fredrik Burmester
23eaddf87c feat: add sonner/toast 2024-09-10 13:14:21 +03:00
Fredrik Burmester
a90dfb2805 fix: text 2024-09-10 10:27:15 +03:00
Fredrik Burmester
78d168050a chore 2024-09-10 10:26:16 +03:00
Fredrik Burmester
b92d55b9a0 chore 2024-09-10 10:25:52 +03:00
Fredrik Burmester
907f6193b5 fix: android header bugs 2024-09-10 10:25:48 +03:00
Fredrik Burmester
6f34f2e6a6 feat: allow authorizing quick connect closes #113 2024-09-09 09:04:35 +03:00
203 changed files with 13082 additions and 4917 deletions

View File

@@ -1,26 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone15Pro]
- OS: [e.g. iOS18]
- Version [e.g. 0.3.1]

59
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Bug report
description: Create a report to help us improve
title: '[Bug]: '
labels:
- ['❌ bug']
projects:
- ['fredrikburmester/5']
assignees:
- fredrikburmester
body:
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Reproduction steps
description: "How do you trigger this bug? Please walk us through it step by step."
placeholder: |
1.
2.
3.
...
validations:
required: true
- type: textarea
id: device
attributes:
label: Which device and operating system are you using?
description: e.g. iPhone 15, iOS 18.1.1
validations:
required: true
- type: dropdown
id: version
attributes:
label: Version
description: What version of Streamyfin are you running?
options:
- 0.22.0
- 0.21.0
- older
validations:
required: true
- type: textarea
id: screenshots
attributes:
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.

View File

@@ -2,7 +2,7 @@
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
labels: '✨ enhancement'
assignees: ''
---

4
.gitignore vendored
View File

@@ -9,6 +9,7 @@ npm-debug.*
*.mobileprovision
*.orig.*
web-build/
modules/vlc-player/android/build
# macOS
.DS_Store
@@ -26,8 +27,11 @@ package-lock.json
/ios
/android
modules/player/android
pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json
*.apk
*.ipa
.continuerc.json

3
.idea/.gitignore generated vendored Normal file
View File

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

329
.idea/caches/deviceStreaming.xml generated Normal file
View File

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

6
.idea/misc.xml generated Normal file
View File

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

8
.idea/modules.xml generated Normal file
View File

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

9
.idea/streamyfin.iml generated Normal file
View File

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

6
.idea/vcs.xml generated Normal file
View File

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

View File

@@ -8,5 +8,10 @@
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
}
},
"[swift]": {
"editor.defaultFormatter": "sswg.swift-lang"
},
"java.configuration.updateBuildConfiguration": "interactive",
"java.compile.nullAnalysis.mode": "automatic"
}

View File

@@ -13,7 +13,8 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
## 🌟 Features
- 📱 **Native video player**: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
- 🚀 **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.
@@ -61,7 +62,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to
## Get it now
<div style="display: flex; gap: 5px;">
<a href="https://apps.apple.com/se/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
</div>
@@ -135,7 +136,7 @@ Key points of the MPL-2.0:
## 🌐 Connect with Us
Join our Discord: [https://discord.gg/zyGKHJZvv4](https://discord.gg/aJvAYeycyY)
Join our Discord: [https://discord.gg/BuGG9ZNhaE](https://discord.gg/BuGG9ZNhaE)
If you have questions or need support, feel free to reach out:
@@ -153,3 +154,7 @@ 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.
- 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)

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.12.0",
"version": "0.22.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -10,7 +10,7 @@
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#29164B"
"backgroundColor": "#2E2E2E"
},
"jsEngine": "hermes",
"assetBundlePatterns": ["**/*"],
@@ -19,11 +19,14 @@
"infoPlist": {
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
"UIBackgroundModes": ["audio"],
"UIBackgroundModes": ["audio", "fetch"],
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
}
},
"UISupportsTrueScreenSizeOnMac": true,
"UIFileSharingEnabled": true,
"LSSupportsOpeningDocumentsInPlace": true
},
"config": {
"usesNonExemptEncryption": false
@@ -33,21 +36,17 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 36,
"versionCode": 47,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png"
"foregroundImage": "./assets/images/adaptive_icon.png"
},
"package": "com.fredrikburmester.streamyfin",
"permissions": [
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.WRITE_SETTINGS"
]
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
"expo-font",
@@ -71,18 +70,12 @@
}
}
],
[
"./plugins/withAndroidMainActivityAttributes",
{
"com.reactnative.googlecast.RNGCExpandedControllerActivity": true
}
],
["./plugins/withExpandedController.js"],
[
"expo-build-properties",
{
"ios": {
"deploymentTarget": "14.0"
"deploymentTarget": "15.6",
"useFrameworks": "static"
},
"android": {
"android": {
@@ -111,7 +104,14 @@
{
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
}
]
],
"expo-asset",
[
"react-native-edge-to-edge",
{ "android": { "parentTheme": "Material3" } }
],
["react-native-bottom-tabs"],
["./plugins/withChangeNativeAndroidTextToWhite.js"]
],
"experiments": {
"typedRoutes": true

View File

@@ -0,0 +1,20 @@
import {Stack} from "expo-router";
import { Platform } from "react-native";
export default function CustomMenuLayout() {
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: "Custom Links",
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,73 @@
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";
export interface MenuLink {
name: string,
url: string,
icon: string
}
export default function menuLinks() {
const [api] = useAtom(apiAtom);
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 config = response?.data;
if (!config && !config.hasOwnProperty("menuLinks")) {
console.error("Menu links not found");
return;
}
setMenuLinks(config?.menuLinks as MenuLink[])
} catch (error) {
console.error("Failed to retrieve config:", error);
}
},
[api]
)
useEffect(() => { getMenuLinks() }, []);
return (
<FlatList
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{
paddingTop: 10,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
data={menuLinks}
renderItem={({item}) => (
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url) }>
<ListItem
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 File

@@ -1,7 +1,6 @@
import { Chromecast } from "@/components/Chromecast";
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native";
@@ -18,66 +17,51 @@ export default function IndexLayout() {
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
style={{
marginRight: Platform.OS === "android" ? 17 : 0,
}}
onPress={() => {
router.push("/(auth)/downloads");
}}
>
<Feather name="download" color={"white"} size={22} />
</TouchableOpacity>
),
headerRight: () => (
<View className="flex flex-row items-center space-x-2">
<TouchableOpacity
onPress={() => {
router.push("/(auth)/syncplay");
}}
style={{
marginRight: 8,
}}
>
<Ionicons name="people" color={"white"} size={22} />
</TouchableOpacity>
<Chromecast />
<TouchableOpacity
onPress={() => {
router.push("/(auth)/settings");
}}
>
<View className="h-10 aspect-square flex items-center justify-center rounded">
<Feather name="settings" color={"white"} size={22} />
</View>
<Feather name="settings" color={"white"} size={22} />
</TouchableOpacity>
</View>
),
}}
/>
<Stack.Screen
name="downloads"
name="downloads/index"
options={{
title: "Downloads",
}}
/>
<Stack.Screen
name="downloads/[seriesId]"
options={{
title: "TV-Series",
}}
/>
<Stack.Screen
name="settings"
options={{
title: "Settings",
}}
/>
<Stack.Screen
name="syncplay"
options={{
title: "Syncplay",
presentation: "modal",
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name="collections/[collectionId]"
options={{
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
</Stack>
);
}

View File

@@ -1,192 +0,0 @@
import { Text } from "@/components/common/Text";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { Loader } from "@/components/Loader";
import { runningProcesses } from "@/utils/atoms/downloads";
import { queueAtom } from "@/utils/atoms/queue";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { FFmpegKit } from "ffmpeg-kit-react-native";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const downloads: React.FC = () => {
const [process, setProcess] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
const { data: downloadedFiles, isLoading } = useQuery({
queryKey: ["downloaded_files", process?.item.Id],
queryFn: async () =>
JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]"
) as BaseItemDto[],
staleTime: 0,
});
const movies = useMemo(
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
[downloadedFiles]
);
const groupedBySeries = useMemo(() => {
const episodes = downloadedFiles?.filter((f) => f.Type === "Episode");
const series: { [key: string]: BaseItemDto[] } = {};
episodes?.forEach((e) => {
if (!series[e.SeriesName!]) series[e.SeriesName!] = [];
series[e.SeriesName!].push(e);
});
return Object.values(series);
}, [downloadedFiles]);
const eta = useMemo(() => {
const length = process?.item?.RunTimeTicks || 0;
if (!process?.speed || !process?.progress) return "";
const timeLeft =
(length - length * (process.progress / 100)) / process.speed;
return formatNumber(timeLeft / 10000);
}, [process]);
const insets = useSafeAreaInsets();
if (isLoading) {
return (
<View className="h-full flex flex-col items-center justify-center -mt-6">
<Loader />
</View>
);
}
return (
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className="px-4 py-4">
<View className="mb-4 flex flex-col space-y-4">
<View>
<Text className="text-2xl font-bold mb-2">Queue</Text>
<View className="flex flex-col space-y-2">
{queue.map((q) => (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
>
<View>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">{q.item.Type}</Text>
</View>
<TouchableOpacity
onPress={() => {
setQueue((prev) => prev.filter((i) => i.id !== q.id));
}}
>
<Ionicons name="close" size={24} color="red" />
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
{queue.length === 0 && (
<Text className="opacity-50">No items in queue</Text>
)}
</View>
<View>
<Text className="text-2xl font-bold mb-2">Active download</Text>
{process?.item ? (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/page?id=${process.item.Id}`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
>
<View>
<Text className="font-semibold">{process.item.Name}</Text>
<Text className="text-xs opacity-50">
{process.item.Type}
</Text>
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
<Text className="text-xs">
{process.progress.toFixed(0)}%
</Text>
<Text className="text-xs">
{process.speed?.toFixed(2)}x
</Text>
<View>
<Text className="text-xs">ETA {eta}</Text>
</View>
</View>
</View>
<TouchableOpacity
onPress={() => {
FFmpegKit.cancel();
setProcess(null);
}}
>
<Ionicons name="close" size={24} color="red" />
</TouchableOpacity>
<View
className={`
absolute bottom-0 left-0 h-1 bg-purple-600
`}
style={{
width: process.progress
? `${Math.max(5, process.progress)}%`
: "5%",
}}
></View>
</TouchableOpacity>
) : (
<Text className="opacity-50">No active downloads</Text>
)}
</View>
</View>
{movies.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2">
<Text className="text-2xl font-bold">Movies</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text>
</View>
</View>
{movies?.map((item: BaseItemDto) => (
<View className="mb-2 last:mb-0" key={item.Id}>
<MovieCard item={item} />
</View>
))}
</View>
)}
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
<SeriesCard items={items} key={items[0].SeriesId} />
))}
</View>
</ScrollView>
);
};
export default downloads;
/*
* Format a number (Date.getTime) to a human readable string ex. 2m 34s
* @param {number} num - The number to format
*
* @returns {string} - The formatted string
*/
const formatNumber = (num: number) => {
const minutes = Math.floor(num / 60000);
const seconds = ((num % 60000) / 1000).toFixed(0);
return `${minutes}m ${seconds}s`;
};

View File

@@ -0,0 +1,106 @@
import {Text} from "@/components/common/Text";
import {useDownload} from "@/providers/DownloadProvider";
import {router, useLocalSearchParams, useNavigation} from "expo-router";
import React, {useCallback, useEffect, useMemo, useState} from "react";
import {ScrollView, TouchableOpacity, View} from "react-native";
import {EpisodeCard} from "@/components/downloads/EpisodeCard";
import {BaseItemDto} from "@jellyfin/sdk/lib/generated-client/models";
import {SeasonDropdown, SeasonIndexState} from "@/components/series/SeasonDropdown";
import {storage} from "@/utils/mmkv";
import {Ionicons} from "@expo/vector-icons";
export default function page() {
const navigation = useNavigation();
const local = useLocalSearchParams();
const {seriesId, episodeSeasonIndex} = local as {
seriesId: string,
episodeSeasonIndex: number | string | undefined
};
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>({});
const {downloadedFiles, deleteItems} = useDownload();
const series = useMemo(() => {
try {
return downloadedFiles
?.filter((f) => f.item.SeriesId == seriesId)
?.sort((a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!)
|| [];
} catch {
return [];
}
}, [downloadedFiles]);
const seasonIndex = seasonIndexState[series?.[0]?.item?.ParentId ?? ""] || episodeSeasonIndex || "";
const groupBySeason = useMemo<BaseItemDto[]>(() => {
const seasons: Record<string, BaseItemDto[]> = {};
series?.forEach((episode) => {
if (!seasons[episode.item.ParentIndexNumber!]) {
seasons[episode.item.ParentIndexNumber!] = [];
}
seasons[episode.item.ParentIndexNumber!].push(episode.item);
});
return seasons[seasonIndex]
?.sort((a, b) => a.IndexNumber! - b.IndexNumber!)
?? []
}, [series, seasonIndex]);
const initialSeasonIndex = useMemo(() =>
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ?? series?.[0]?.item?.ParentIndexNumber,
[groupBySeason]
);
useEffect(() => {
if (series.length > 0) {
navigation.setOptions({
title: series[0].item.SeriesName,
});
}
else {
storage.delete(seriesId);
router.back();
}
}, [series]);
const deleteSeries = useCallback(
async () => deleteItems(groupBySeason),
[groupBySeason]
);
return (
<>
{series.length > 0 && <View className="my-4 flex flex-row items-center justify-start">
<SeasonDropdown
item={series[0].item}
seasons={series.map(s => s.item)}
state={seasonIndexState}
initialSeasonIndex={initialSeasonIndex!}
onSelect={(season) => {
setSeasonIndexState((prev) => ({
...prev,
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
}));
}}/>
<View className="flex flex-row items-center justify-between w-72">
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
</View>
<View className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center">
<TouchableOpacity onPress={deleteSeries}>
<Ionicons name="trash" size={22} color="white"/>
</TouchableOpacity>
</View>
</View>
</View>}
<ScrollView key={seasonIndex}>
{groupBySeason.map((episode, index) => (
<View className="px-4 flex flex-col my-4" key={index}>
<EpisodeCard item={episode}/>
</View>
))}
</ScrollView>
</>
);
}

View File

@@ -0,0 +1,231 @@
import { Text } from "@/components/common/Text";
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
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 { useAtom } from "jotai";
import React, {useEffect, useMemo, useRef} from "react";
import {Alert, ScrollView, TouchableOpacity, View} from "react-native";
import { Button } from "@/components/Button";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {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();
const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
const router = useRouter();
const [settings] = useSettings();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const movies = useMemo(() => {
try {
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
} catch {
migration_20241124();
return [];
}
}, [downloadedFiles]);
const groupedBySeries = useMemo(() => {
try {
const episodes = downloadedFiles?.filter(
(f) => f.item.Type === "Episode"
);
const series: { [key: string]: DownloadedItem[] } = {};
episodes?.forEach((e) => {
if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = [];
series[e.item.SeriesName!].push(e);
});
return Object.values(series);
} catch {
migration_20241124();
return [];
}
}, [downloadedFiles]);
const insets = useSafeAreaInsets();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<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("Movie")
.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 (
<>
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === "remux" && (
<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
</Text>
<View className="flex flex-col space-y-2 mt-2">
{queue.map((q, index) => (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
key={index}
>
<View>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">{q.item.Type}</Text>
</View>
<TouchableOpacity
onPress={() => {
removeProcess(q.id);
setQueue((prev) => {
if (!prev) return [];
return [...prev.filter((i) => i.id !== q.id)];
});
}}
>
<Ionicons name="close" size={24} color="red"/>
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
{queue.length === 0 && (
<Text className="opacity-50">No items in queue</Text>
)}
</View>
)}
<ActiveDownloads/>
</View>
{movies.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">Movies</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{movies?.map((item) => (
<View className="mb-2 last:mb-0" key={item.item.Id}>
<MovieCard item={item.item}/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{groupedBySeries.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">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>
</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}>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className="flex px-4">
<Text className="opacity-50">No downloaded items</Text>
</View>
)}
</View>
</ScrollView>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
>
<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>
</View>
</BottomSheetView>
</BottomSheetModal>
</>
);
}
function migration_20241124() {
const router = useRouter();
const { deleteAllFiles } = useDownload();
Alert.alert(
"New app version requires re-download",
"The new update reqires content to be downloaded again. Please remove all downloaded content and try again.",
[
{
text: "Back",
onPress: () => router.back(),
},
{
text: "Delete",
style: "destructive",
onPress: async () => await deleteAllFiles(),
},
]
);
}

View File

@@ -4,11 +4,17 @@ import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
@@ -17,43 +23,40 @@ import {
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
RefreshControl,
SafeAreaView,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
type BaseSection = {
title: string;
queryKey: (string | undefined)[];
};
type ScrollingCollectionListSection = BaseSection & {
type ScrollingCollectionListSection = {
type: "ScrollingCollectionList";
queryFn: () => Promise<BaseItemDto[]>;
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[]>;
orientation?: "horizontal" | "vertical";
};
type MediaListSection = BaseSection & {
type MediaListSection = {
type: "MediaListSection";
queryFn: () => Promise<BaseItemDto>;
queryKey: (string | undefined)[];
queryFn: QueryFunction<BaseItemDto>;
};
type Section = ScrollingCollectionListSection | MediaListSection;
export default function index() {
const queryClient = useQueryClient();
const router = useRouter();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const [settings, _] = useSettings();
@@ -61,6 +64,31 @@ export default function index() {
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
const { downloadedFiles } = useDownload();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
useEffect(() => {
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className="p-2"
>
<Feather
name="download"
color={hasDownloads ? Colors.primary : "white"}
size={22}
/>
</TouchableOpacity>
),
});
}, [downloadedFiles, navigation, router]);
const checkConnection = useCallback(async () => {
setLoadingRetry(true);
const state = await NetInfo.fetch();
@@ -89,7 +117,7 @@ export default function index() {
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["userViews", user?.Id],
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
@@ -110,7 +138,7 @@ export default function index() {
isError: e2,
isLoading: l2,
} = useQuery({
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
queryKey: ["home", "sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
@@ -128,43 +156,84 @@ export default function index() {
staleTime: 60 * 1000,
});
const movieCollectionId = useMemo(() => {
return userViews?.find((c) => c.CollectionType === "movies")?.Id;
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
userViews?.filter(
(c) => c.CollectionType && allow.includes(c.CollectionType)
) || []
);
}, [userViews]);
const tvShowCollectionId = useMemo(() => {
return userViews?.find((c) => c.CollectionType === "tvshows")?.Id;
}, [userViews]);
const invalidateCache = useInvalidatePlaybackProgressCache();
const refetch = useCallback(async () => {
setLoading(true);
await queryClient.refetchQueries({ queryKey: ["userViews"] });
await queryClient.refetchQueries({ queryKey: ["resumeItems"] });
await queryClient.refetchQueries({ queryKey: ["nextUp-all"] });
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] });
await queryClient.refetchQueries({ queryKey: ["suggestions"] });
await queryClient.refetchQueries({
queryKey: ["sf_promoted"],
});
await queryClient.refetchQueries({
queryKey: ["sf_carousel"],
});
await invalidateCache();
setLoading(false);
}, [queryClient, user?.Id]);
}, []);
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined
): ScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async () => {
if (!api) return [];
return (
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 20,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
).data || []
);
},
type: "ScrollingCollectionList",
}),
[api, user?.Id]
);
const sections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = "Recently Added in " + c.Name;
const queryKey = [
"home",
"recentlyAddedIn" + c.CollectionType,
user?.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id
);
});
const ss: Section[] = [
{
title: "Continue Watching",
queryKey: ["resumeItems", user.Id],
queryKey: ["home", "resumeItems"],
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
})
).data.Items || [],
type: "ScrollingCollectionList",
@@ -172,7 +241,7 @@ export default function index() {
},
{
title: "Next Up",
queryKey: ["nextUp-all", user?.Id],
queryKey: ["home", "nextUp-all"],
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
@@ -180,57 +249,26 @@ export default function index() {
fields: ["MediaSourceCount"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
...latestMediaViews,
...(mediaListCollections?.map(
(ml) =>
({
title: ml.Name || "",
queryKey: ["mediaList", ml.Id],
title: ml.Name,
queryKey: ["home", "mediaList", ml.Id!],
queryFn: async () => ml,
type: "MediaListSection",
} as MediaListSection)
orientation: "vertical",
} as Section)
) || []),
{
title: "Recently Added in Movies",
queryKey: ["recentlyAddedInMovies", user?.Id, movieCollectionId],
queryFn: async () =>
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 50,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie"],
parentId: movieCollectionId,
})
).data || [],
type: "ScrollingCollectionList",
},
{
title: "Recently Added in TV-Shows",
queryKey: ["recentlyAddedInTVShows", user?.Id, tvShowCollectionId],
queryFn: async () =>
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 50,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Series"],
parentId: tvShowCollectionId,
})
).data || [],
type: "ScrollingCollectionList",
},
{
title: "Suggested Movies",
queryKey: ["suggestedMovies", user?.Id],
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async () =>
(
await getSuggestionsApi(api).getSuggestions({
@@ -245,7 +283,7 @@ export default function index() {
},
{
title: "Suggested Episodes",
queryKey: ["suggestedEpisodes", user?.Id],
queryKey: ["home", "suggestedEpisodes", user?.Id],
queryFn: async () => {
try {
const suggestions = await getSuggestions(api, user.Id);
@@ -265,13 +303,7 @@ export default function index() {
},
];
return ss;
}, [
api,
user?.Id,
movieCollectionId,
tvShowCollectionId,
mediaListCollections,
]);
}, [api, user?.Id, collections, mediaListCollections]);
if (isConnected === false) {
return (
@@ -315,8 +347,6 @@ export default function index() {
);
}
const insets = useSafeAreaInsets();
if (e1 || e2)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
@@ -341,14 +371,13 @@ export default function index() {
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<View
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
className="flex flex-col pt-4 pb-24 gap-y-4"
>
<View className="flex flex-col space-y-4">
<LargeMovieCarousel />
{sections.map((section, index) => {

View File

@@ -2,30 +2,91 @@ import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/ListItem";
import { SettingToggles } from "@/components/settings/SettingToggles";
import { useFiles } from "@/hooks/useFiles";
import {bytesToReadable, useDownload} from "@/providers/DownloadProvider";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { clearLogs, readFromLog } from "@/utils/log";
import {clearLogs, useLog} from "@/utils/log";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import { ScrollView, View } from "react-native";
import {Alert, ScrollView, 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 } = useFiles();
const { deleteAllFiles, appSizeUsage } = useDownload();
const { logs } = useLog();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: logs } = useQuery({
queryKey: ["logs"],
queryFn: async () => readFromLog(),
refetchInterval: 1000,
});
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 onClearLogsClicked = async () => {
clearLogs();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
};
return (
<ScrollView
contentContainerStyle={{
@@ -35,40 +96,62 @@ export default function settings() {
}}
>
<View className="p-4 flex flex-col gap-y-4">
{/* <Button
onPress={() => {
registerBackgroundFetchAsync();
}}
>
registerBackgroundFetchAsync
</Button> */}
<View>
<Text className="font-bold text-lg mb-2">Information</Text>
<Text className="font-bold text-lg mb-2">User Info</Text>
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
<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>
<View>
<Text className="font-bold text-lg mb-2">Quick connect</Text>
<Button onPress={openQuickConnectAuthCodeInput} color="black">
Authorize
</Button>
</View>
<SettingToggles />
<View className="flex flex-col space-y-2">
<Button color="black" onPress={logout}>
Log out
</Button>
<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}
/>
{size && (
<Text>Available: {bytesToReadable(size.remaining)}, Total: {bytesToReadable(size.total)}</Text>
)}
</View>
<Button
color="red"
onPress={async () => {
await deleteAllFiles();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
}}
onPress={onDeleteClicked}
>
Delete all downloaded files
</Button>
<Button
color="red"
onPress={async () => {
await clearLogs();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
}}
onPress={onClearLogsClicked}
>
Delete all logs
</Button>
@@ -87,7 +170,9 @@ export default function settings() {
>
{log.level}
</Text>
<Text className="text-xs">{log.message}</Text>
<Text uiTextView selectable className="text-xs">
{log.message}
</Text>
</View>
))}
{logs?.length === 0 && (

View File

@@ -1,145 +0,0 @@
import { Text } from "@/components/common/Text";
import { List } from "@/components/List";
import { ListItem } from "@/components/ListItem";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { ActivityIndicator, Alert, ScrollView, View } from "react-native";
export default function page() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const queryClient = useQueryClient();
const name = useMemo(() => user?.Name || "", [user]);
const { data: activeGroups } = useQuery({
queryKey: ["syncplay", "activeGroups"],
queryFn: async () => {
if (!api) return [];
const res = await getSyncPlayApi(api).syncPlayGetGroups();
return res.data;
},
refetchInterval: 1000,
refetchIntervalInBackground: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchOnMount: true,
});
const createGroupMutation = useMutation({
mutationFn: async (GroupName: string) => {
if (!api) return;
const res = await getSyncPlayApi(api).syncPlayCreateGroup({
newGroupRequestDto: {
GroupName,
},
});
if (res.status !== 204) {
Alert.alert("Error", "Failed to create group");
return false;
}
return true;
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["syncplay", "activeGroups"] });
},
});
const createGroup = () => {
Alert.prompt("Create Group", "Enter a name for the group", (text) => {
if (text) {
createGroupMutation.mutate(text);
}
});
};
const joinGroupMutation = useMutation({
mutationFn: async (groupId: string) => {
if (!api) return;
const res = await getSyncPlayApi(api).syncPlayJoinGroup({
joinGroupRequestDto: {
GroupId: groupId,
},
});
if (res.status !== 204) {
Alert.alert("Error", "Failed to join group");
return false;
}
return true;
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["syncplay", "activeGroups"] });
},
});
const leaveGroupMutation = useMutation({
mutationFn: async () => {
if (!api) return;
const res = await getSyncPlayApi(api).syncPlayLeaveGroup();
if (res.status !== 204) {
Alert.alert("Error", "Failed to exit group");
return false;
}
return true;
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["syncplay", "activeGroups"] });
},
});
return (
<ScrollView>
<View className="px-4 py-4">
<View>
<Text className="text-lg font-bold mb-4">Join group</Text>
{!activeGroups?.length && (
<Text className="text-neutral-500 mb-4">No active groups</Text>
)}
<List>
{activeGroups?.map((group) => (
<ListItem
key={group.GroupId}
title={group.GroupName}
onPress={async () => {
if (!group.GroupId) {
return;
}
if (group.Participants?.includes(name)) {
leaveGroupMutation.mutate();
} else {
joinGroupMutation.mutate(group.GroupId);
}
}}
iconAfter={
group.Participants?.includes(name) ? (
<Ionicons name="exit-outline" size={20} color="white" />
) : (
<Ionicons name="add" size={20} color="white" />
)
}
subTitle={group.Participants?.join(", ")}
/>
))}
<ListItem
onPress={() => createGroup()}
key={"create"}
title={"Create group"}
iconAfter={
createGroupMutation.isPending ? (
<ActivityIndicator size={20} color={"white"} />
) : (
<Ionicons name="add" size={20} color="white" />
)
}
/>
</List>
</View>
</View>
</ScrollView>
);
}

View File

@@ -50,7 +50,7 @@ const page: React.FC = () => {
userId: user.Id,
personIds: [actorId],
startIndex: pageParam,
limit: 8,
limit: 16,
sortOrder: ["Descending", "Descending", "Ascending"],
includeItemTypes: ["Movie", "Series"],
recursive: true,

View File

@@ -34,8 +34,6 @@ import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { FlatList, View } from "react-native";
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
const { collectionId } = searchParams as { collectionId: string };
@@ -169,7 +167,7 @@ const page: React.FC = () => {
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<MemoizedTouchableItemRouter
<TouchableItemRouter
key={item.Id}
style={{
width: "100%",
@@ -193,7 +191,7 @@ const page: React.FC = () => {
{/* <MoviePoster item={item} /> */}
<ItemCardText item={item} />
</View>
</MemoizedTouchableItemRouter>
</TouchableItemRouter>
),
[orientation]
);

View File

@@ -1,13 +1,101 @@
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 { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import React, { useMemo } from "react";
import { useAtom } from "jotai";
import React, { useEffect } from "react";
import { View } from "react-native";
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
const Page: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { id } = useLocalSearchParams() as { id: string };
const memoizedContent = useMemo(() => <ItemContent id={id} />, [id]);
const { data: item, isError } = useQuery({
queryKey: ["item", id],
queryFn: async () => {
if (!api || !user || !id) return;
const res = await getUserLibraryApi(api).getItem({
itemId: id,
userId: user?.Id,
});
return memoizedContent;
return res.data;
},
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
});
const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: opacity.value,
};
});
const fadeOut = (callback: any) => {
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
};
const fadeIn = (callback: any) => {
opacity.value = withTiming(1, { duration: 300 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
};
useEffect(() => {
if (item) {
fadeOut(() => {});
} else {
fadeIn(() => {});
}
}, [item]);
if (isError)
return (
<View className="flex flex-col items-center justify-center h-screen w-screen">
<Text>Could not load item</Text>
</View>
);
return (
<View className="flex flex-1 relative">
<Animated.View
pointerEvents={"none"}
style={[animatedStyle]}
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
>
<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>
</Animated.View>
{item && <ItemContent item={item} />}
</View>
);
};
export default React.memo(Page);
export default Page;

View File

@@ -0,0 +1,49 @@
import type {
MaterialTopTabNavigationEventMap,
MaterialTopTabNavigationOptions,
} from "@react-navigation/material-top-tabs";
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
import { ParamListBase, TabNavigationState } from "@react-navigation/native";
import { Stack, withLayoutContext } from "expo-router";
import React from "react";
const { Navigator } = createMaterialTopTabNavigator();
export const Tab = withLayoutContext<
MaterialTopTabNavigationOptions,
typeof Navigator,
TabNavigationState<ParamListBase>,
MaterialTopTabNavigationEventMap
>(Navigator);
const Layout = () => {
return (
<>
<Stack.Screen options={{ title: "Live TV" }} />
<Tab
initialRouteName="programs"
keyboardDismissMode="none"
screenOptions={{
tabBarBounces: true,
tabBarLabelStyle: { fontSize: 10 },
tabBarItemStyle: {
width: 100,
},
tabBarStyle: { backgroundColor: "black" },
animationEnabled: true,
lazy: true,
swipeEnabled: true,
tabBarIndicatorStyle: { backgroundColor: "#9334E9" },
tabBarScrollEnabled: true,
}}
>
<Tab.Screen name="programs" />
<Tab.Screen name="guide" />
<Tab.Screen name="channels" />
<Tab.Screen name="recordings" />
</Tab>
</>
);
};
export default Layout;

View File

@@ -0,0 +1,56 @@
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React from "react";
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function page() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const { data: channels } = useQuery({
queryKey: ["livetv", "channels"],
queryFn: async () => {
const res = await getLiveTvApi(api!).getLiveTvChannels({
startIndex: 0,
limit: 500,
enableFavoriteSorting: true,
userId: user?.Id,
addCurrentProgram: false,
enableUserData: false,
enableImageTypes: ["Primary"],
});
return res.data;
},
});
return (
<View className="flex flex-1">
<FlashList
data={channels?.Items}
estimatedItemSize={76}
renderItem={({ item }) => (
<View className="flex flex-row items-center px-4 mb-2">
<View className="w-22 mr-4 rounded-lg overflow-hidden">
<ItemImage
style={{
aspectRatio: "1/1",
width: 60,
borderRadius: 8,
}}
item={item}
/>
</View>
<Text className="font-bold">{item.Name}</Text>
</View>
)}
/>
</View>
);
}

View File

@@ -0,0 +1,168 @@
import { ItemImage } from "@/components/common/ItemImage";
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 { 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 { useSafeAreaInsets } from "react-native-safe-area-context";
const HOUR_HEIGHT = 30;
const ITEMS_PER_PAGE = 20;
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
export default function page() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const [date, setDate] = useState<Date>(new Date());
const [currentPage, setCurrentPage] = useState(1);
const { data: guideInfo } = useQuery({
queryKey: ["livetv", "guideInfo"],
queryFn: async () => {
const res = await getLiveTvApi(api!).getGuideInfo();
return res.data;
},
});
const { data: channels } = useQuery({
queryKey: ["livetv", "channels", currentPage],
queryFn: async () => {
const res = await getLiveTvApi(api!).getLiveTvChannels({
startIndex: (currentPage - 1) * ITEMS_PER_PAGE,
limit: ITEMS_PER_PAGE,
enableFavoriteSorting: true,
userId: user?.Id,
addCurrentProgram: false,
enableUserData: false,
enableImageTypes: ["Primary"],
});
return res.data;
},
});
const { data: programs } = useQuery({
queryKey: ["livetv", "programs", date, currentPage],
queryFn: async () => {
const startOfDay = new Date(date);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(date);
endOfDay.setHours(23, 59, 59, 999);
const now = new Date();
const isToday = startOfDay.toDateString() === now.toDateString();
const res = await getLiveTvApi(api!).getPrograms({
getProgramsDto: {
MaxStartDate: endOfDay.toISOString(),
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
ChannelIds: channels?.Items?.map((c) => c.Id).filter(
Boolean
) as string[],
ImageTypeLimit: 1,
EnableImages: false,
SortBy: ["StartDate"],
EnableTotalRecordCount: false,
EnableUserData: false,
},
});
return res.data;
},
enabled: !!channels,
});
const screenWidth = Dimensions.get("window").width;
const memoizedChannels = useMemo(() => channels?.Items || [], [channels]);
const [scrollX, setScrollX] = useState(0);
const handleNextPage = useCallback(() => {
setCurrentPage((prev) => prev + 1);
}, []);
const handlePrevPage = useCallback(() => {
setCurrentPage((prev) => Math.max(1, prev - 1));
}, []);
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
key={"home"}
contentContainerStyle={{
paddingLeft: insets.left,
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>
<View className="flex flex-row">
<View className="flex flex-col w-[64px]">
<View
style={{
height: HOUR_HEIGHT,
}}
className="bg-neutral-800"
></View>
{channels?.Items?.map((c, i) => (
<View className="h-16 w-16 mr-4 rounded-lg overflow-hidden" key={i}>
<ItemImage
style={{
width: "100%",
height: "100%",
resizeMode: "contain",
}}
item={c}
/>
</View>
))}
</View>
<ScrollView
style={{
width: screenWidth - 64,
}}
horizontal
scrollEnabled
onScroll={(e) => {
setScrollX(e.nativeEvent.contentOffset.x);
}}
>
<View className="flex flex-col">
<HourHeader height={HOUR_HEIGHT} />
{channels?.Items?.map((c, i) => (
<MemoizedLiveTVGuideRow
channel={c}
programs={programs?.Items}
key={c.Id}
scrollX={scrollX}
/>
))}
</View>
</ScrollView>
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,150 @@
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { TAB_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import React from "react";
import {
ScrollView,
View
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function page() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
key={"home"}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
paddingTop: 8,
}}
style={{
marginBottom: TAB_HEIGHT,
}}
>
<View className="flex flex-col space-y-2">
<ScrollingCollectionList
queryKey={["livetv", "recommended"]}
title={"On now"}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getRecommendedPrograms({
userId: user?.Id,
isAiring: true,
limit: 24,
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
enableTotalRecordCount: false,
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
});
return res.data.Items || [];
}}
orientation="horizontal"
/>
<ScrollingCollectionList
queryKey={["livetv", "shows"]}
title={"Shows"}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user?.Id,
hasAired: false,
limit: 9,
isMovie: false,
isSeries: true,
isSports: false,
isNews: false,
isKids: false,
enableTotalRecordCount: false,
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
return res.data.Items || [];
}}
orientation="horizontal"
/>
<ScrollingCollectionList
queryKey={["livetv", "movies"]}
title={"Movies"}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user?.Id,
hasAired: false,
limit: 9,
isMovie: true,
enableTotalRecordCount: false,
fields: ["ChannelInfo"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
return res.data.Items || [];
}}
orientation="horizontal"
/>
<ScrollingCollectionList
queryKey={["livetv", "sports"]}
title={"Sports"}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user?.Id,
hasAired: false,
limit: 9,
isSports: true,
enableTotalRecordCount: false,
fields: ["ChannelInfo"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
return res.data.Items || [];
}}
orientation="horizontal"
/>
<ScrollingCollectionList
queryKey={["livetv", "kids"]}
title={"For Kids"}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user?.Id,
hasAired: false,
limit: 9,
isKids: true,
enableTotalRecordCount: false,
fields: ["ChannelInfo"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
return res.data.Items || [];
}}
orientation="horizontal"
/>
<ScrollingCollectionList
queryKey={["livetv", "news"]}
title={"News"}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user?.Id,
hasAired: false,
limit: 9,
isNews: true,
enableTotalRecordCount: false,
fields: ["ChannelInfo"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
return res.data.Items || [];
}}
orientation="horizontal"
/>
</View>
</ScrollView>
);
}

View File

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

View File

@@ -8,20 +8,23 @@ import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import {useLocalSearchParams, useNavigation} from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import React, {useEffect} from "react";
import { 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();
const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as {
id: string;
seasonIndex: string;
};
console.log("seasonIndex", seasonIndex);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -57,7 +60,43 @@ const page: React.FC = () => {
[item]
);
if (!item || !backdropUrl) return null;
const {data: allEpisodes, isLoading} = useQuery({
queryKey: ["AllEpisodes", item?.Id],
queryFn: async () => {
const res = await getTvShowsApi(api!).getEpisodes({
seriesId: item?.Id!,
userId: user?.Id!,
enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview"],
});
return res?.data.Items || []
},
enabled: !!api && !!user?.Id && !!item?.Id
});
useEffect(() => {
navigation.setOptions({
headerRight: () => (
(!isLoading && allEpisodes && allEpisodes.length > 0) && (
<View className="flex flex-row items-center space-x-2">
<DownloadItems
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
<MaterialCommunityIcons name="folder-download" size={24} color="white"/>
)}
DownloadedIconComponent={() => (
<MaterialCommunityIcons name="folder-check" size={26} color="#9333ea"/>
)}
/>
</View>
)
)
})
}, [allEpisodes, isLoading]);
if (!item || !backdropUrl)
return null;
return (
<ParallaxScrollView

View File

@@ -1,8 +1,8 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useLocalSearchParams, useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
import React, { useCallback, useEffect, useMemo } from "react";
import { FlatList, useWindowDimensions, View } from "react-native";
import { Text } from "@/components/common/Text";
@@ -12,6 +12,7 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
genreFilterAtom,
@@ -28,7 +29,6 @@ import {
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import { orientationAtom } from "@/utils/atoms/orientation";
import {
BaseItemDto,
BaseItemDtoQueryResult,
@@ -56,22 +56,21 @@ const Page = () => {
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [sortBy, _setSortBy] = useAtom(sortByAtom);
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
const [orientation] = useAtom(orientationAtom);
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
const [sortOrderPreference, setOderByPreference] = useAtom(
sortOrderPreferenceAtom
);
const { orientation } = useOrientation();
useEffect(() => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sop) {
console.log("getSortOrderPreference ~", sop, libraryId);
_setSortOrder([sop]);
} else {
_setSortOrder([SortOrderOption.Ascending]);
}
const obp = getSortByPreference(libraryId, sortByPreference);
console.log("getSortByPreference ~", obp, libraryId);
if (obp) {
_setSortBy([obp]);
} else {
@@ -83,7 +82,6 @@ const Page = () => {
(sortBy: SortByOption[]) => {
const sop = getSortByPreference(libraryId, sortByPreference);
if (sortBy[0] !== sop) {
console.log("setSortByPreference ~", sortBy[0], libraryId);
setSortByPreference({ ...sortByPreference, [libraryId]: sortBy[0] });
}
_setSortBy(sortBy);
@@ -95,7 +93,6 @@ const Page = () => {
(sortOrder: SortOrderOption[]) => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sortOrder[0] !== sop) {
console.log("setSortOrderPreference ~", sortOrder[0], libraryId);
setOderByPreference({
...sortOrderPreference,
[libraryId]: sortOrder[0],
@@ -106,11 +103,12 @@ const Page = () => {
[libraryId, sortOrderPreference]
);
const getNumberOfColumns = useCallback(() => {
if (orientation === ScreenOrientation.Orientation.PORTRAIT_UP) return 3;
if (screenWidth < 600) return 5;
if (screenWidth < 960) return 6;
if (screenWidth < 1280) return 7;
const nrOfCols = useMemo(() => {
if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3;
if (screenWidth < 800) return 5;
if (screenWidth < 1000) return 6;
if (screenWidth < 1500) return 7;
return 6;
}, [screenWidth, orientation]);
@@ -128,6 +126,13 @@ const Page = () => {
staleTime: 60 * 1000,
});
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
title: library?.Name || "",
});
}, [library]);
const fetchItems = useCallback(
async ({
pageParam,
@@ -212,7 +217,7 @@ const Page = () => {
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<MemoizedTouchableItemRouter
<TouchableItemRouter
key={item.Id}
style={{
width: "100%",
@@ -223,10 +228,10 @@ const Page = () => {
<View
style={{
alignSelf:
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
? index % 3 === 0
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
? index % nrOfCols === 0
? "flex-end"
: (index + 1) % 3 === 0
: (index + 1) % nrOfCols === 0
? "flex-start"
: "center"
: "center",
@@ -237,7 +242,7 @@ const Page = () => {
<ItemPoster item={item} />
<ItemCardText item={item} />
</View>
</MemoizedTouchableItemRouter>
</TouchableItemRouter>
),
[orientation]
);
@@ -422,6 +427,7 @@ const Page = () => {
return (
<FlashList
key={orientation}
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No results</Text>
@@ -430,10 +436,10 @@ const Page = () => {
contentInsetAdjustmentBehavior="automatic"
data={flatData}
renderItem={renderItem}
extraData={orientation}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
estimatedItemSize={244}
numColumns={getNumberOfColumns()}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();

View File

@@ -195,6 +195,16 @@ export default function IndexLayout() {
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name="collections/[collectionId]"
options={{
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
</Stack>
);
}

View File

@@ -19,6 +19,16 @@ export default function SearchLayout() {
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name="collections/[collectionId]"
options={{
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
</Stack>
);
}

View File

@@ -1,4 +1,3 @@
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
@@ -71,37 +70,43 @@ export default function search() {
types: BaseItemKind[];
query: string;
}): Promise<BaseItemDto[]> => {
if (!api) return [];
if (!api || !query) return [];
if (searchEngine === "Jellyfin") {
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: query,
limit: 10,
includeItemTypes: types,
});
try {
if (searchEngine === "Jellyfin") {
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: query,
limit: 10,
includeItemTypes: types,
});
return searchApi.data.SearchHints as BaseItemDto[];
} else {
const url = `${settings?.marlinServerUrl}/search?q=${encodeURIComponent(
query
)}&includeItemTypes=${types
.map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`;
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
} else {
if (!settings?.marlinServerUrl) return [];
const url = `${
settings.marlinServerUrl
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
.map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`;
const response1 = await axios.get(url);
const ids = response1.data.ids;
const response1 = await axios.get(url);
const ids = response1.data.ids;
if (!ids || !ids.length) return [];
if (!ids || !ids.length) return [];
const response2 = await getItemsApi(api).getItems({
ids,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
const response2 = await getItemsApi(api).getItems({
ids,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
return response2.data.Items as BaseItemDto[];
return (response2.data.Items as BaseItemDto[]) || [];
}
} catch (error) {
console.error("Error during search:", error);
return []; // Ensure an empty array is returned in case of an error
}
},
[api, settings]
[api, searchEngine, settings]
);
const navigation = useNavigation();
@@ -227,7 +232,7 @@ export default function search() {
paddingRight: insets.right,
}}
>
<View className="flex flex-col pt-4 pb-32">
<View className="flex flex-col pt-2">
{Platform.OS === "android" && (
<View className="mb-4 px-4">
<Input
@@ -250,165 +255,125 @@ export default function search() {
<SearchItemWrapper
header="Movies"
ids={movies?.map((m) => m.Id!)}
renderItem={(data) => (
<HorizontalScroll
data={data}
renderItem={(item) => (
<TouchableItemRouter
key={item.Id}
className="flex flex-col w-28"
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>
)}
/>
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={(data) => (
<HorizontalScroll
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/series/${item.Id}`)}
className="flex flex-col w-28"
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableOpacity>
)}
/>
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={(data) => (
<HorizontalScroll
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/items/page?id=${item.Id}`)}
className="flex flex-col w-44"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableOpacity>
)}
/>
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={(data) => (
<HorizontalScroll
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
className="flex flex-col w-28"
onPress={() => router.push(`/collections/${item.Id}`)}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
</TouchableOpacity>
)}
/>
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={(data) => (
<HorizontalScroll
data={data}
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
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={(data) => (
<HorizontalScroll
data={data}
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
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={(data) => (
<HorizontalScroll
data={data}
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
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={(data) => (
<HorizontalScroll
data={data}
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
>
<AlbumCover id={item.AlbumId} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.AlbumId} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
{loading ? (
@@ -445,7 +410,7 @@ export default function search() {
type Props = {
ids?: string[] | null;
renderItem: (data: BaseItemDto[]) => React.ReactNode;
renderItem: (item: BaseItemDto) => React.ReactNode;
header?: string;
};
@@ -483,8 +448,14 @@ const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
return (
<>
<Text className="font-bold text-2xl px-4 my-2">{header}</Text>
{renderItem(data)}
<Text className="font-bold text-lg px-4 mb-2">{header}</Text>
<ScrollView
horizontal
className="px-4 mb-2"
showsHorizontalScrollIndicator={false}
>
{data.map((item) => renderItem(item))}
</ScrollView>
</>
);
};

View File

@@ -1,87 +1,91 @@
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
import React from "react";
import { Platform } from "react-native";
import { withLayoutContext } from "expo-router";
import {
createNativeBottomTabNavigator,
NativeBottomTabNavigationEventMap,
} from "@bottom-tabs/react-navigation";
const { Navigator } = createNativeBottomTabNavigator();
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import { Colors } from "@/constants/Colors";
import { BlurView } from "expo-blur";
import * as NavigationBar from "expo-navigation-bar";
import { Tabs } from "expo-router";
import React, { useEffect } from "react";
import { Platform, StyleSheet } from "react-native";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { SystemBars } from "react-native-edge-to-edge";
import { useSettings } from "@/utils/atoms/settings";
export const NativeTabs = withLayoutContext<
BottomTabNavigationOptions,
typeof Navigator,
TabNavigationState<ParamListBase>,
NativeBottomTabNavigationEventMap
>(Navigator);
export default function TabLayout() {
useEffect(() => {
if (Platform.OS === "android") {
NavigationBar.setBackgroundColorAsync("#121212");
NavigationBar.setBorderColorAsync("#121212");
}
}, []);
const [settings] = useSettings();
return (
<Tabs
initialRouteName="home"
screenOptions={{
tabBarActiveTintColor: Colors.tabIconSelected,
headerShown: false,
tabBarStyle: {
position: "absolute",
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
borderTopWidth: 0,
paddingTop: 8,
paddingBottom: Platform.OS === "android" ? 8 : 26,
height: Platform.OS === "android" ? 58 : 74,
},
tabBarBackground: () =>
Platform.OS === "ios" ? (
<BlurView
experimentalBlurMethod="dimezisBlurView"
intensity={95}
style={{
...StyleSheet.absoluteFillObject,
overflow: "hidden",
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
backgroundColor: "black",
}}
/>
) : undefined,
}}
>
<Tabs.Screen redirect name="index" />
<Tabs.Screen
name="(home)"
options={{
headerShown: false,
title: "Home",
tabBarIcon: ({ color, focused }) => (
<TabBarIcon
name={focused ? "home" : "home-outline"}
color={color}
/>
),
}}
/>
<Tabs.Screen
name="(search)"
options={{
headerShown: false,
title: "Search",
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name={focused ? "search" : "search"} color={color} />
),
}}
/>
<Tabs.Screen
name="(libraries)"
options={{
headerShown: false,
title: "Library",
tabBarIcon: ({ color, focused }) => (
<TabBarIcon
name={focused ? "apps" : "apps-outline"}
color={color}
/>
),
}}
/>
</Tabs>
<>
<SystemBars hidden={false} style="light" />
<NativeTabs
sidebarAdaptable
ignoresTopSafeArea
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
tabBarActiveTintColor={Colors.primary}
scrollEdgeAppearance="default"
>
<NativeTabs.Screen redirect name="index" />
<NativeTabs.Screen
name="(home)"
options={{
title: "Home",
tabBarIcon:
Platform.OS == "android"
? ({ color, focused, size }) =>
require("@/assets/icons/house.fill.png")
: () => ({ sfSymbol: "house" }),
}}
/>
<NativeTabs.Screen
name="(search)"
options={{
title: "Search",
tabBarIcon:
Platform.OS == "android"
? ({ color, focused, size }) =>
require("@/assets/icons/magnifyingglass.png")
: () => ({ sfSymbol: "magnifyingglass" }),
}}
/>
<NativeTabs.Screen
name="(libraries)"
options={{
title: "Library",
tabBarIcon:
Platform.OS == "android"
? ({ color, focused, size }) =>
require("@/assets/icons/server.rack.png")
: () => ({ sfSymbol: "rectangle.stack" }),
}}
/>
<NativeTabs.Screen
name="(custom-links)"
options={{
title: "Custom Links",
// @ts-expect-error
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
tabBarIcon:
Platform.OS == "android"
? () => require("@/assets/icons/list.png")
: () => ({ sfSymbol: "list.dash" }),
}}
/>
</NativeTabs>
</>
);
}

View File

@@ -0,0 +1,40 @@
import { Stack } from "expo-router";
import React from "react";
import { SystemBars } from "react-native-edge-to-edge";
export default function Layout() {
return (
<>
<SystemBars hidden />
<Stack>
<Stack.Screen
name="direct-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="transcoding-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="music-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
</Stack>
</>
);
}

View File

@@ -0,0 +1,545 @@
import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules/vlc-player";
import {
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log";
import native from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useMemo,
useRef,
useState,
useEffect,
} from "react";
import {
Alert,
BackHandler,
View,
AppState,
AppStateStatus,
} from "react-native";
import { useSharedValue } from "react-native-reanimated";
import settings from "../(tabs)/(home)/settings";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const { getDownloadedItem } = useDownload();
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const setShowControls = useCallback((show: boolean) => {
_setShowControls(show);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}, []);
const {
itemId,
audioIndex: audioIndexStr,
subtitleIndex: subtitleIndexStr,
mediaSourceId,
bitrateValue: bitrateValueStr,
offline: offlineStr,
} = useGlobalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
offline: string;
}>();
const [settings] = useSettings();
const offline = offlineStr === "true";
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
const bitrateValue = bitrateValueStr
? parseInt(bitrateValueStr, 10)
: BITRATES[0].value;
const {
data: item,
isLoading: isLoadingItem,
isError: isErrorItem,
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
console.log("Offline:", offline);
if (offline) {
const item = await getDownloadedItem(itemId);
if (item) return item.item;
}
const res = await getUserLibraryApi(api!).getItem({
itemId,
userId: user?.Id,
});
return res.data;
},
enabled: !!itemId,
staleTime: 0,
});
const {
data: stream,
isLoading: isLoadingStreamUrl,
isError: isErrorStreamUrl,
} = useQuery({
queryKey: [
"stream-url",
itemId,
audioIndex,
subtitleIndex,
mediaSourceId,
bitrateValue,
],
queryFn: async () => {
console.log("Offline:", offline);
if (offline) {
const data = await getDownloadedItem(itemId);
if (!data?.mediaSource) return null;
const url = await getDownloadedFileUrl(data.item.Id!);
if (item)
return {
mediaSource: data.mediaSource,
url,
sessionId: undefined,
};
}
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: native,
});
if (!res) return null;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert("Error", "Failed to get stream url");
return null;
}
return {
mediaSource,
sessionId,
url,
};
},
enabled: !!itemId && !!item,
staleTime: 0,
});
const togglePlay = useCallback(async () => {
if (!api) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
await videoRef.current?.pause();
if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.value),
isPaused: true,
playMethod: stream.url?.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
console.log("Actually marked as paused");
} else {
videoRef.current?.play();
if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.value),
isPaused: false,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
}
}, [
isPlaying,
api,
item,
stream,
videoRef,
audioIndex,
subtitleIndex,
mediaSourceId,
offline,
progress.value,
]);
const reportPlaybackStopped = useCallback(async () => {
if (offline) return;
const currentTimeInTicks = msToTicks(progress.value);
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!,
mediaSourceId: mediaSourceId,
positionTicks: currentTimeInTicks,
playSessionId: stream?.sessionId!,
});
revalidateProgressCache();
}, [api, item, mediaSourceId, stream]);
const stop = useCallback(() => {
reportPlaybackStopped();
setIsPlaybackStopped(true);
videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]);
const reportPlaybackStart = useCallback(async () => {
if (offline) return;
if (!stream) return;
await getPlaystateApi(api!).onPlaybackStart({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
});
}, [api, item, mediaSourceId, stream]);
const onProgress = useCallback(
async (data: ProgressUpdatePayload) => {
if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return;
const { currentTime } = data.nativeEvent;
if (isBuffering) {
setIsBuffering(false);
}
progress.value = currentTime;
if (offline) return;
const currentTimeInTicks = msToTicks(currentTime);
if (!item?.Id || !stream) return;
console.log("onProgress ~", currentTimeInTicks, isPlaying);
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(currentTimeInTicks),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
});
},
[item?.Id, isPlaying, api, isPlaybackStopped]
);
useOrientation();
useOrientationSettings();
useWebSocket({
isPlaying: isPlaying,
togglePlay: togglePlay,
stopPlayback: stop,
offline,
});
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") {
setIsPlaying(true);
return;
}
if (state === "Paused") {
setIsPlaying(false);
return;
}
if (isPlaying) {
setIsPlaying(true);
setIsBuffering(false);
} else if (isBuffering) {
setIsBuffering(true);
}
}, []);
const startPosition = useMemo(() => {
if (offline) return 0;
return item?.UserData?.PlaybackPositionTicks
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
: 0;
}, [item]);
const backAction = () => {
videoRef.current?.stop();
return false;
};
useFocusEffect(
React.useCallback(() => {
const onBackPress = () => {
return backAction();
};
BackHandler.addEventListener("hardwareBackPress", onBackPress);
return async () => {
videoRef.current?.stop();
BackHandler.removeEventListener("hardwareBackPress", onBackPress);
};
}, [])
);
const [appState, setAppState] = useState(AppState.currentState);
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();
}
}
setAppState(nextAppState);
};
// Use AppState.addEventListener and return a cleanup function
const subscription = AppState.addEventListener(
"change",
handleAppStateChange
);
return () => {
// Cleanup the event listener when the component is unmounted
subscription.remove();
};
}, [appState]);
// Preselection of audio and subtitle tracks.
if (!settings) return null;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
let externalTrack = { name: "", DeliveryUrl: "" };
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle"
) || [];
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex
);
const allAudio =
stream?.mediaSource.MediaStreams?.filter(
(audio) => audio.Type === "Audio"
) || [];
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
// Direct playback CASE
if (!bitrateValue) {
// If Subtitle is embedded we can use the position to select it straight away.
if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) {
initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`);
} else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) {
// If Subtitle is external we need to pass the URL to the player.
externalTrack = {
name: chosenSubtitleTrack.DisplayTitle || "",
DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
};
}
if (chosenAudioTrack)
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
} else {
// Transcoded playback CASE
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
externalTrack = {
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
DeliveryUrl: "",
};
}
}
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
if (isErrorItem || isErrorStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">Error</Text>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: "black" }}>
<View
style={{
display: "flex",
width: "100%",
height: "100%",
position: "relative",
flexDirection: "column",
justifyContent: "center",
opacity: showControls ? 0.5 : 1,
}}
>
<VlcPlayerView
ref={videoRef}
source={{
uri: stream.url,
autoplay: true,
isNetwork: true,
startPosition,
externalTrack,
initOptions,
}}
style={{ width: "100%", height: "100%" }}
onVideoProgress={onProgress}
progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged}
onVideoLoadStart={() => {}}
onVideoLoadEnd={() => {
setIsVideoLoaded(true);
}}
onVideoError={(e) => {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
"Error",
"An error occurred while playing the video. Check logs in settings."
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
/>
</View>
{videoRef.current && (
<Controls
mediaSource={stream?.mediaSource}
item={item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded}
play={videoRef.current?.play}
pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo}
enableTrickplay={true}
getAudioTracks={videoRef.current?.getAudioTracks}
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
offline={false}
setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack}
stop={stop}
isVlc
/>
)}
</View>
);
}
export function usePoster(
item: BaseItemDto,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!item || !api) return undefined;
return item.Type === "Audio"
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: item,
quality: 70,
width: 200,
});
}, [item, api]);
return poster ?? undefined;
}

View File

@@ -0,0 +1,420 @@
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useWebSocket } from "@/hooks/useWebsockets";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { Image } from "expo-image";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Pressable, useWindowDimensions, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, { OnProgressData, VideoRef } from "react-native-video";
export default function page() {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [settings] = useSettings();
const videoRef = useRef<VideoRef | null>(null);
const windowDimensions = useWindowDimensions();
const firstTime = useRef(true);
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const {
itemId,
audioIndex: audioIndexStr,
subtitleIndex: subtitleIndexStr,
mediaSourceId,
bitrateValue: bitrateValueStr,
} = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
const subtitleIndex = subtitleIndexStr
? parseInt(subtitleIndexStr, 10)
: undefined;
const bitrateValue = bitrateValueStr
? parseInt(bitrateValueStr, 10)
: undefined;
const {
data: item,
isLoading: isLoadingItem,
isError: isErrorItem,
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
if (!api) return;
const res = await getUserLibraryApi(api).getItem({
itemId,
userId: user?.Id,
});
return res.data;
},
enabled: !!itemId && !!api,
staleTime: 0,
});
const {
data: stream,
isLoading: isLoadingStreamUrl,
isError: isErrorStreamUrl,
} = useQuery({
queryKey: ["stream-url"],
queryFn: async () => {
if (!api) return;
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
});
if (!res) return null;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) return null;
return {
mediaSource,
sessionId,
url,
};
},
});
const poster = usePoster(item, api);
const videoSource = useVideoSource(item, api, poster, stream?.url);
const togglePlay = useCallback(
async (ticks: number) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(ticks),
isPaused: true,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream?.sessionId,
});
} else {
videoRef.current?.resume();
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(ticks),
isPaused: false,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream?.sessionId,
});
}
},
[
isPlaying,
api,
item,
videoRef,
settings,
audioIndex,
subtitleIndex,
mediaSourceId,
stream,
]
);
const play = useCallback(() => {
console.log("play");
videoRef.current?.resume();
reportPlaybackStart();
}, [videoRef]);
const pause = useCallback(() => {
console.log("play");
videoRef.current?.pause();
}, [videoRef]);
const stop = useCallback(() => {
console.log("stop");
setIsPlaybackStopped(true);
videoRef.current?.pause();
reportPlaybackStopped();
}, [videoRef]);
const seek = useCallback(
(seconds: number) => {
videoRef.current?.seek(seconds);
},
[videoRef]
);
const reportPlaybackStopped = async () => {
if (!item?.Id) return;
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item.Id,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(progress.value),
playSessionId: stream?.sessionId,
});
};
const reportPlaybackStart = async () => {
if (!item?.Id) return;
await getPlaystateApi(api!).onPlaybackStart({
itemId: item?.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
};
const onProgress = useCallback(
async (data: OnProgressData) => {
if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return;
const ticks = data.currentTime * 10000000;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBuffering(data.playableDuration === 0);
if (!item?.Id || data.currentTime === 0) return;
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.round(ticks),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
},
[
item,
isPlaying,
api,
isPlaybackStopped,
audioIndex,
subtitleIndex,
mediaSourceId,
stream,
]
);
useFocusEffect(
useCallback(() => {
play();
return () => {
stop();
};
}, [play, stop])
);
useOrientation();
useOrientationSettings();
useWebSocket({
isPlaying: isPlaying,
pauseVideo: pause,
playVideo: play,
stopPlayback: stop,
});
if (isLoadingItem || isLoadingStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
if (isErrorItem || isErrorStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">Error</Text>
</View>
);
if (!item || !stream)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">Error</Text>
</View>
);
return (
<View
style={{
width: windowDimensions.width,
height: windowDimensions.height,
position: "relative",
}}
className="flex flex-col items-center justify-center"
>
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
<Image
source={poster}
style={{ width: "100%", height: "100%", resizeMode: "contain" }}
/>
</View>
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
className="absolute z-0 h-full w-full opacity-0"
>
{videoSource && (
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={() => {}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
progressUpdateInterval={500}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
setIsPlaying(state.isPlaying);
}}
/>
)}
</Pressable>
<Controls
item={item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
enableTrickplay={false}
pause={pause}
play={play}
seek={seek}
isVlc={false}
stop={stop}
/>
</View>
);
}
export function usePoster(
item: BaseItemDto | null | undefined,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!item || !api) return undefined;
return item.Type === "Audio"
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: item,
quality: 70,
width: 200,
});
}, [item, api]);
return poster ?? undefined;
}
export function useVideoSource(
item: BaseItemDto | null | undefined,
api: Api | null,
poster: string | undefined,
url?: string | null
) {
const videoSource = useMemo(() => {
if (!item || !api || !url) {
return null;
}
const startPosition = item?.UserData?.PlaybackPositionTicks
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: item?.AlbumArtist ?? undefined,
title: item?.Name || "Unknown",
description: item?.Overview ?? undefined,
imageUri: poster,
subtitle: item?.Album ?? undefined,
},
};
}, [item, api, poster]);
return videoSource;
}

View File

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

View File

@@ -1,27 +1,212 @@
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
import { JellyfinProvider } from "@/providers/JellyfinProvider";
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
getOrSetDeviceId,
getTokenFromStorage,
JellyfinProvider,
} from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaybackProvider } from "@/providers/PlaybackProvider";
import { useSettings } from "@/utils/atoms/settings";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { orientationAtom } from "@/utils/atoms/orientation";
import { Settings, useSettings } from "@/utils/atoms/settings";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
import { LogProvider, writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import {
checkForExistingDownloads,
completeHandler,
download,
} from "@kesha-antonov/react-native-background-downloader";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as BackgroundFetch from "expo-background-fetch";
import * as FileSystem from "expo-file-system";
import { useFonts } from "expo-font";
import { useKeepAwake } from "expo-keep-awake";
import { Stack, useRouter } from "expo-router";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { router, Stack } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
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 { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated";
import * as Linking from "expo-linking";
import { orientationAtom } from "@/utils/atoms/orientation";
import { Toaster } from "sonner-native";
SplashScreen.preventAutoHideAsync();
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
function useNotificationObserver() {
useEffect(() => {
let isMounted = true;
function redirect(notification: Notifications.Notification) {
const url = notification.request.content.data?.url;
if (url) {
router.push(url);
}
}
Notifications.getLastNotificationResponseAsync().then((response) => {
if (!isMounted || !response?.notification) {
return;
}
redirect(response?.notification);
});
const subscription = Notifications.addNotificationResponseReceivedListener(
(response) => {
redirect(response.notification);
}
);
return () => {
isMounted = false;
subscription.remove();
};
}, []);
}
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger");
const now = Date.now();
const settingsData = storage.getString("settings");
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
const settings: Partial<Settings> = JSON.parse(settingsData);
const url = settings?.optimizedVersionsServerUrl;
if (!settings?.autoDownload || !url)
return BackgroundFetch.BackgroundFetchResult.NoData;
const token = getTokenFromStorage();
const deviceId = getOrSetDeviceId();
const baseDirectory = FileSystem.documentDirectory;
if (!token || !deviceId || !baseDirectory)
return BackgroundFetch.BackgroundFetchResult.NoData;
const jobs = await getAllJobsByDeviceId({
deviceId,
authHeader: token,
url,
});
console.log("TaskManager ~ Active jobs: ", jobs.length);
for (let job of jobs) {
if (job.status === "completed") {
const downloadUrl = url + "download/" + job.id;
const tasks = await checkForExistingDownloads();
if (tasks.find((task) => task.id === job.id)) {
console.log("TaskManager ~ Download already in progress: ", job.id);
continue;
}
download({
id: job.id,
url: downloadUrl,
destination: `${baseDirectory}${job.item.Id}.mp4`,
headers: {
Authorization: token,
},
})
.begin(() => {
console.log("TaskManager ~ Download started: ", job.id);
})
.done(() => {
console.log("TaskManager ~ Download completed: ", job.id);
saveDownloadedItemInfo(job.item);
completeHandler(job.id);
cancelJobById({
authHeader: token,
id: job.id,
url: url,
});
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download completed",
data: {
url: `/downloads`,
},
},
trigger: null,
});
})
.error((error) => {
console.log("TaskManager ~ Download error: ", job.id, error);
completeHandler(job.id);
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download failed",
data: {
url: `/downloads`,
},
},
trigger: null,
});
});
}
}
console.log(`Auto download started: ${new Date(now).toISOString()}`);
// Be sure to return the successful result type!
return BackgroundFetch.BackgroundFetchResult.NewData;
});
const checkAndRequestPermissions = async () => {
try {
const hasAskedBefore = storage.getString(
"hasAskedForNotificationPermission"
);
if (hasAskedBefore !== "true") {
const { status } = await Notifications.requestPermissionsAsync();
if (status === "granted") {
writeToLog("INFO", "Notification permissions granted.");
console.log("Notification permissions granted.");
} else {
writeToLog("ERROR", "Notification permissions denied.");
console.log("Notification permissions denied.");
}
storage.set("hasAskedForNotificationPermission", "true");
} else {
console.log("Already asked for notification permissions before.");
}
} catch (error) {
writeToLog(
"ERROR",
"Error checking/requesting notification permissions:",
error
);
console.error("Error checking/requesting notification permissions:", error);
}
};
export default function RootLayout() {
const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
@@ -33,6 +218,8 @@ export default function RootLayout() {
}
}, [loaded]);
Appearance.setColorScheme("dark");
if (!loaded) {
return null;
}
@@ -44,25 +231,28 @@ export default function RootLayout() {
);
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retryOnMount: true,
},
},
});
function Layout() {
const [settings, updateSettings] = useSettings();
const [orientation, setOrientation] = useAtom(orientationAtom);
useKeepAwake();
useNotificationObserver();
const queryClientRef = useRef<QueryClient>(
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retryOnMount: true,
},
},
})
);
useEffect(() => {
checkAndRequestPermissions();
}, []);
useEffect(() => {
if (settings?.autoRotate === true)
@@ -73,10 +263,28 @@ function Layout() {
);
}, [settings]);
const appState = useRef(AppState.currentState);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
checkForExistingDownloads();
}
});
checkForExistingDownloads();
return () => {
subscription.remove();
};
}, []);
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
console.log(event.orientationInfo.orientation);
setOrientation(event.orientationInfo.orientation);
}
);
@@ -98,36 +306,85 @@ function Layout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClientRef.current}>
<JobQueueProvider>
<ActionSheetProvider>
<BottomSheetModalProvider>
<JellyfinProvider>
<PlaybackProvider>
<StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
<CurrentlyPlayingBar />
</ThemeProvider>
</PlaybackProvider>
</JellyfinProvider>
</BottomSheetModalProvider>
</ActionSheetProvider>
</JobQueueProvider>
<QueryClientProvider client={queryClient}>
<ActionSheetProvider>
<JobQueueProvider>
<JellyfinProvider>
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="(auth)/player"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</JobQueueProvider>
</ActionSheetProvider>
</QueryClientProvider>
</GestureHandlerRootView>
);
}
function saveDownloadedItemInfo(item: BaseItemDto) {
try {
const downloadedItems = storage.getString("downloadedItems");
let items: BaseItemDto[] = downloadedItems
? JSON.parse(downloadedItems)
: [];
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
if (existingItemIndex !== -1) {
items[existingItemIndex] = item;
} else {
items.push(item);
}
storage.set("downloadedItems", JSON.stringify(items));
} catch (error) {
writeToLog("ERROR", "Failed to save downloaded item information:", error);
console.error("Failed to save downloaded item information:", error);
}
}

View File

@@ -3,6 +3,9 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
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 { useAtom } from "jotai";
import React, { useEffect, useState } from "react";
@@ -33,6 +36,7 @@ const Login: React.FC = () => {
} = params as { apiUrl: string; username: string; password: string };
const [serverURL, setServerURL] = useState<string>(_apiUrl);
const [serverName, setServerName] = useState<string>("");
const [error, setError] = useState<string>("");
const [credentials, setCredentials] = useState<{
username: string;
@@ -44,6 +48,8 @@ const Login: React.FC = () => {
useEffect(() => {
(async () => {
// we might re-use the checkUrl function here to check the url as well
// however, I don't think it should be necessary for now
if (_apiUrl) {
setServer({
address: _apiUrl,
@@ -79,12 +85,93 @@ const Login: React.FC = () => {
}
};
const handleConnect = (url: string) => {
if (!url.startsWith("http")) {
Alert.alert("Error", "URL needs to start with http or https.");
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
/**
* Checks the availability and validity of a Jellyfin server URL.
*
* This function attempts to connect to a Jellyfin server using the provided URL.
* It tries both HTTPS and HTTP protocols, with a timeout to handle long 404 responses.
*
* @param {string} url - The base URL of the Jellyfin server to check.
* @returns {Promise<string | undefined>} A Promise that resolves to:
* - The full URL (including protocol) if a valid Jellyfin server is found.
* - undefined if no valid server is found at the given URL.
*
* Side effects:
* - 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;
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);
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);
}
}
}
return undefined;
} finally {
setLoadingServerCheck(false);
}
}
/**
* Handles the connection attempt to a Jellyfin server.
*
* This function trims the input URL, checks its validity using the `checkUrl` function,
* and sets the server address if a valid connection is established.
*
* @param {string} url - The URL of the Jellyfin server to connect to.
*
* @returns {Promise<void>}
*
* Side effects:
* - Calls `checkUrl` to validate the server URL.
* - Shows an alert if the connection fails.
* - Sets the server address using `setServer` if the connection is successful.
*
*/
const handleConnect = async (url: string) => {
url = url.trim();
const result = await checkUrl(
url.startsWith("http") ? new URL(url).host : url
);
if (result === undefined) {
Alert.alert(
"Connection failed",
"Could not connect to the server. Please check the URL and your network connection."
);
return;
}
setServer({ address: url.trim() });
setServer({ address: result });
};
const handleQuickConnect = async () => {
@@ -109,19 +196,22 @@ const Login: React.FC = () => {
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1, height: "100%" }}
>
<View className="flex flex-col justify-between px-4 h-full gap-y-2">
<View></View>
<View>
<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-2">Streamyfin</Text>
<Text className="text-neutral-500 mb-2">
Server: {api.basePath}
<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();
setServerURL("");
}}
justify="between"
iconLeft={
@@ -138,9 +228,6 @@ const Login: React.FC = () => {
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold">Log in</Text>
<Text className="text-neutral-500">
Log in to any user account
</Text>
<Input
placeholder="Username"
onChangeText={(text) =>
@@ -177,11 +264,11 @@ const Login: React.FC = () => {
<Text className="text-red-600 mb-2">{error}</Text>
</View>
<View className="mt-auto mb-2">
<View className="absolute bottom-0 left-0 w-full px-4 mb-2">
<Button
color="black"
onPress={handleQuickConnect}
className="mb-2"
className="w-full mb-2"
>
Use Quick Connect
</Button>
@@ -199,11 +286,19 @@ const Login: React.FC = () => {
<SafeAreaView style={{ flex: 1 }}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
style={{ flex: 1, height: "100%" }}
>
<View className="flex flex-col px-4 justify-between h-full">
<View></View>
<View className="flex flex-col gap-y-2">
<View className="flex flex-col h-full relative items-center justify-center w-full">
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
}}
source={require("@/assets/images/StreamyFinFinal.png")}
/>
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="text-neutral-500">
Connect to your Jellyfin server
@@ -218,13 +313,17 @@ const Login: React.FC = () => {
textContentType="URL"
maxLength={500}
/>
<Text className="opacity-30">
Server URL requires http or https
</Text>
</View>
<Button onPress={() => handleConnect(serverURL)} className="mb-2">
Connect
</Button>
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)}
className="w-full grow"
>
Connect
</Button>
</View>
</View>
</KeyboardAvoidingView>
</SafeAreaView>

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
assets/icons/list.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 49 KiB

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,20 +1,13 @@
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
import { useSettings } from "@/utils/atoms/settings";
interface Props extends React.ComponentProps<typeof View> {
source: MediaSourceInfo;
source?: MediaSourceInfo;
onChange: (value: number) => void;
selected: number;
selected?: number | undefined;
}
export const AudioTrackSelector: React.FC<Props> = ({
@@ -23,10 +16,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
selected,
...props
}) => {
const [settings] = useSettings();
const audioStreams = useMemo(
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
[source]
);
@@ -35,24 +26,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
[audioStreams, selected]
);
useEffect(() => {
const defaultAudioIndex = audioStreams?.find(
(x) => x.Language === settings?.defaultAudioLanguage
)?.Index;
if (defaultAudioIndex !== undefined && defaultAudioIndex !== null) {
onChange(defaultAudioIndex);
return;
}
const index = source.DefaultAudioStreamIndex;
if (index !== undefined && index !== null) {
console.log("DefaultAudioStreamIndex", index);
onChange(index);
return;
}
onChange(0);
}, [audioStreams, settings]);
return (
<View
className="flex shrink"

View File

@@ -6,10 +6,9 @@ import { useMemo } from "react";
export type Bitrate = {
key: string;
value: number | undefined;
height?: number;
};
const BITRATES: Bitrate[] = [
export const BITRATES: Bitrate[] = [
{
key: "Max",
value: undefined,
@@ -27,24 +26,21 @@ const BITRATES: Bitrate[] = [
{
key: "2 Mb/s",
value: 2000000,
height: 720,
},
{
key: "500 Kb/s",
value: 500000,
height: 480,
},
{
key: "250 Kb/s",
value: 250000,
height: 480,
},
];
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
interface Props extends React.ComponentProps<typeof View> {
onChange: (value: Bitrate) => void;
selected: Bitrate;
inverted?: boolean;
selected?: Bitrate | null;
inverted?: boolean | null;
}
export const BitrateSelector: React.FC<Props> = ({
@@ -77,7 +73,7 @@ export const BitrateSelector: React.FC<Props> = ({
<Text className="opacity-50 mb-1 text-xs">Quality</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
{BITRATES.find((b) => b.value === selected.value)?.key}
{BITRATES.find((b) => b.value === selected?.value)?.key}
</Text>
</TouchableOpacity>
</View>

View File

@@ -1,8 +1,9 @@
import { Feather } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import React, { useEffect } from "react";
import React, { useCallback, useEffect } from "react";
import { Platform, TouchableOpacity, ViewProps } from "react-native";
import GoogleCast, {
CastButton,
CastContext,
useCastDevice,
useDevices,
@@ -39,18 +40,32 @@ export const Chromecast: React.FC<Props> = ({
})();
}, [client, devices, castDevice, sessionManager, discoveryManager]);
// Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback(
() =>
Platform.OS === "android" ? (
<CastButton tintColor="transparent" />
) : (
<></>
),
[Platform.OS]
);
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>
<>
<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")
@@ -82,6 +97,7 @@ export const Chromecast: React.FC<Props> = ({
>
<Feather name="cast" size={22} color={"white"} />
</BlurView>
<AndroidCastButton />
</TouchableOpacity>
);
};

View File

@@ -1,23 +1,24 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator";
import React from "react";
type ContinueWatchingPosterProps = {
item: BaseItemDto;
width?: number;
useEpisodePoster?: boolean;
size?: "small" | "normal";
};
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
item,
width = 176,
useEpisodePoster = false,
size = "normal",
}) => {
const [api] = useAtom(apiAtom);
const api = useAtomValue(apiAtom);
/**
* Get horrizontal poster for movie and episode, with failover to primary.
@@ -39,28 +40,38 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
else
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}
if (item.Type === "Program") {
if (item.ImageTags?.["Thumb"])
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
else
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}
}, [item]);
const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0
);
const progress = useMemo(() => {
if (item.Type === "Program") {
const startDate = new Date(item.StartDate || "");
const endDate = new Date(item.EndDate || "");
const now = new Date();
const total = endDate.getTime() - startDate.getTime();
const elapsed = now.getTime() - startDate.getTime();
return (elapsed / total) * 100;
} else {
return item.UserData?.PlayedPercentage || 0;
}
}, [item]);
if (!url)
return (
<View
className="aspect-video border border-neutral-800"
style={{
width,
}}
></View>
<View className="aspect-video border border-neutral-800 w-44"></View>
);
return (
<View
style={{
width,
}}
className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800"
className={`
relative w-44 aspect-video rounded-lg overflow-hidden border border-neutral-800
${size === "small" ? "w-32" : "w-44"}
`}
>
<Image
key={item.Id}
@@ -76,10 +87,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
{progress > 0 && (
<>
<View
style={{
width: `100%`,
}}
className={`absolute bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full`}
className={`absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full`}
></View>
<View
style={{

View File

@@ -1,313 +0,0 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { useRouter, useSegments } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import Video from "react-native-video";
import { Text } from "./common/Text";
import { Loader } from "./Loader";
export const CurrentlyPlayingBar: React.FC = () => {
const segments = useSegments();
const {
currentlyPlaying,
pauseVideo,
playVideo,
stopPlayback,
setVolume,
setIsPlaying,
isPlaying,
videoRef,
presentFullscreenPlayer,
onProgress,
onBuffer,
} = usePlayback();
const [api] = useAtom(apiAtom);
const aBottom = useSharedValue(0);
const aPadding = useSharedValue(0);
const aHeight = useSharedValue(100);
const router = useRouter();
const animatedOuterStyle = useAnimatedStyle(() => {
return {
bottom: withTiming(aBottom.value, { duration: 500 }),
height: withTiming(aHeight.value, { duration: 500 }),
padding: withTiming(aPadding.value, { duration: 500 }),
};
});
const aPaddingBottom = useSharedValue(30);
const aPaddingInner = useSharedValue(12);
const aBorderRadiusBottom = useSharedValue(12);
const animatedInnerStyle = useAnimatedStyle(() => {
return {
padding: withTiming(aPaddingInner.value, { duration: 500 }),
paddingBottom: withTiming(aPaddingBottom.value, { duration: 500 }),
borderBottomLeftRadius: withTiming(aBorderRadiusBottom.value, {
duration: 500,
}),
borderBottomRightRadius: withTiming(aBorderRadiusBottom.value, {
duration: 500,
}),
};
});
const from = useMemo(() => segments[2], [segments]);
useEffect(() => {
if (segments.find((s) => s.includes("tabs"))) {
// Tab screen - i.e. home
aBottom.value = Platform.OS === "ios" ? 78 : 50;
aHeight.value = 80;
aPadding.value = 8;
aPaddingBottom.value = 8;
aPaddingInner.value = 8;
} else {
// Inside a normal screen
aBottom.value = Platform.OS === "ios" ? 0 : 0;
aHeight.value = Platform.OS === "ios" ? 110 : 80;
aPadding.value = Platform.OS === "ios" ? 0 : 8;
aPaddingInner.value = Platform.OS === "ios" ? 12 : 8;
aPaddingBottom.value = Platform.OS === "ios" ? 40 : 12;
}
}, [segments]);
const startPosition = useMemo(
() =>
currentlyPlaying?.item?.UserData?.PlaybackPositionTicks
? Math.round(
currentlyPlaying?.item.UserData.PlaybackPositionTicks / 10000
)
: 0,
[currentlyPlaying?.item]
);
const poster = useMemo(() => {
if (currentlyPlaying?.item.Type === "Audio")
return `${api?.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`;
else
return getBackdropUrl({
api,
item: currentlyPlaying?.item,
quality: 70,
width: 200,
});
}, [currentlyPlaying?.item.Id, api]);
const videoSource = useMemo(() => {
if (!api || !currentlyPlaying || !poster) return null;
return {
uri: currentlyPlaying.url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: currentlyPlaying.item?.AlbumArtist
? currentlyPlaying.item?.AlbumArtist
: undefined,
title: currentlyPlaying.item?.Name || "Unknown",
description: currentlyPlaying.item?.Overview
? currentlyPlaying.item?.Overview
: undefined,
imageUri: poster,
subtitle: currentlyPlaying.item?.Album
? currentlyPlaying.item?.Album
: undefined,
},
};
}, [currentlyPlaying, startPosition, api, poster]);
if (!api || !currentlyPlaying) return null;
return (
<Animated.View
style={[animatedOuterStyle]}
className="absolute left-0 w-screen"
>
<BlurView
intensity={Platform.OS === "android" ? 60 : 100}
experimentalBlurMethod={Platform.OS === "android" ? "none" : undefined}
className={`h-full w-full rounded-xl overflow-hidden ${
Platform.OS === "android" && "bg-black"
}`}
>
<Animated.View
style={[
{ padding: 8, borderTopLeftRadius: 12, borderTopEndRadius: 12 },
animatedInnerStyle,
]}
className="h-full w-full flex flex-row items-center justify-between overflow-hidden"
>
<View className="flex flex-row items-center space-x-4 shrink">
<TouchableOpacity
onPress={() => {
videoRef.current?.presentFullscreenPlayer();
}}
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
${
currentlyPlaying.item?.Type === "Audio"
? "aspect-square"
: "aspect-video"
}
`}
>
{videoSource && (
<Video
ref={videoRef}
allowsExternalPlayback
style={{ width: "100%", height: "100%" }}
playWhenInactive={true}
playInBackground={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
controls={false}
pictureInPicture={true}
poster={
poster && currentlyPlaying.item?.Type === "Audio"
? poster
: undefined
}
debug={{
enable: true,
thread: true,
}}
onIdle={() => {
console.log("IDLE");
}}
fullscreenAutorotate={true}
onReadyForDisplay={() => {
console.log("READY FOR DISPLAY");
}}
onProgress={(e) => onProgress(e)}
subtitleStyle={{
fontSize: 16,
}}
onBuffer={(e) => onBuffer(e.isBuffering)}
source={videoSource}
onRestoreUserInterfaceForPictureInPictureStop={() => {
setTimeout(() => {
presentFullscreenPlayer();
}, 300);
}}
onPlaybackStateChanged={(e) => {
if (e.isPlaying === true) {
playVideo(false);
} else if (e.isPlaying === false) {
pauseVideo(false);
}
}}
onVolumeChange={(e) => {
setVolume(e.volume);
}}
progressUpdateInterval={4000}
onError={(e) => {
console.log(e);
writeToLog(
"ERROR",
"Video playback error: " + JSON.stringify(e)
);
Alert.alert("Error", "Cannot play this video file.");
setIsPlaying(false);
// setCurrentlyPlaying(null);
}}
renderLoader={
currentlyPlaying.item?.Type !== "Audio" && (
<View className="flex flex-col items-center justify-center h-full">
<Loader />
</View>
)
}
/>
)}
</TouchableOpacity>
<View className="shrink text-xs">
<TouchableOpacity
onPress={() => {
if (currentlyPlaying.item?.Type === "Audio") {
router.push(
// @ts-ignore
`/(auth)/(tabs)/${from}/albums/${currentlyPlaying.item.AlbumId}`
);
} else {
router.push(
// @ts-ignore
`/(auth)/(tabs)/${from}/items/page?id=${currentlyPlaying.item?.Id}`
);
}
}}
>
<Text>{currentlyPlaying.item?.Name}</Text>
</TouchableOpacity>
{currentlyPlaying.item?.Type === "Episode" && (
<TouchableOpacity
onPress={() => {
router.push(
// @ts-ignore
`/(auth)/(tabs)/${from}/series/${currentlyPlaying.item.SeriesId}`
);
}}
className="text-xs opacity-50"
>
<Text>{currentlyPlaying.item.SeriesName}</Text>
</TouchableOpacity>
)}
{currentlyPlaying.item?.Type === "Movie" && (
<View>
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.ProductionYear}
</Text>
</View>
)}
{currentlyPlaying.item?.Type === "Audio" && (
<TouchableOpacity
onPress={() => {
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
}}
>
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.Album}
</Text>
</TouchableOpacity>
)}
</View>
</View>
<View className="flex flex-row items-center space-x-2">
<TouchableOpacity
onPress={() => {
if (isPlaying) pauseVideo();
else playVideo();
}}
className="aspect-square rounded flex flex-col items-center justify-center p-2"
>
{isPlaying ? (
<Ionicons name="pause" size={24} color="white" />
) : (
<Ionicons name="play" size={24} color="white" />
)}
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
stopPlayback();
}}
className="aspect-square rounded flex flex-col items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
</Animated.View>
</BlurView>
</Animated.View>
);
};

View File

@@ -1,11 +1,12 @@
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runningProcesses } from "@/utils/atoms/downloads";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
import download from "@/utils/profiles/download";
import Ionicons from "@expo/vector-icons/Ionicons";
import {
BottomSheetBackdrop,
@@ -17,12 +18,11 @@ import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import {Href, router, useFocusEffect} from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
import { toast } from "sonner-native";
import { AudioTrackSelector } from "./AudioTrackSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button";
@@ -33,30 +33,34 @@ import ProgressCircle from "./ProgressCircle";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
interface DownloadProps extends ViewProps {
item: BaseItemDto;
items: BaseItemDto[];
MissingDownloadIconComponent: () => React.ReactElement;
DownloadedIconComponent: () => React.ReactElement;
}
export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
export const DownloadItems: React.FC<DownloadProps> = ({
items,
MissingDownloadIconComponent,
DownloadedIconComponent,
...props
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [process] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
const { startRemuxing } = useRemuxHlsToMp4(item);
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
const { startRemuxing } = useRemuxHlsToMp4();
const [selectedMediaSource, setSelectedMediaSource] =
useState<MediaSourceInfo | null>(null);
const [selectedMediaSource, setSelectedMediaSource] = useState<MediaSourceInfo | undefined | null>(undefined);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [selectedSubtitleStream, setSelectedSubtitleStream] = useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
key: "Max",
value: undefined,
});
const userCanDownload = useMemo(() => {
return user?.Policy?.EnableContentDownloading;
}, [user]);
const userCanDownload = useMemo(() => user?.Policy?.EnableContentDownloading, [user]);
const usingOptimizedServer = useMemo(() => settings?.downloadMethod === "optimized", [settings]);
/**
* Bottom sheet
@@ -67,123 +71,160 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
bottomSheetModalRef.current?.present();
}, []);
const handleSheetChanges = useCallback((index: number) => {
console.log("handleSheetChanges", index);
}, []);
const handleSheetChanges = useCallback((index: number) => {}, []);
const closeModal = useCallback(() => {
bottomSheetModalRef.current?.dismiss();
}, []);
// region computed
const itemIds = useMemo(() => items.map(i => i.Id), [items]);
const pendingItems = 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 itemsProcesses = useMemo(() =>
processes?.filter(p => itemIds.includes(p.item.Id)),
[processes, itemIds]
);
const progress = useMemo(() => {
if (itemIds.length == 1)
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0)
return ((itemIds.length - queue.filter(q => itemIds.includes(q.item.Id)).length) / itemIds.length) * 100
},
[queue, itemsProcesses, itemIds]
);
const itemsQueued = useMemo(() => {
return pendingItems.length > 0 && pendingItems.every(p => queue.some(q => p.Id == q.item.Id))
},
[queue, pendingItems]
);
// endregion computed
// region helper functions
const navigateToDownloads = () => router.push("/downloads");
const onDownloadedPress = () => {
const firstItem = items?.[0]
router.push(
firstItem.Type !== "Episode"
? "/downloads"
: {
pathname: `/downloads/${firstItem.SeriesId}`,
params: {
episodeSeasonIndex: firstItem.ParentIndexNumber
}
} as Href
);
}
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (pendingItems.some(i => !i.Id)) {
throw new Error("No item id");
}
closeModal();
if (usingOptimizedServer)
initiateDownload(...pendingItems);
else {
queueActions.enqueue(
queue,
setQueue,
...pendingItems.map(item => ({
id: item.Id!,
execute: async () => await initiateDownload(item),
item,
}))
)
}
} else {
toast.error("You are not allowed to download files.");
}
}, [
queue,
setQueue,
pendingItems,
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 () => {
if (!api || !user?.Id || !item.Id || !selectedMediaSource?.Id) {
throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item"
);
const initiateDownload = useCallback(async (...items: BaseItemDto[]) => {
if (!api || !user?.Id || items.some(p => !p.Id) || (pendingItems.length === 1 && !selectedMediaSource?.Id)) {
throw new Error("DownloadItem ~ initiateDownload: No api or user or item");
}
let mediaSource = selectedMediaSource
let audioIndex: number | undefined = selectedAudioStream
let subtitleIndex: number | undefined = selectedSubtitleStream
let deviceProfile: any = ios;
if (settings?.deviceProfile === "Native") {
deviceProfile = native;
} else if (settings?.deviceProfile === "Old") {
deviceProfile = old;
}
const response = await api.axiosInstance.post(
`${api.basePath}/Items/${item.Id}/PlaybackInfo`,
{
DeviceProfile: deviceProfile,
UserId: user.Id,
MaxStreamingBitrate: maxBitrate.value,
StartTimeTicks: 0,
EnableTranscoding: maxBitrate.value ? true : undefined,
AutoOpenLiveStream: true,
AllowVideoStreamCopy: maxBitrate.value ? false : true,
MediaSourceId: selectedMediaSource?.Id,
AudioStreamIndex: selectedAudioStream,
SubtitleStreamIndex: selectedSubtitleStream,
},
{
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
for (const item of items) {
if (pendingItems.length > 1) {
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(item, settings!));
}
);
let url: string | undefined = undefined;
const res = await getStreamUrl({
api,
item,
startTimeTicks: 0,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: maxBitrate.value,
mediaSourceId: mediaSource?.Id,
subtitleStreamIndex: subtitleIndex,
deviceProfile: download,
});
const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
(source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id
);
if (!mediaSource) {
throw new Error("No media source");
}
if (mediaSource.SupportsDirectPlay) {
if (item.MediaType === "Video") {
console.log("Using direct stream for video!");
url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
} else if (item.MediaType === "Audio") {
console.log("Using direct stream for audio!");
const searchParams = new URLSearchParams({
UserId: user.Id,
DeviceId: api.deviceInfo.id,
MaxStreamingBitrate: "140000000",
Container:
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
TranscodingContainer: "mp4",
TranscodingProtocol: "hls",
AudioCodec: "aac",
api_key: api.accessToken,
StartTimeTicks: "0",
EnableRedirection: "true",
EnableRemoteMedia: "false",
});
url = `${api.basePath}/Audio/${
item.Id
}/universal?${searchParams.toString()}`;
if (!res) {
Alert.alert(
"Something went wrong",
"Could not get stream url from Jellyfin"
);
continue;
}
const {mediaSource: source, url} = res;
if (!url || !source) throw new Error("No url");
saveDownloadItemInfoToDiskTmp(item, source, url);
if (usingOptimizedServer) {
await startBackgroundDownload(url, item, source);
} else {
await startRemuxing(item, url, source);
}
} else if (mediaSource.TranscodingUrl) {
console.log("Using transcoded stream!");
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
}
if (!url) throw new Error("No url");
return await startRemuxing(url);
}, [
api,
item,
startRemuxing,
user?.Id,
pendingItems,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
settings,
maxBitrate,
usingOptimizedServer,
startBackgroundDownload,
startRemuxing,
]);
/**
* Check if item is downloaded
*/
const { data: downloaded, isFetching } = useQuery({
queryKey: ["downloaded", item.Id],
queryFn: async () => {
if (!item.Id) return false;
const data: BaseItemDto[] = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]"
);
return data.some((d) => d.Id === item.Id);
},
enabled: !!item.Id,
});
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
@@ -194,27 +235,38 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
),
[]
);
// endregion helper functions
// Allow to select & set settings for single download
useFocusEffect(
useCallback(() => {
if (!settings) return;
if (pendingItems.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])
);
return (
<View
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
{...props}
>
{isFetching ? (
<Loader />
) : process && process?.item.Id === item.Id ? (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
{process.progress === 0 ? (
{processes && itemsProcesses.length > 0 ? (
<TouchableOpacity onPress={navigateToDownloads}>
{progress === 0 ? (
<Loader />
) : (
<View className="-rotate-45">
<ProgressCircle
size={24}
fill={process.progress}
fill={progress}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
@@ -222,25 +274,17 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
</View>
)}
</TouchableOpacity>
) : queue.some((i) => i.id === item.Id) ? (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
) : itemsQueued ? (
<TouchableOpacity onPress={navigateToDownloads}>
<Ionicons name="hourglass" size={24} color="white" />
</TouchableOpacity>
) : downloaded ? (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<Ionicons name="cloud-download" size={26} color="#9333ea" />
) : isDownloaded ? (
<TouchableOpacity onPress={onDownloadedPress}>
{DownloadedIconComponent()}
</TouchableOpacity>
) : (
<TouchableOpacity onPress={handlePresentModalPress}>
<Ionicons name="cloud-download-outline" size={24} color="white" />
{MissingDownloadIconComponent()}
</TouchableOpacity>
)}
<BottomSheetModal
@@ -263,55 +307,62 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
<View className="flex flex-col space-y-2 w-full items-start">
<BitrateSelector
inverted
onChange={(val) => setMaxBitrate(val)}
onChange={setMaxBitrate}
selected={maxBitrate}
/>
<MediaSourceSelector
item={item}
onChange={setSelectedMediaSource}
selected={selectedMediaSource}
/>
{selectedMediaSource && (
<View className="flex flex-col space-y-2">
<AudioTrackSelector
source={selectedMediaSource}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
{pendingItems.length === 1 && (
<>
<MediaSourceSelector
item={items[0]}
onChange={setSelectedMediaSource}
selected={selectedMediaSource}
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
{selectedMediaSource && (
<View className="flex flex-col space-y-2">
<AudioTrackSelector
source={selectedMediaSource}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
)}
</>
)}
</View>
<Button
className="mt-auto"
onPress={() => {
if (userCanDownload === true) {
closeModal();
queueActions.enqueue(queue, setQueue, {
id: item.Id!,
execute: async () => {
await initiateDownload();
},
item,
});
} else {
Alert.alert(
"Disabled",
"This user is not allowed to download files."
);
}
}}
onPress={acceptDownloadOptions}
color="purple"
>
Download
</Button>
<View className="opacity-70 text-center w-full flex items-center">
<Text className="text-xs">
{usingOptimizedServer ? "Using optimized server" : "Using default method"}
</Text>
</View>
</View>
</BottomSheetView>
</BottomSheetModal>
</View>
);
};
export const DownloadSingleItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return (
<DownloadItems
items={[item]}
MissingDownloadIconComponent={() => (
<Ionicons name="cloud-download-outline" size={24} color="white" />
)}
DownloadedIconComponent={() => (
<Ionicons name="cloud-download" size={26} color="#9333ea" />
)}
/>
)
}

22
components/GenreTags.tsx Normal file
View File

@@ -0,0 +1,22 @@
// GenreTags.tsx
import React from "react";
import { View } from "react-native";
import { Text } from "./common/Text";
interface GenreTagsProps {
genres?: string[];
}
export const GenreTags: React.FC<GenreTagsProps> = ({ genres }) => {
if (!genres || genres.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>
))}
</View>
);
};

View File

@@ -10,7 +10,7 @@ type ItemCardProps = {
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
return (
<View className="mt-2 flex flex-col h-12">
<View className="mt-2 flex flex-col">
{item.Type === "Episode" ? (
<>
<Text numberOfLines={2} className="">

View File

@@ -1,6 +1,6 @@
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { DownloadItem } from "@/components/DownloadItem";
import {DownloadSingleItem} from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlayButton } from "@/components/PlayButton";
@@ -11,363 +11,250 @@ import { ItemImage } from "@/components/common/ItemImage";
import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getItemImage } from "@/utils/getItemImage";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { View } from "react-native";
import { useCastDevice } from "react-native-google-cast";
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader";
import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
export type SelectedOptions = {
bitrate: Bitrate;
mediaSource: MediaSourceInfo | undefined;
audioIndex: number | undefined;
subtitleIndex: number;
};
const opacity = useSharedValue(0);
const castDevice = useCastDevice();
const navigation = useNavigation();
const [settings] = useSettings();
const [selectedMediaSource, setSelectedMediaSource] =
useState<MediaSourceInfo | null>(null);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
key: "Max",
value: undefined,
});
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
({ item }) => {
const [api] = useAtom(apiAtom);
const [settings] = useSettings();
const { orientation } = useOrientation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
useImageColors({ item });
const [loadingLogo, setLoadingLogo] = useState(true);
const [loadingLogo, setLoadingLogo] = useState(true);
const [headerHeight, setHeaderHeight] = useState(350);
const [orientation, setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP
);
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
>(undefined);
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
setOrientation(event.orientationInfo.orientation);
}
);
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(item, settings);
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
setOrientation(initialOrientation);
});
// Needs to automatically change the selected to the default values for default indexes.
useEffect(() => {
console.log(defaultAudioIndex, defaultSubtitleIndex);
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
}, [
defaultAudioIndex,
defaultBitrate,
defaultSubtitleIndex,
defaultMediaSource,
]);
return () => {
ScreenOrientation.removeOrientationChangeListener(subscription);
};
}, []);
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: opacity.value,
};
});
const fadeIn = () => {
opacity.value = withTiming(1, { duration: 300 });
};
const fadeOut = (callback: any) => {
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
};
const headerHeightRef = useRef(400);
const {
data: item,
isLoading,
isFetching,
} = useQuery({
queryKey: ["item", id],
queryFn: async () => {
const res = await getUserItemData({
api,
userId: user?.Id,
itemId: id,
});
return res;
},
enabled: !!id && !!api,
staleTime: 60 * 1000 * 5,
});
const [localItem, setLocalItem] = useState(item);
useEffect(() => {
if (item) {
if (localItem) {
// Fade out current item
fadeOut(() => {
// Update local item after fade out
setLocalItem(item);
// Then fade in
fadeIn();
});
} else {
// If there's no current item, just set and fade in
setLocalItem(item);
fadeIn();
}
} else {
// If item is null, fade out and clear local item
fadeOut(() => setLocalItem(null));
}
}, [item]);
useEffect(() => {
navigation.setOptions({
headerRight: () =>
item && (
<View className="flex flex-row items-center space-x-2">
<Chromecast background="blur" width={22} height={22} />
<DownloadItem item={item} />
<PlayedStatus item={item} />
</View>
),
});
}, [item]);
useEffect(() => {
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
headerHeightRef.current = 230;
return;
}
if (item?.Type === "Episode") headerHeightRef.current = 400;
else if (item?.Type === "Movie") headerHeightRef.current = 500;
else headerHeightRef.current = 400;
}, [item]);
const { data: sessionData } = useQuery({
queryKey: ["sessionData", item?.Id],
queryFn: async () => {
if (!api || !user?.Id || !item?.Id) return null;
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: item?.Id,
userId: user?.Id,
});
return playbackData.data;
},
enabled: !!item?.Id && !!api && !!user?.Id,
staleTime: 0,
});
const { data: playbackUrl } = useQuery({
queryKey: [
"playbackUrl",
item?.Id,
maxBitrate,
castDevice,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
settings,
],
queryFn: async () => {
if (!api || !user?.Id || !sessionData || !selectedMediaSource?.Id)
return null;
let deviceProfile: any = ios;
if (castDevice?.deviceId) {
deviceProfile = chromecastProfile;
} else if (settings?.deviceProfile === "Native") {
deviceProfile = native;
} else if (settings?.deviceProfile === "Old") {
deviceProfile = old;
}
const url = await getStreamUrl({
api,
userId: user.Id,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
maxStreamingBitrate: maxBitrate.value,
sessionData,
deviceProfile,
audioStreamIndex: selectedAudioStream,
subtitleStreamIndex: selectedSubtitleStream,
forceDirectPlay: settings?.forceDirectPlay,
height: maxBitrate.height,
mediaSourceId: selectedMediaSource.Id,
});
console.info("Stream URL:", url);
return url;
},
enabled: !!sessionData && !!api && !!user?.Id && !!item?.Id,
staleTime: 0,
});
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
const themeImageColorSource = useMemo(() => {
if (!api || !item) return;
return getItemImage({
item,
api,
variant: "Primary",
quality: 80,
width: 300,
});
}, [api, item]);
useImageColors(themeImageColorSource?.uri);
const loading = useMemo(() => {
return Boolean(isLoading || isFetching || (logoUrl && loadingLogo));
}, [isLoading, isFetching, loadingLogo, logoUrl]);
const insets = useSafeAreaInsets();
return (
<View
className="flex-1 relative"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
{loading && (
<View className="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex flex-col justify-center items-center z-50">
<Loader />
</View>
)}
<ParallaxScrollView
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
headerHeight={headerHeightRef.current}
headerImage={
<>
<Animated.View style={[animatedStyle, { flex: 1 }]}>
{localItem && (
<ItemImage
useThemeColor
variant={
localItem.Type === "Movie" && logoUrl
? "Backdrop"
: "Primary"
}
item={localItem}
style={{
width: "100%",
height: "100%",
}}
/>
useEffect(() => {
navigation.setOptions({
headerRight: () =>
item && (
<View className="flex flex-row items-center space-x-2">
<Chromecast background="blur" width={22} height={22} />
{item.Type !== "Program" && (
<View className="flex flex-row items-center space-x-2">
<DownloadSingleItem item={item} />
<PlayedStatus item={item} />
</View>
)}
</Animated.View>
</>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
onLoad={() => setLoadingLogo(false)}
onError={() => setLoadingLogo(false)}
/>
) : null}
</>
}
</View>
),
});
}, [item]);
useEffect(() => {
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
setHeaderHeight(230);
else if (item.Type === "Movie") setHeaderHeight(500);
else setHeaderHeight(350);
}, [item.Type, orientation]);
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
if (!selectedOptions) return null;
return (
<View
className="flex-1 relative"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View className="flex flex-col bg-transparent shrink">
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
<Animated.View style={[animatedStyle, { flex: 1 }]}>
<ItemHeader item={localItem} className="mb-4" />
{localItem ? (
<ParallaxScrollView
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
headerHeight={headerHeight}
headerImage={
<View style={[{ flex: 1 }]}>
<ItemImage
variant={
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
}
item={item}
style={{
width: "100%",
height: "100%",
}}
/>
</View>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
onLoad={() => setLoadingLogo(false)}
onError={() => setLoadingLogo(false)}
/>
) : null}
</>
}
>
<View className="flex flex-col bg-transparent shrink">
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
<ItemHeader item={item} className="mb-4" />
{item.Type !== "Program" && (
<View className="flex flex-row items-center justify-start w-full h-16">
<BitrateSelector
className="mr-1"
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
onChange={(val) =>
setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val }
)
}
selected={selectedOptions.bitrate}
/>
<MediaSourceSelector
className="mr-1"
item={localItem}
onChange={setSelectedMediaSource}
selected={selectedMediaSource}
item={item}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
mediaSource: val,
}
)
}
selected={selectedOptions.mediaSource}
/>
<AudioTrackSelector
className="mr-1"
source={selectedOptions.mediaSource}
onChange={(val) => {
console.log(val);
setSelectedOptions(
(prev) =>
prev && {
...prev,
audioIndex: val,
}
);
}}
selected={selectedOptions.audioIndex}
/>
<SubtitleTrackSelector
source={selectedOptions.mediaSource}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
subtitleIndex: val,
}
)
}
selected={selectedOptions.subtitleIndex}
/>
{selectedMediaSource && (
<>
<AudioTrackSelector
className="mr-1"
source={selectedMediaSource}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</>
)}
</View>
) : (
<View className="h-16">
<View className="bg-neutral-900 h-4 w-2/4 rounded-md mb-1"></View>
<View className="bg-neutral-900 h-10 w-3/4 rounded-lg"></View>
</View>
)}
</Animated.View>
<PlayButton item={item} url={playbackUrl} className="grow" />
<PlayButton
className="grow"
selectedOptions={selectedOptions}
item={item}
/>
</View>
{item.Type === "Episode" && (
<SeasonEpisodesCarousel item={item} loading={loading} />
)}
<OverviewText text={item.Overview} className="px-4 my-4" />
{item.Type !== "Program" && (
<>
<CastAndCrew item={item} className="mb-4" loading={loading} />
{item.People && item.People.length > 0 && (
<View className="mb-4">
{item.People.slice(0, 3).map((person, idx) => (
<MoreMoviesWithActor
currentItem={item}
key={idx}
actorId={person.Id!}
className="mb-4"
/>
))}
</View>
)}
{item.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" />
)}
<SimilarItems itemId={item.Id} />
</>
)}
<View className="h-16"></View>
</View>
{item?.Type === "Episode" && (
<SeasonEpisodesCarousel item={item} loading={loading} />
)}
<OverviewText text={item?.Overview} className="px-4 mb-4" />
<CastAndCrew item={item} className="mb-4" loading={loading} />
{item?.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" />
)}
<SimilarItems itemId={item?.Id} />
<View className="h-16"></View>
</View>
</ParallaxScrollView>
</View>
);
});
</ParallaxScrollView>
</View>
);
}
);

View File

@@ -3,6 +3,8 @@ import { View, ViewProps } from "react-native";
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
import { Ratings } from "./Ratings";
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
import { GenreTags } from "./GenreTags";
import React from "react";
interface Props extends ViewProps {
item?: BaseItemDto | null;
@@ -12,7 +14,7 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
if (!item)
return (
<View
className="flex flex-col space-y-1.5 w-full items-start h-24"
className="flex flex-col space-y-1.5 w-full items-start h-32"
{...props}
>
<View className="w-1/3 h-6 bg-neutral-900 rounded" />
@@ -23,16 +25,22 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
);
return (
<View
style={{
minHeight: 96,
}}
className="flex flex-col"
{...props}
>
<Ratings item={item} className="mb-2" />
{item.Type === "Episode" && <EpisodeTitleHeader item={item} />}
{item.Type === "Movie" && <MoviesTitleHeader item={item} />}
<View className="flex flex-col" {...props}>
<View className="flex flex-col" {...props}>
<Ratings item={item} className="mb-2" />
{item.Type === "Episode" && (
<>
<EpisodeTitleHeader item={item} />
<GenreTags genres={item.Genres!} />
</>
)}
{item.Type === "Movie" && (
<>
<MoviesTitleHeader item={item} />
<GenreTags genres={item.Genres!} />
</>
)}
</View>
</View>
);
};

View File

@@ -1,19 +0,0 @@
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { PropsWithChildren } from "react";
interface Props extends ViewProps {}
export const List: React.FC<PropsWithChildren<Props>> = ({
children,
...props
}) => {
return (
<View
className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800"
{...props}
>
{children}
</View>
);
};

View File

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

View File

@@ -7,11 +7,12 @@ import { useEffect, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
onChange: (value: MediaSourceInfo) => void;
selected: MediaSourceInfo | null;
selected?: MediaSourceInfo | null;
}
export const MediaSourceSelector: React.FC<Props> = ({
@@ -20,30 +21,14 @@ export const MediaSourceSelector: React.FC<Props> = ({
selected,
...props
}) => {
const mediaSources = useMemo(() => {
return item.MediaSources;
}, [item]);
const selectedMediaSource = useMemo(
const selectedName = useMemo(
() =>
mediaSources
?.find((x) => x.Id === selected?.Id)
?.MediaStreams?.find((x) => x.Type === "Video")?.DisplayTitle || "",
[mediaSources, selected]
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
(x) => x.Type === "Video"
)?.DisplayTitle || "",
[item, selected]
);
useEffect(() => {
if (mediaSources?.length) onChange(mediaSources[0]);
}, [mediaSources]);
const name = (name?: string | null) => {
if (name && name.length > 40)
return (
name.substring(0, 20) + " [...] " + name.substring(name.length - 20)
);
return name;
};
return (
<View
className="flex shrink"
@@ -55,8 +40,8 @@ export const MediaSourceSelector: React.FC<Props> = ({
<DropdownMenu.Trigger>
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">Video</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center ">
<Text numberOfLines={1}>{selectedMediaSource}</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
<Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
@@ -70,7 +55,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
sideOffset={8}
>
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
{mediaSources?.map((source, idx: number) => (
{item.MediaSources?.map((source, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
@@ -78,7 +63,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
}}
>
<DropdownMenu.ItemTitle>
{name(source.Name)}
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
source.Size
)}`}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
@@ -87,3 +74,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
</View>
);
};
const name = (name?: string | null) => {
if (name && name.length > 40)
return name.substring(0, 20) + " [...] " + name.substring(name.length - 20);
return name;
};

View File

@@ -0,0 +1,100 @@
import React from "react";
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import MoviePoster from "@/components/posters/MoviePoster";
import { ItemCardText } from "@/components/ItemCardText";
import { useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
interface Props extends ViewProps {
actorId: string;
currentItem: BaseItemDto;
}
export const MoreMoviesWithActor: React.FC<Props> = ({
actorId,
currentItem,
...props
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: actor } = useQuery({
queryKey: ["actor", actorId],
queryFn: async () => {
if (!api || !user?.Id) return null;
return await getUserItemData({
api,
userId: user.Id,
itemId: actorId,
});
},
enabled: !!api && !!user?.Id && !!actorId,
});
const { data: items, isLoading } = useQuery({
queryKey: ["actor", "movies", actorId, currentItem.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
personIds: [actorId],
limit: 20,
sortOrder: ["Descending"],
includeItemTypes: ["Movie", "Series"],
recursive: true,
fields: ["ParentId", "PrimaryImageAspectRatio"],
sortBy: ["PremiereDate"],
collapseBoxSetItems: false,
excludeItemIds: [currentItem.SeriesId || "", currentItem.Id || ""],
});
// Remove duplicates based on item ID
const uniqueItems =
response.data.Items?.reduce((acc, current) => {
const x = acc.find((item) => item.Id === current.Id);
if (!x) {
return acc.concat([current]);
} else {
return acc;
}
}, [] as BaseItemDto[]) || [];
return uniqueItems;
},
enabled: !!api && !!user?.Id && !!actorId,
});
if (items?.length === 0) return null;
return (
<View {...props}>
<Text className="text-lg font-bold mb-2 px-4">
More with {actor?.Name}
</Text>
<HorizontalScroll
data={items}
loading={isLoading}
height={247}
renderItem={(item: BaseItemDto, idx: number) => (
<TouchableItemRouter
key={idx}
item={item}
className="flex flex-col w-28"
>
<View>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
)}
/>
</View>
);
};

View File

@@ -1,37 +0,0 @@
import React, { useEffect, useRef } from "react";
import Video, { VideoRef } from "react-native-video";
type VideoPlayerProps = {
url: string;
};
export const OfflineVideoPlayer: React.FC<VideoPlayerProps> = ({ url }) => {
const videoRef = useRef<VideoRef | null>(null);
const onError = (error: any) => {
console.error("Video Error: ", error);
};
useEffect(() => {
if (videoRef.current) {
videoRef.current.resume();
}
setTimeout(() => {
if (videoRef.current) {
videoRef.current.presentFullscreenPlayer();
}
}, 500);
}, []);
return (
<Video
source={{
uri: url,
isNetwork: false,
}}
ref={videoRef}
onError={onError}
ignoreSilentSwitch="ignore"
/>
);
};

View File

@@ -19,7 +19,7 @@ export const OverviewText: React.FC<Props> = ({
return (
<View className="flex flex-col" {...props}>
<Text className="text-xl font-bold mb-2">Overview</Text>
<Text className="text-lg font-bold mb-2">Overview</Text>
<TouchableOpacity
onPress={() =>
setLimit((prev) =>

View File

@@ -0,0 +1,35 @@
import { BlurView } from "expo-blur";
import React from "react";
import { Platform, View, ViewProps } from "react-native";
interface Props extends ViewProps {
blurAmount?: number;
blurType?: "light" | "dark" | "xlight";
}
/**
* BlurView for iOS and simple View for Android
*/
export const PlatformBlurView: React.FC<Props> = ({
blurAmount = 100,
blurType = "light",
style,
children,
...props
}) => {
if (Platform.OS === "ios") {
return (
<BlurView style={style} intensity={blurAmount} {...props}>
{children}
</BlurView>
);
}
return (
<View
style={[{ backgroundColor: "rgba(50, 50, 50, 0.9)" }, style]}
{...props}
>
{children}
</View>
);
};

View File

@@ -1,16 +1,20 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import ios from "@/utils/profiles/ios";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
import { Alert, TouchableOpacity, View } from "react-native";
import CastContext, {
CastButton,
PlayServicesState,
useMediaStatus,
useRemoteMediaClient,
@@ -26,45 +30,69 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { Button } from "./Button";
import { Text } from "./common/Text";
import { SelectedOptions } from "./ItemContent";
import { chromecastProfile } from "@/utils/profiles/chromecast";
interface Props extends React.ComponentProps<typeof Button> {
item?: BaseItemDto | null;
url?: string | null;
item: BaseItemDto;
selectedOptions: SelectedOptions;
}
const ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
export const PlayButton: React.FC<Props> = ({
item,
selectedOptions,
...props
}: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const { setCurrentlyPlayingState } = usePlayback();
const mediaStatus = useMediaStatus();
const [colorAtom] = useAtom(itemThemeColorAtom);
const [api] = useAtom(apiAtom);
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
const router = useRouter();
const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0);
const endColor = useSharedValue(memoizedColor);
const startColor = useSharedValue(memoizedColor);
const endColor = useSharedValue(colorAtom);
const startColor = useSharedValue(colorAtom);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const [settings] = useSettings();
const directStream = useMemo(() => {
return !url?.includes("m3u8");
}, []);
const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => {
if (!bitrateValue) {
router.push(`/player/direct-player?${q}`);
return;
}
router.push(`/player/transcoding-player?${q}`);
},
[router]
);
const onPress = useCallback(async () => {
if (!item) return;
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
});
const queryString = queryParams.toString();
const onPress = async () => {
if (!url || !item) return;
if (!client) {
setCurrentlyPlayingState({ item, url });
goToPlayer(queryString, selectedOptions.bitrate?.value);
return;
}
const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2;
showActionSheetWithOptions(
@@ -80,20 +108,36 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
switch (selectedIndex) {
case 0:
await CastContext.getPlayServicesState().then((state) => {
await CastContext.getPlayServicesState().then(async (state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
// If we're opening a currently playing item, don't restart the media.
// Instead just open controls.
if (isOpeningCurrentlyPlayingMedia) {
CastContext.showExpandedControls();
// Get a new URL with the Chromecast device profile:
const data = await getStreamUrl({
api,
item,
deviceProfile: chromecastProfile,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
"Client error",
"Could not create stream for Chromecast"
);
return;
}
client
.loadMedia({
mediaInfo: {
contentUrl: url,
contentUrl: data?.url,
contentType: "video/mp4",
metadata:
item.Type === "Episode"
@@ -159,28 +203,38 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
});
break;
case 1:
setCurrentlyPlayingState({ item, url });
goToPlayer(queryString, selectedOptions.bitrate?.value);
break;
case cancelButtonIndex:
break;
}
}
);
};
}, [
item,
client,
settings,
api,
user,
router,
showActionSheetWithOptions,
mediaStatus,
selectedOptions,
]);
const derivedTargetWidth = useDerivedValue(() => {
if (!memoizedItem || !memoizedItem.RunTimeTicks) return 0;
const userData = memoizedItem.UserData;
if (!item || !item.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData && userData.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0
? Math.max(
(userData.PlaybackPositionTicks / memoizedItem.RunTimeTicks) * 100,
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH
)
: 0;
}
return 0;
}, [memoizedItem]);
}, [item]);
useAnimatedReaction(
() => derivedTargetWidth.value,
@@ -196,7 +250,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
);
useAnimatedReaction(
() => memoizedColor,
() => colorAtom,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
@@ -205,19 +259,19 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
[memoizedColor]
[colorAtom]
);
useEffect(() => {
const timeout_2 = setTimeout(() => {
startColor.value = memoizedColor;
startColor.value = colorAtom;
startWidth.value = targetWidth.value;
}, ANIMATION_DURATION);
return () => {
clearTimeout(timeout_2);
};
}, [memoizedColor, memoizedItem]);
}, [colorAtom, item]);
/**
* ANIMATED STYLES
@@ -226,7 +280,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.average, endColor.value.average]
[startColor.value.primary, endColor.value.primary]
),
}));
@@ -260,10 +314,11 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
return (
<View>
<TouchableOpacity
disabled={!item}
accessibilityLabel="Play button"
accessibilityHint="Tap to play the media"
onPress={onPress}
className="relative"
className={`relative`}
{...props}
>
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
@@ -279,7 +334,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
</View>
<Animated.View
style={[animatedAverageStyle]}
style={[animatedAverageStyle, { opacity: 0.5 }]}
className="absolute w-full h-full top-0 left-0 rounded-xl"
/>
<View
@@ -300,12 +355,22 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
{client && (
<Animated.Text style={animatedTextStyle}>
<Feather name="cast" size={22} />
<CastButton tintColor="transparent" />
</Animated.Text>
)}
{!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name="vlc"
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</View>
</TouchableOpacity>
<View className="mt-2 flex flex-row items-center">
{/* <View className="mt-2 flex flex-row items-center">
<Ionicons
name="information-circle"
size={12}
@@ -315,7 +380,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
<Text className="text-neutral-500 ml-1">
{directStream ? "Direct stream" : "Transcoded stream"}
</Text>
</View>
</View> */}
</View>
);
};

View File

@@ -21,11 +21,17 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
const invalidateQueries = () => {
queryClient.invalidateQueries({
queryKey: ["item"],
queryKey: ["item", item.Id],
});
queryClient.invalidateQueries({
queryKey: ["resumeItems"],
});
queryClient.invalidateQueries({
queryKey: ["continueWatching"],
});
queryClient.invalidateQueries({
queryKey: ["nextUp-all"],
});
queryClient.invalidateQueries({
queryKey: ["nextUp"],
});
@@ -36,7 +42,7 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
queryKey: ["seasons"],
});
queryClient.invalidateQueries({
queryKey: ["nextUp-all"],
queryKey: ["home"],
});
};

View File

@@ -10,6 +10,8 @@ import { ScrollView, TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "./common/Text";
import { ItemCardText } from "./ItemCardText";
import { Loader } from "./Loader";
import { HorizontalScroll } from "./common/HorrizontalScroll";
import { TouchableItemRouter } from "./common/TouchableItemRouter";
interface SimilarItemsProps extends ViewProps {
itemId?: string | null;
@@ -46,29 +48,24 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
return (
<View {...props}>
<Text className="px-4 text-lg font-bold mb-2">Similar items</Text>
{isLoading ? (
<View className="my-12">
<Loader />
</View>
) : (
<ScrollView horizontal>
<View className="px-4 flex flex-row gap-x-2">
{movies.map((item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/items/page?id=${item.Id}`)}
className="flex flex-col w-32"
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableOpacity>
))}
</View>
</ScrollView>
)}
{movies.length === 0 && (
<Text className="px-4 text-neutral-500">No similar items</Text>
)}
<HorizontalScroll
data={movies}
loading={isLoading}
height={247}
noItemsText="No similar items found"
renderItem={(item: BaseItemDto, idx: number) => (
<TouchableItemRouter
key={idx}
item={item}
className="flex flex-col w-28"
>
<View>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
)}
/>
</View>
);
};

View File

@@ -1,20 +1,14 @@
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 * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
import { useSettings } from "@/utils/atoms/settings";
interface Props extends React.ComponentProps<typeof View> {
source: MediaSourceInfo;
source?: MediaSourceInfo;
onChange: (value: number) => void;
selected: number;
selected?: number | undefined;
}
export const SubtitleTrackSelector: React.FC<Props> = ({
@@ -23,10 +17,8 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
selected,
...props
}) => {
const [settings] = useSettings();
const subtitleStreams = useMemo(
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
() => source?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
[source]
);
@@ -35,23 +27,6 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
[subtitleStreams, selected]
);
useEffect(() => {
// const index = source.DefaultAudioStreamIndex;
// if (index !== undefined && index !== null) {
// onChange(index);
// return;
// }
const defaultSubIndex = subtitleStreams?.find(
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
)?.Index;
if (defaultSubIndex !== undefined && defaultSubIndex !== null) {
onChange(defaultSubIndex);
return;
}
onChange(-1);
}, [subtitleStreams, settings]);
if (subtitleStreams.length === 0) return null;
return (

View File

@@ -1,4 +1,5 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import { View } from "react-native";
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {

View File

@@ -1,4 +1,5 @@
import {
Platform,
TouchableOpacity,
TouchableOpacityProps,
View,
@@ -21,16 +22,16 @@ export const HeaderBackButton: React.FC<Props> = ({
}) => {
const router = useRouter();
if (background === "transparent")
if (background === "transparent" && Platform.OS !== "android")
return (
<BlurView
{...props}
intensity={100}
className="overflow-hidden rounded-full p-2"
<TouchableOpacity
onPress={() => router.back()}
{...touchableOpacityProps}
>
<TouchableOpacity
onPress={() => router.back()}
{...touchableOpacityProps}
<BlurView
{...props}
intensity={100}
className="overflow-hidden rounded-full p-2"
>
<Ionicons
className="drop-shadow-2xl"
@@ -38,8 +39,8 @@ export const HeaderBackButton: React.FC<Props> = ({
size={24}
color="white"
/>
</TouchableOpacity>
</BlurView>
</BlurView>
</TouchableOpacity>
);
return (
@@ -52,7 +53,7 @@ export const HeaderBackButton: React.FC<Props> = ({
className="drop-shadow-2xl"
name="arrow-back"
size={24}
color="#077DF2"
color="white"
/>
</TouchableOpacity>
);

View File

@@ -16,12 +16,14 @@ interface HorizontalScrollProps<T>
> {
data?: T[] | null;
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor?: (item: T, index: number) => string;
containerStyle?: ViewStyle;
contentContainerStyle?: ViewStyle;
loadingContainerStyle?: ViewStyle;
height?: number;
loading?: boolean;
extraData?: any;
noItemsText?: string;
}
export const HorizontalScroll = forwardRef<
@@ -31,6 +33,7 @@ export const HorizontalScroll = forwardRef<
<T,>(
{
data = [],
keyExtractor,
renderItem,
containerStyle,
contentContainerStyle,
@@ -38,6 +41,7 @@ export const HorizontalScroll = forwardRef<
loading = false,
height = 164,
extraData,
noItemsText,
...props
}: HorizontalScrollProps<T>,
ref: React.ForwardedRef<HorizontalScrollRef>
@@ -89,9 +93,12 @@ export const HorizontalScroll = forwardRef<
paddingHorizontal: 16,
...contentContainerStyle,
}}
keyExtractor={keyExtractor}
ListEmptyComponent={() => (
<View className="flex-1 justify-center items-center">
<Text className="text-center text-gray-500">No data available</Text>
<Text className="text-center text-gray-500">
{noItemsText || "No data available"}
</Text>
</View>
)}
{...props}

View File

@@ -10,9 +10,9 @@ export function Input(props: TextInputProps) {
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}
placeholderTextColor={"#9CA3AF"}
clearButtonMode="while-editing"
{...otherProps}
/>
);
}

View File

@@ -22,7 +22,6 @@ interface Props extends ImageProps {
| "Thumb";
quality?: number;
width?: number;
useThemeColor?: boolean;
onError?: () => void;
}
@@ -31,7 +30,6 @@ export const ItemImage: React.FC<Props> = ({
variant = "Primary",
quality = 90,
width = 1000,
useThemeColor = false,
onError,
...props
}) => {

View File

@@ -1,11 +1,16 @@
import React from "react";
import { TextProps } from "react-native";
import { Text as DefaultText } from "react-native";
export function Text(props: TextProps) {
import { UITextView } from "react-native-uitextview";
export function Text(
props: TextProps & {
uiTextView?: boolean;
}
) {
const { style, ...otherProps } = props;
return (
<DefaultText
<UITextView
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}

View File

@@ -8,6 +8,46 @@ interface Props extends TouchableOpacityProps {
item: BaseItemDto;
}
export const itemRouter = (item: BaseItemDto, from: string) => {
if (item.CollectionType === "livetv") {
return `/(auth)/(tabs)/${from}/livetv`;
}
if (item.Type === "Series") {
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
}
if (item.Type === "MusicAlbum") {
return `/(auth)/(tabs)/${from}/albums/${item.Id}`;
}
if (item.Type === "Audio") {
return `/(auth)/(tabs)/${from}/albums/${item.AlbumId}`;
}
if (item.Type === "MusicArtist") {
return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
}
if (item.Type === "Person") {
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
}
if (item.Type === "BoxSet") {
return `/(auth)/(tabs)/${from}/collections/${item.Id}`;
}
if (item.Type === "UserView") {
return `/(auth)/(tabs)/${from}/collections/${item.Id}`;
}
if (item.Type === "CollectionFolder") {
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
}
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
};
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
item,
children,
@@ -23,54 +63,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (item.Type === "Series") {
router.push(`/(auth)/(tabs)/${from}/series/${item.Id}`);
return;
}
if (item.Type === "MusicAlbum") {
router.push(`/(auth)/(tabs)/${from}/albums/${item.Id}`);
return;
}
if (item.Type === "Audio") {
router.push(`/(auth)/(tabs)/${from}/albums/${item.AlbumId}`);
return;
}
if (item.Type === "MusicArtist") {
router.push(`/(auth)/(tabs)/${from}/artists/${item.Id}`);
return;
}
if (item.Type === "Person") {
router.push(`/(auth)/(tabs)/${from}/actors/${item.Id}`);
return;
}
if (item.Type === "BoxSet") {
router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`);
return;
}
if (item.Type === "UserView") {
router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`);
return;
}
if (item.Type === "CollectionFolder") {
router.push(`/(auth)/(tabs)/(libraries)/${item.Id}`);
return;
}
// Same as default
// if (item.Type === "Episode") {
// router.push(`/items/${item.Id}`);
// return;
// }
router.push(`/(auth)/(tabs)/${from}/items/page?id=${item.Id}`);
const url = itemRouter(item, from);
// @ts-ignore
router.push(url);
}}
{...props}
>

View File

@@ -0,0 +1,191 @@
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { JobStatus } from "@/utils/optimize-server";
import { formatTimeString } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { FFmpegKit } from "ffmpeg-kit-react-native";
import { useAtom } from "jotai";
import {
ActivityIndicator,
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { toast } from "sonner-native";
import { Button } from "../Button";
import { Image } from "expo-image";
import { useMemo } from "react";
import { storage } from "@/utils/mmkv";
interface Props extends ViewProps {}
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
const { processes } = useDownload();
if (processes?.length === 0)
return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Active download</Text>
<Text className="opacity-50">No active downloads</Text>
</View>
);
return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold mb-2">Active downloads</Text>
<View className="space-y-2">
{processes?.map((p) => (
<DownloadCard key={p.item.Id} process={p} />
))}
</View>
</View>
);
};
interface DownloadCardProps extends TouchableOpacityProps {
process: JobStatus;
}
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { processes, startDownload } = useDownload();
const router = useRouter();
const { removeProcess, setProcesses } = useDownload();
const [settings] = useSettings();
const queryClient = useQueryClient();
const cancelJobMutation = useMutation({
mutationFn: async (id: string) => {
if (!process) throw new Error("No active download");
if (settings?.downloadMethod === "optimized") {
try {
const tasks = await checkForExistingDownloads();
for (const task of tasks) {
if (task.id === id) {
task.stop();
}
}
} catch (e) {
throw e;
} finally {
await removeProcess(id);
await queryClient.refetchQueries({ queryKey: ["jobs"] });
}
} else {
FFmpegKit.cancel(Number(id));
setProcesses((prev) => prev.filter((p) => p.id !== id));
}
},
onSuccess: () => {
toast.success("Download canceled");
},
onError: (e) => {
console.error(e);
toast.error("Could not cancel download");
},
});
const eta = (p: JobStatus) => {
if (!p.speed || !p.progress) return null;
const length = p?.item?.RunTimeTicks || 0;
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
return formatTimeString(timeLeft, "tick");
};
const base64Image = useMemo(() => {
return storage.getString(process.item.Id!);
}, []);
return (
<TouchableOpacity
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden"
{...props}
>
{(process.status === "optimizing" ||
process.status === "downloading") && (
<View
className={`
bg-purple-600 h-1 absolute bottom-0 left-0
`}
style={{
width: process.progress
? `${Math.max(5, process.progress)}%`
: "5%",
}}
></View>
)}
<View className="px-3 py-1.5 flex flex-col w-full">
<View className="flex flex-row items-center w-full">
{base64Image && (
<View className="w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4">
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
resizeMode: "cover",
}}
/>
</View>
)}
<View className="shrink mb-1">
<Text className="text-xs opacity-50">{process.item.Type}</Text>
<Text className="font-semibold shrink">{process.item.Name}</Text>
<Text className="text-xs opacity-50">
{process.item.ProductionYear}
</Text>
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
{process.progress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<Text className="text-xs">{process.progress.toFixed(0)}%</Text>
)}
{process.speed && (
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
)}
{eta(process) && (
<Text className="text-xs">ETA {eta(process)}</Text>
)}
</View>
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
<Text className="text-xs capitalize">{process.status}</Text>
</View>
</View>
<TouchableOpacity
disabled={cancelJobMutation.isPending}
onPress={() => cancelJobMutation.mutate(process.id)}
className="ml-auto"
>
{cancelJobMutation.isPending ? (
<ActivityIndicator size="small" color="white" />
) : (
<Ionicons name="close" size={24} color="red" />
)}
</TouchableOpacity>
</View>
{process.status === "completed" && (
<View className="flex flex-row mt-4 space-x-4">
<Button
onPress={() => {
startDownload(process);
}}
className="w-full"
>
Download now
</Button>
</View>
)}
</View>
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,62 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, {useEffect, useMemo, useState} from "react";
import {Text} from "@/components/common/Text";
import useDownloadHelper from "@/utils/download";
import {bytesToReadable, useDownload} from "@/providers/DownloadProvider";
import {TextProps} from "react-native";
interface DownloadSizeProps extends TextProps {
items: BaseItemDto[];
}
interface DownloadSizes {
knownSize: number;
itemsNeedingSize: BaseItemDto[];
}
export const DownloadSize: React.FC<DownloadSizeProps> = ({ items, ...props }) => {
const { downloadedFiles, saveDownloadedItemInfo } = useDownload();
const { getDownloadSize } = useDownloadHelper();
const [size, setSize] = useState<string | undefined>();
const itemIds = useMemo(() => items.map(i => i.Id), [items])
useEffect(() => {
if (!downloadedFiles)
return
const {knownSize, itemsNeedingSize} = downloadedFiles
.filter(f => itemIds.includes(f.item.Id))
?.reduce<DownloadSizes>((acc, file) => {
if (file?.size && file.size > 0)
acc.knownSize += file.size
else
acc.itemsNeedingSize.push(file.item)
return acc
}, {
knownSize: 0,
itemsNeedingSize: []
})
getDownloadSize(
(item, size) => saveDownloadedItemInfo(item, size),
...itemsNeedingSize
).then(sizeSum => {
setSize(bytesToReadable((sizeSum + knownSize)))
})
},
[items, itemIds]
);
const sizeText = useMemo(() => {
if (!size)
return "reading size..."
return size
}, [size])
return (
<>
<Text className="text-xs text-neutral-500" {...props}>{sizeText}</Text>
</>
);
};

View File

@@ -1,36 +1,42 @@
import React, { useCallback } from "react";
import { TouchableOpacity } from "react-native";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as ContextMenu from "zeego/context-menu";
import * as Haptics from "expo-haptics";
import * as FileSystem from "expo-file-system";
import { useAtom } from "jotai";
import React, {useCallback, useMemo} from "react";
import { TouchableOpacity, View } from "react-native";
import {
ActionSheetProvider,
useActionSheet,
} from "@expo/react-native-action-sheet";
import { Text } from "../common/Text";
import { useFiles } from "@/hooks/useFiles";
import { useSettings } from "@/utils/atoms/settings";
import { usePlayback } from "@/providers/PlaybackProvider";
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import {useDownload} from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { Image } from "expo-image";
import { Ionicons } from "@expo/vector-icons";
import {Text} from "@/components/common/Text";
import {runtimeTicksToSeconds} from "@/utils/time";
import {DownloadSize} from "@/components/downloads/DownloadSize";
interface EpisodeCardProps {
item: BaseItemDto;
}
/**
* EpisodeCard component displays an episode with context menu options.
* EpisodeCard component displays an episode with action sheet options.
* @param {EpisodeCardProps} props - The component props.
* @returns {React.ReactElement} The rendered EpisodeCard component.
*/
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
const { deleteFile } = useFiles();
const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const { startDownloadedFilePlayback } = usePlayback();
const base64Image = useMemo(() => {
return storage.getString(item.Id!);
}, [item]);
const handleOpenFile = useCallback(async () => {
startDownloadedFilePlayback({
item,
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
});
}, [item, startDownloadedFilePlayback]);
const handleOpenFile = useCallback(() => {
openFile(item);
}, [item, openFile]);
/**
* Handles deleting the file with haptic feedback.
@@ -42,43 +48,86 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
}
}, [deleteFile, item.Id]);
const contextMenuOptions = [
{
label: "Delete",
onSelect: handleDeleteFile,
destructive: true,
},
];
const showActionSheet = useCallback(() => {
const options = ["Delete", "Cancel"];
const destructiveButtonIndex = 0;
const cancelButtonIndex = 1;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
destructiveButtonIndex,
},
(selectedIndex) => {
switch (selectedIndex) {
case destructiveButtonIndex:
// Delete
handleDeleteFile();
break;
case cancelButtonIndex:
// Cancelled
break;
}
}
);
}, [showActionSheetWithOptions, handleDeleteFile]);
return (
<ContextMenu.Root>
<ContextMenu.Trigger>
<TouchableOpacity
onPress={handleOpenFile}
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
>
<Text className="font-bold">{item.Name}</Text>
<Text className="text-xs opacity-50">Episode {item.IndexNumber}</Text>
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
alignOffset={0}
avoidCollisions
collisionPadding={10}
loop={false}
>
{contextMenuOptions.map((option) => (
<ContextMenu.Item
key={option.label}
onSelect={option.onSelect}
destructive={option.destructive}
>
<ContextMenu.ItemTitle style={{ color: "red" }}>
{option.label}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
))}
</ContextMenu.Content>
</ContextMenu.Root>
<TouchableOpacity
onPress={handleOpenFile}
onLongPress={showActionSheet}
className="flex flex-col mr-2"
>
<View className="flex flex-row items-start mb-2">
<View className="mr-2">
{base64Image ? (
<View className="w-44 aspect-video rounded-lg overflow-hidden">
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
resizeMode: "cover",
}}
/>
</View>
) : (
<View className="w-44 aspect-video rounded-lg bg-neutral-900 flex items-center justify-center">
<Ionicons
name="image-outline"
size={24}
color="gray"
className="self-center mt-16"
/>
</View>
)}
</View>
<View className="w-56 flex flex-col">
<Text numberOfLines={2} className="">
{item.Name}
</Text>
<Text numberOfLines={1} className="text-xs text-neutral-500">
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
</Text>
<Text className="text-xs text-neutral-500">
{runtimeTicksToSeconds(item.RunTimeTicks)}
</Text>
<DownloadSize items={[item]} />
</View>
</View>
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">{item.Overview}</Text>
</TouchableOpacity>
);
};
// Wrap the parent component with ActionSheetProvider
export const EpisodeCardWithActionSheet: React.FC<EpisodeCardProps> = (
props
) => (
<ActionSheetProvider>
<EpisodeCard {...props} />
</ActionSheetProvider>
);

View File

@@ -1,39 +1,41 @@
import React, { useCallback } from "react";
import { TouchableOpacity, View } from "react-native";
import {
ActionSheetProvider,
useActionSheet,
} from "@expo/react-native-action-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as ContextMenu from "zeego/context-menu";
import * as FileSystem from "expo-file-system";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import { Text } from "../common/Text";
import { useFiles } from "@/hooks/useFiles";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useSettings } from "@/utils/atoms/settings";
import { usePlayback } from "@/providers/PlaybackProvider";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { ItemCardText } from "../ItemCardText";
interface MovieCardProps {
item: BaseItemDto;
}
/**
* MovieCard component displays a movie with context menu options.
* MovieCard component displays a movie with action sheet options.
* @param {MovieCardProps} props - The component props.
* @returns {React.ReactElement} The rendered MovieCard component.
*/
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const { deleteFile } = useFiles();
const [settings] = useSettings();
const { startDownloadedFilePlayback } = usePlayback();
const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const handleOpenFile = useCallback(() => {
startDownloadedFilePlayback({
item,
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
});
}, [item, startDownloadedFilePlayback]);
openFile(item);
}, [item, openFile]);
const base64Image = useMemo(() => {
return storage.getString(item.Id!);
}, []);
/**
* Handles deleting the file with haptic feedback.
@@ -45,48 +47,65 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
}
}, [deleteFile, item.Id]);
const contextMenuOptions = [
{
label: "Delete",
onSelect: handleDeleteFile,
destructive: true,
},
];
const showActionSheet = useCallback(() => {
const options = ["Delete", "Cancel"];
const destructiveButtonIndex = 0;
const cancelButtonIndex = 1;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
destructiveButtonIndex,
},
(selectedIndex) => {
switch (selectedIndex) {
case destructiveButtonIndex:
// Delete
handleDeleteFile();
break;
case cancelButtonIndex:
// Cancelled
break;
}
}
);
}, [showActionSheetWithOptions, handleDeleteFile]);
return (
<ContextMenu.Root>
<ContextMenu.Trigger>
<TouchableOpacity
onPress={handleOpenFile}
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
>
<Text className="font-bold">{item.Name}</Text>
<View className="flex flex-col">
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
<Text className="text-xs opacity-50">
{runtimeTicksToMinutes(item.RunTimeTicks)}
</Text>
</View>
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
loop={false}
alignOffset={0}
avoidCollisions={false}
collisionPadding={0}
>
{contextMenuOptions.map((option) => (
<ContextMenu.Item
key={option.label}
onSelect={option.onSelect}
destructive={option.destructive}
>
<ContextMenu.ItemTitle style={{ color: "red" }}>
{option.label}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
))}
</ContextMenu.Content>
</ContextMenu.Root>
<TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}>
{base64Image ? (
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
resizeMode: "cover",
}}
/>
</View>
) : (
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
<Ionicons
name="image-outline"
size={24}
color="gray"
className="self-center mt-16"
/>
</View>
)}
<ItemCardText item={item} />
<DownloadSize items={[item]} />
</TouchableOpacity>
);
};
// Wrap the parent component with ActionSheetProvider
export const MovieCardWithActionSheet: React.FC<MovieCardProps> = (props) => (
<ActionSheetProvider>
<MovieCard {...props} />
</ActionSheetProvider>
);

View File

@@ -1,49 +1,82 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { View } from "react-native";
import { EpisodeCard } from "./EpisodeCard";
import {TouchableOpacity, View} from "react-native";
import { Text } from "../common/Text";
import { useMemo } from "react";
import { SeasonPicker } from "../series/SeasonPicker";
import React, {useCallback, useMemo} from "react";
import {storage} from "@/utils/mmkv";
import {Image} from "expo-image";
import {Ionicons} from "@expo/vector-icons";
import {router} from "expo-router";
import {DownloadSize} from "@/components/downloads/DownloadSize";
import {useDownload} from "@/providers/DownloadProvider";
import {useActionSheet} from "@expo/react-native-action-sheet";
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
const groupBySeason = useMemo(() => {
const seasons: Record<string, BaseItemDto[]> = {};
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
const { deleteItems } = useDownload();
const { showActionSheetWithOptions } = useActionSheet();
items.forEach((item) => {
if (!seasons[item.SeasonName!]) {
seasons[item.SeasonName!] = [];
const base64Image = useMemo(() => {
return storage.getString(items[0].SeriesId!);
}, []);
const deleteSeries = useCallback(
async () => deleteItems(items),
[items]
);
const showActionSheet = useCallback(() => {
const options = ["Delete", "Cancel"];
const destructiveButtonIndex = 0;
showActionSheetWithOptions({
options,
destructiveButtonIndex,
},
(selectedIndex) => {
if (selectedIndex == destructiveButtonIndex) {
deleteSeries();
}
}
seasons[item.SeasonName!].push(item);
});
return Object.values(seasons).sort(
(a, b) => a[0].IndexNumber! - b[0].IndexNumber!
);
}, [items]);
}, [showActionSheetWithOptions, deleteSeries]);
return (
<View>
<View className="flex flex-row items-center justify-between">
<Text className="text-2xl font-bold shrink">{items[0].SeriesName}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{items.length}</Text>
<TouchableOpacity
onPress={() => router.push(`/downloads/${items[0].SeriesId}`)}
onLongPress={showActionSheet}
>
{base64Image ? (
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
resizeMode: "cover",
}}
/>
<View
className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center absolute bottom-1 right-1">
<Text className="text-xs font-bold">{items.length}</Text>
</View>
</View>
</View>
) : (
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
<Ionicons
name="image-outline"
size={24}
color="gray"
className="self-center mt-16"
/>
</View>
)}
<Text className="opacity-50 mb-2">TV-Series</Text>
{groupBySeason.map((seasonItems, seasonIndex) => (
<View key={seasonIndex}>
<Text className="mb-2 font-semibold">
{seasonItems[0].SeasonName}
</Text>
{seasonItems.map((item, index) => (
<View className="mb-2" key={index}>
<EpisodeCard item={item} />
</View>
))}
</View>
))}
</View>
<View className="w-28 mt-2 flex flex-col">
<Text numberOfLines={2} className="">{items[0].SeriesName}</Text>
<Text className="text-xs opacity-50">{items[0].ProductionYear}</Text>
<DownloadSize items={items} />
</View>
</TouchableOpacity>
);
};

View File

@@ -4,25 +4,29 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import React, { useMemo } from "react";
import { Dimensions, View, ViewProps } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import React, { useCallback, useMemo } from "react";
import { Dimensions, TouchableOpacity, View, ViewProps } from "react-native";
import Animated, {
runOnJS,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import Carousel, {
ICarouselInstance,
Pagination,
} from "react-native-reanimated-carousel";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
import { Loader } from "../Loader";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { useRouter, useSegments } from "expo-router";
import * as Haptics from "expo-haptics";
interface Props extends ViewProps {}
export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
const router = useRouter();
const queryClient = useQueryClient();
const [settings] = useSettings();
const ref = React.useRef<ICarouselInstance>(null);
@@ -80,13 +84,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
const width = Dimensions.get("screen").width;
if (l1 || l2)
return (
<View className="h-[242px] flex items-center justify-center">
<Loader />
</View>
);
if (l1 || l2) return null;
if (!popularItems) return null;
return (
@@ -122,7 +120,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const [api] = useAtom(apiAtom);
const router = useRouter();
const screenWidth = Dimensions.get("screen").width;
const uri = useMemo(() => {
@@ -141,11 +139,41 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return getLogoImageUrlById({ api, item, height: 100 });
}, [item]);
const segments = useSegments();
const from = segments[2];
const opacity = useSharedValue(1);
const handleRoute = useCallback(() => {
if (!from) return;
const url = itemRouter(item, from);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// @ts-ignore
if (url) router.push(url);
}, [item, from]);
const tap = Gesture.Tap()
.maxDuration(2000)
.onBegin(() => {
opacity.value = withTiming(0.5, { duration: 100 });
})
.onEnd(() => {
runOnJS(handleRoute)();
})
.onFinalize(() => {
opacity.value = withTiming(1, { duration: 100 });
});
if (!uri || !logoUri) return null;
return (
<TouchableItemRouter item={item}>
<View className="px-4">
<GestureDetector gesture={tap}>
<Animated.View
style={{
opacity: opacity,
}}
className="px-4"
>
<View className="relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800">
<Image
source={{
@@ -171,7 +199,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
/>
</View>
</View>
</View>
</TouchableItemRouter>
</Animated.View>
</GestureDetector>
);
};

View File

@@ -1,23 +1,20 @@
import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster";
import { useSettings } from "@/utils/atoms/settings";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
useQuery,
type QueryFunction,
type QueryKey,
} from "@tanstack/react-query";
import { View, ViewProps } from "react-native";
import { ScrollView, View, ViewProps } from "react-native";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import SeriesPoster from "../posters/SeriesPoster";
interface Props extends ViewProps {
title?: string | null;
orientation?: "horizontal" | "vertical";
height?: "small" | "large";
disabled?: boolean;
queryKey: QueryKey;
queryFn: QueryFunction<BaseItemDto[]>;
@@ -26,58 +23,97 @@ interface Props extends ViewProps {
export const ScrollingCollectionList: React.FC<Props> = ({
title,
orientation = "vertical",
height = "small",
disabled = false,
queryFn,
queryKey,
...props
}) => {
const [settings] = useSettings();
// console.log(queryKey);
const { data, isLoading } = useQuery({
queryKey,
queryKey: queryKey,
queryFn,
enabled: !disabled,
staleTime: 60 * 1000,
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
});
if (disabled || !title) return null;
return (
<View {...props}>
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
<View {...props} className="">
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
{title}
</Text>
<HorizontalScroll
data={data}
height={orientation === "vertical" ? 247 : 164}
loading={isLoading}
renderItem={(item, index) => (
<TouchableItemRouter
key={index}
item={item}
className={`flex flex-col
{isLoading === false && data?.length === 0 && (
<View className="px-4">
<Text className="text-neutral-500">No items</Text>
</View>
)}
{isLoading ? (
<View
className={`
flex flex-row gap-2 px-4
`}
>
{[1, 2, 3].map((i) => (
<View className="w-44" key={i}>
<View className="bg-neutral-900 h-24 w-full rounded-md mb-1"></View>
<View className="rounded-md overflow-hidden mb-1 self-start">
<Text
className="text-neutral-900 bg-neutral-900 rounded-md"
numberOfLines={1}
>
Nisi mollit voluptate amet.
</Text>
</View>
<View className="rounded-md overflow-hidden self-start mb-1">
<Text
className="text-neutral-900 bg-neutral-900 text-xs rounded-md "
numberOfLines={1}
>
Lorem ipsum
</Text>
</View>
</View>
))}
</View>
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{data?.map((item, index) => (
<TouchableItemRouter
item={item}
key={index}
className={`
mr-2
${orientation === "horizontal" ? "w-44" : "w-28"}
`}
>
<View>
{item.Type === "Episode" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Episode" && orientation === "vertical" && (
<SeriesPoster item={item} />
)}
{item.Type === "Movie" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Movie" && orientation === "vertical" && (
<MoviePoster item={item} />
)}
{item.Type === "Series" && <SeriesPoster item={item} />}
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
)}
/>
>
{item.Type === "Episode" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Episode" && orientation === "vertical" && (
<SeriesPoster item={item} />
)}
{item.Type === "Movie" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Movie" && orientation === "vertical" && (
<MoviePoster item={item} />
)}
{item.Type === "Series" && <SeriesPoster item={item} />}
{item.Type === "Program" && (
<ContinueWatchingPoster item={item} />
)}
<ItemCardText item={item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
)}
</View>
);
};

View File

@@ -0,0 +1,44 @@
import {TouchableOpacity, View} from "react-native";
import {Text} from "@/components/common/Text";
interface StepperProps {
value: number,
step: number,
min: number,
max: number,
onUpdate: (value: number) => void,
appendValue?: string,
}
export const Stepper: React.FC<StepperProps> = ({
value,
step,
min,
max,
onUpdate,
appendValue
}) => {
return (
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() => onUpdate(Math.max(min, value - step))}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text
className={
"w-auto h-8 bg-neutral-800 py-2 px-1 flex items-center justify-center" + (appendValue ? "first-letter:px-2" : "")
}
>
{value}{appendValue}
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() => onUpdate(Math.min(max, value + step))}
>
<Text>+</Text>
</TouchableOpacity>
</View>
)
}

View File

@@ -11,21 +11,14 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
import { TouchableOpacityProps, View } from "react-native";
import { getColors } from "react-native-image-colors";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
interface Props extends TouchableOpacityProps {
library: BaseItemDto;
}
type LibraryColor = {
dominantColor: string;
averageColor: string;
secondary: string;
};
type IconName = React.ComponentProps<typeof Ionicons>["name"];
const icons: Record<CollectionType, IconName> = {
@@ -48,12 +41,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
const [user] = useAtom(userAtom);
const [settings] = useSettings();
const [imageInfo, setImageInfo] = useState<LibraryColor>({
dominantColor: "#fff",
averageColor: "#fff",
secondary: "#fff",
});
const url = useMemo(
() =>
getPrimaryImageUrl({
@@ -74,42 +61,9 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
});
return response.data.TotalRecordCount;
},
staleTime: 1000 * 60 * 60,
});
useEffect(() => {
if (url) {
getColors(url, {
fallback: "#fff",
cache: true,
key: url,
})
.then((colors) => {
let dominantColor: string = "#fff";
let averageColor: string = "#fff";
let secondary: string = "#fff";
if (colors.platform === "android") {
dominantColor = colors.dominant;
averageColor = colors.average;
secondary = colors.muted;
} else if (colors.platform === "ios") {
dominantColor = colors.primary;
averageColor = colors.background;
secondary = colors.detail;
}
setImageInfo({
dominantColor,
averageColor,
secondary,
});
})
.catch((error) => {
console.error("Error getting colors", error);
});
}
}, [url]);
if (!url) return null;
if (settings?.libraryOptions?.display === "row") {
@@ -125,7 +79,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
{library.Name}
</Text>
{settings?.libraryOptions?.showStats && (
<Text className="font-bold text-xs text-neutral-500 text-start px-4 ml-auto">
<Text className="font-bold text-xs text-neutral-500 text-start ml-auto">
{itemsCount} items
</Text>
)}

View File

@@ -0,0 +1,43 @@
import React from "react";
import { View } from "react-native";
import { Text } from "../common/Text";
export const HourHeader = ({ height }: { height: number }) => {
const now = new Date();
const currentHour = now.getHours();
const hoursRemaining = 24 - currentHour;
const hours = generateHours(currentHour, hoursRemaining);
return (
<View
className="flex flex-row"
style={{
height,
}}
>
{hours.map((hour, index) => (
<HourCell key={index} hour={hour} />
))}
</View>
);
};
const HourCell = ({ hour }: { hour: Date }) => (
<View className="w-[200px] flex items-center justify-center bg-neutral-800">
<Text className="text-xs text-gray-600">
{hour.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</View>
);
const generateHours = (startHour: number, count: number): Date[] => {
const now = new Date();
return Array.from({ length: count }, (_, i) => {
const hour = new Date(now);
hour.setHours(startHour + i, 0, 0, 0);
return hour;
});
};

View File

@@ -0,0 +1,96 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useMemo, useRef } from "react";
import { Dimensions, View } from "react-native";
import { Text } from "../common/Text";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
export const LiveTVGuideRow = ({
channel,
programs,
scrollX = 0,
isVisible = true,
}: {
channel: BaseItemDto;
programs?: BaseItemDto[] | null;
scrollX?: number;
isVisible?: boolean;
}) => {
const positionRefs = useRef<{ [key: string]: number }>({});
const screenWidth = Dimensions.get("window").width;
const calculateWidth = (s?: string | null, e?: string | null) => {
if (!s || !e) return 0;
const start = new Date(s);
const end = new Date(e);
const duration = end.getTime() - start.getTime();
const minutes = duration / 60000;
const width = (minutes / 60) * 200;
return width;
};
const programsWithPositions = useMemo(() => {
let cumulativeWidth = 0;
return programs
?.filter((p) => p.ChannelId === channel.Id)
.map((p) => {
const width = calculateWidth(p.StartDate, p.EndDate);
const position = cumulativeWidth;
cumulativeWidth += width;
return { ...p, width, position };
});
}, [programs, channel.Id]);
const isCurrentlyLive = (program: BaseItemDto) => {
if (!program.StartDate || !program.EndDate) return false;
const now = new Date();
const start = new Date(program.StartDate);
const end = new Date(program.EndDate);
return now >= start && now <= end;
};
if (!isVisible) {
return <View style={{ height: 64 }} />;
}
return (
<View key={channel.ChannelNumber} className="flex flex-row h-16">
{programsWithPositions?.map((p) => (
<TouchableItemRouter item={p} key={p.Id}>
<View
style={{
width: p.width,
height: "100%",
position: "absolute",
left: p.position,
backgroundColor: isCurrentlyLive(p)
? "rgba(255, 255, 255, 0.1)"
: "transparent",
}}
className="flex flex-col items-center justify-center border border-neutral-800 overflow-hidden"
>
{(() => {
return (
<View
style={{
marginLeft:
p.width > screenWidth && scrollX > p.position
? scrollX - p.position
: 0,
}}
className="px-4 self-start"
>
<Text
numberOfLines={2}
className="text-xs text-start self-start"
>
{p.Name}
</Text>
</View>
);
})()}
</View>
</TouchableItemRouter>
))}
</View>
);
};

View File

@@ -31,10 +31,10 @@ export const MediaListSection: React.FC<Props> = ({
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: collection, isLoading } = useQuery({
const { data: collection } = useQuery({
queryKey,
queryFn,
staleTime: 60 * 1000,
staleTime: 0,
});
const fetchItems = useCallback(

View File

@@ -9,8 +9,10 @@ interface Props extends ViewProps {
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
return (
<View {...props}>
<Text className=" font-bold text-2xl mb-1">{item?.Name}</Text>
<Text className=" opacity-50">{item?.ProductionYear}</Text>
<Text uiTextView selectable className="font-bold text-2xl mb-1">
{item?.Name}
</Text>
<Text className="opacity-50">{item?.ProductionYear}</Text>
</View>
);
};

View File

@@ -1,14 +1,12 @@
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios from "@/utils/profiles/ios";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { runtimeTicksToSeconds } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useCallback } from "react";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import CastContext, {
PlayServicesState,
@@ -35,11 +33,11 @@ export const SongsListItem: React.FC<Props> = ({
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const castDevice = useCastDevice();
const router = useRouter();
const client = useRemoteMediaClient();
const { showActionSheetWithOptions } = useActionSheet();
const { setCurrentlyPlayingState } = usePlayback();
const { setPlaySettings } = usePlaySettings();
const openSelect = () => {
if (!castDevice?.deviceId) {
@@ -70,32 +68,18 @@ export const SongsListItem: React.FC<Props> = ({
);
};
const play = async (type: "device" | "cast") => {
const play = useCallback(async (type: "device" | "cast") => {
if (!user?.Id || !api || !item.Id) {
console.warn("No user, api or item", user, api, item.Id);
return;
}
const response = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: item?.Id,
userId: user?.Id,
});
const sessionData = response.data;
const url = await getStreamUrl({
api,
userId: user.Id,
const data = await setPlaySettings({
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
mediaSourceId: item.Id,
});
if (!url || !item) {
console.warn("No url or item", url, item.Id);
return;
if (!data?.url) {
throw new Error("play-music ~ No stream url");
}
if (type === "cast" && client) {
@@ -105,7 +89,7 @@ export const SongsListItem: React.FC<Props> = ({
else {
client.loadMedia({
mediaInfo: {
contentUrl: url,
contentUrl: data.url!,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
@@ -118,13 +102,10 @@ export const SongsListItem: React.FC<Props> = ({
}
});
} else {
console.log("Playing on device", url, item.Id);
setCurrentlyPlayingState({
item,
url,
});
console.log("Playing on device", data.url, item.Id);
router.push("/music-player");
}
};
}, []);
return (
<TouchableOpacity

View File

@@ -36,7 +36,7 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
}, [item]);
return (
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]">
<Image
placeholder={{
blurhash,
@@ -57,7 +57,6 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
width: "100%",
}}
/>
<WatchedIndicator item={item} />
{showProgress && progress > 0 && (
<View className="h-1 bg-red-600 w-full"></View>

View File

@@ -32,7 +32,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
}, [item]);
return (
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
<View className="w-28 aspect-[10/15] relative rounded-lg overflow-hidden border border-neutral-900 ">
<Image
placeholder={{
blurhash,
@@ -49,7 +49,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "10/15",
height: "100%",
width: "100%",
}}
/>

View File

@@ -6,7 +6,7 @@ import {
} from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { useAtom } from "jotai";
import React from "react";
import React, { useMemo } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
@@ -20,23 +20,37 @@ interface Props extends ViewProps {
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom);
const destinctPeople = useMemo(() => {
const people: BaseItemPerson[] = [];
item?.People?.forEach((person) => {
const existingPerson = people.find((p) => p.Id === person.Id);
if (existingPerson) {
existingPerson.Role = `${existingPerson.Role}, ${person.Role}`;
} else {
people.push(person);
}
});
return people;
}, [item?.People]);
return (
<View {...props} className="flex flex-col">
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
<HorizontalScroll
loading={loading}
data={item?.People || []}
renderItem={(item, index) => (
keyExtractor={(i, idx) => i.Id.toString()}
height={247}
data={destinctPeople}
renderItem={(i) => (
<TouchableOpacity
onPress={() => {
router.push(`/actors/${item.Id}`);
router.push(`/actors/${i.Id}`);
}}
key={item.Id}
className="flex flex-col w-32"
className="flex flex-col w-28"
>
<Poster item={item} url={getPrimaryImageUrl({ api, item })} />
<Text className="mt-2">{item.Name}</Text>
<Text className="text-xs opacity-50">{item.Role}</Text>
<Poster item={i} url={getPrimaryImageUrl({ api, item: i })} />
<Text className="mt-2">{i.Name}</Text>
<Text className="text-xs opacity-50">{i.Role}</Text>
</TouchableOpacity>
)}
/>

View File

@@ -21,11 +21,12 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
<Text className="text-lg font-bold mb-2 px-4">Series</Text>
<HorizontalScroll
data={[item]}
height={247}
renderItem={(item, index) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/series/${item.SeriesId}`)}
className="flex flex-col space-y-2 w-32"
className="flex flex-col space-y-2 w-28"
>
<Poster
item={item}

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