Compare commits

...

260 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
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
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
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
Alex Kim
57354e6b06 WIP 2024-10-12 03:00:26 +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
Alex Kim
8be1e2df0c Push to remote repo 2024-10-10 23:40:01 +11:00
Fredrik Burmester
57201f8606 wip 2024-10-09 20:06:30 +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
135 changed files with 8340 additions and 3960 deletions

View File

@@ -1,26 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: '❌ bug'
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.

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

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.18.0",
"version": "0.22.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -23,7 +23,10 @@
"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,14 +36,15 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 46,
"versionCode": 47,
"adaptiveIcon": {
"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"
]
},
"plugins": [
@@ -70,7 +74,8 @@
"expo-build-properties",
{
"ios": {
"deploymentTarget": "15.6"
"deploymentTarget": "15.6",
"useFrameworks": "static"
},
"android": {
"android": {
@@ -99,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,14 +1,11 @@
import { Chromecast } from "@/components/Chromecast";
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useDownload } from "@/providers/DownloadProvider";
import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native";
export default function IndexLayout() {
const router = useRouter();
return (
<Stack>
<Stack.Screen
@@ -27,7 +24,6 @@ export default function IndexLayout() {
onPress={() => {
router.push("/(auth)/settings");
}}
className="p-2 "
>
<Feather name="settings" color={"white"} size={22} />
</TouchableOpacity>
@@ -36,11 +32,17 @@ export default function IndexLayout() {
}}
/>
<Stack.Screen
name="downloads"
name="downloads/index"
options={{
title: "Downloads",
}}
/>
<Stack.Screen
name="downloads/[seriesId]"
options={{
title: "TV-Series",
}}
/>
<Stack.Screen
name="settings"
options={{

View File

@@ -1,123 +0,0 @@
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 { useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
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 [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedFiles } = useDownload();
const [settings] = useSettings();
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 insets = useSafeAreaInsets();
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) => (
<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={() => {
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: BaseItemDto) => (
<View className="mb-2 last:mb-0" key={item.Id}>
<MovieCard item={item} />
</View>
))}
</View>
</ScrollView>
</View>
)}
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
<SeriesCard items={items} key={items[0].SeriesId} />
))}
{downloadedFiles?.length === 0 && (
<View className="flex px-4">
<Text className="opacity-50">No downloaded items</Text>
</View>
)}
</View>
</ScrollView>
);
};
export default downloads;

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

@@ -5,7 +5,7 @@ import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionLi
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import { TAB_HEIGHT } from "@/constants/Values";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
@@ -53,7 +53,6 @@ type MediaListSection = {
type Section = ScrollingCollectionListSection | MediaListSection;
export default function index() {
const queryClient = useQueryClient();
const router = useRouter();
const api = useAtomValue(apiAtom);
@@ -68,6 +67,8 @@ export default function index() {
const { downloadedFiles } = useDownload();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
useEffect(() => {
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
navigation.setOptions({
@@ -164,28 +165,13 @@ export default function index() {
);
}, [userViews]);
const invalidateCache = useInvalidatePlaybackProgressCache();
const refetch = useCallback(async () => {
setLoading(true);
await queryClient.invalidateQueries({
queryKey: ["home"],
refetchType: "all",
type: "all",
exact: false,
});
await queryClient.invalidateQueries({
queryKey: ["home"],
refetchType: "all",
type: "all",
exact: false,
});
await queryClient.invalidateQueries({
queryKey: ["item"],
refetchType: "all",
type: "all",
exact: false,
});
await invalidateCache();
setLoading(false);
}, [queryClient]);
}, []);
const createCollectionConfig = useCallback(
(
@@ -202,7 +188,7 @@ export default function index() {
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 50,
limit: 20,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
@@ -241,7 +227,7 @@ export default function index() {
const ss: Section[] = [
{
title: "Continue Watching",
queryKey: ["home", "resumeItems", user.Id],
queryKey: ["home", "resumeItems"],
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
@@ -255,7 +241,7 @@ export default function index() {
},
{
title: "Next Up",
queryKey: ["home", "nextUp-all", user?.Id],
queryKey: ["home", "nextUp-all"],
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
@@ -361,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">
@@ -387,15 +371,11 @@ export default function index() {
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
key={"home"}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
style={{
marginBottom: TAB_HEIGHT,
}}
>
<View className="flex flex-col space-y-4">
<LargeMovieCarousel />

View File

@@ -2,32 +2,41 @@ import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/ListItem";
import { SettingToggles } from "@/components/settings/SettingToggles";
import { useDownload } from "@/providers/DownloadProvider";
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 { Alert, 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 } = useDownload();
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",
@@ -57,6 +66,27 @@ export default function settings() {
);
};
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={{
@@ -81,6 +111,9 @@ export default function settings() {
<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>
@@ -92,42 +125,36 @@ export default function settings() {
<SettingToggles />
<View>
<Text className="font-bold text-lg mb-2">Account and storage</Text>
<View className="flex flex-col space-y-2">
<Button color="black" onPress={logout}>
Log out
</Button>
<Button
color="red"
onPress={async () => {
try {
await deleteAllFiles();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
} catch (e) {
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Error
);
toast.error("Error deleting files");
}
}}
>
Delete all downloaded files
</Button>
<Button
color="red"
onPress={async () => {
await clearLogs();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
}}
>
Delete all logs
</Button>
<View className="flex flex-col space-y-2">
<Text className="font-bold text-lg mb-2">Storage</Text>
<View className="mb-4 space-y-2">
{size && <Text>App usage: {bytesToReadable(size.app)}</Text>}
<Progress.Bar
className="bg-gray-100/10"
indeterminate={appSizeLoading}
color="#9333ea"
width={null}
height={10}
borderRadius={6}
borderWidth={0}
progress={size?.used}
/>
{size && (
<Text>Available: {bytesToReadable(size.remaining)}, Total: {bytesToReadable(size.total)}</Text>
)}
</View>
<Button
color="red"
onPress={onDeleteClicked}
>
Delete all downloaded files
</Button>
<Button
color="red"
onPress={onClearLogsClicked}
>
Delete all logs
</Button>
</View>
<View>
<Text className="font-bold text-lg mb-2">Logs</Text>

View File

@@ -2,6 +2,10 @@ 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 { useAtom } from "jotai";
@@ -22,16 +26,18 @@ const Page: React.FC = () => {
const { data: item, isError } = useQuery({
queryKey: ["item", id],
queryFn: async () => {
const res = await getUserItemData({
api,
userId: user?.Id,
if (!api || !user || !id) return;
const res = await getUserLibraryApi(api).getItem({
itemId: id,
userId: user?.Id,
});
return res;
return res.data;
},
enabled: !!id && !!api,
staleTime: 60 * 1000 * 5, // 5 minutes
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
});
const opacity = useSharedValue(1);

View File

@@ -8,13 +8,17 @@ 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 React from "react";
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;
@@ -56,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,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";
@@ -8,7 +7,6 @@ import { Loader } from "@/components/Loader";
import AlbumCover from "@/components/posters/AlbumCover";
import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster";
import { TAB_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
@@ -72,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();
@@ -226,10 +230,6 @@ export default function search() {
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
style={{
marginBottom: TAB_HEIGHT,
}}
>
<View className="flex flex-col pt-2">

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

@@ -1,308 +0,0 @@
import { Text } from "@/components/common/Text";
import AlbumCover from "@/components/posters/AlbumCover";
import { Controls } from "@/components/video-player/Controls";
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useWebSocket } from "@/hooks/useWebsockets";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
PlaybackType,
usePlaySettings,
} from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk";
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import * as Haptics from "expo-haptics";
import { Image } from "expo-image";
import { useFocusEffect } from "expo-router";
import { useAtomValue } from "jotai";
import { debounce } from "lodash";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Dimensions, Pressable, StatusBar, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, { OnProgressData, VideoRef } from "react-native-video";
export default function page() {
const { playSettings, playUrl, playSessionId } = usePlaySettings();
const api = useAtomValue(apiAtom);
const [settings] = useSettings();
const videoRef = useRef<VideoRef | null>(null);
const poster = usePoster(playSettings, api);
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
const firstTime = useRef(true);
const screenDimensions = Dimensions.get("screen");
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);
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
return null;
const togglePlay = useCallback(
async (ticks: number) => {
console.log("togglePlay");
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(ticks),
isPaused: true,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
} else {
videoRef.current?.resume();
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(ticks),
isPaused: false,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
}
},
[isPlaying, api, playSettings?.item?.Id, videoRef, settings]
);
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 reportPlaybackStopped = async () => {
await getPlaystateApi(api).onPlaybackStopped({
itemId: playSettings?.item?.Id!,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(progress.value),
playSessionId: playSessionId ? playSessionId : undefined,
});
};
const reportPlaybackStart = async () => {
await getPlaystateApi(api).onPlaybackStart({
itemId: playSettings?.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
};
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 (!playSettings?.item?.Id || data.currentTime === 0) return;
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item.Id,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.round(ticks),
isPaused: !isPlaying,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
},
[playSettings?.item.Id, isPlaying, api, isPlaybackStopped]
);
useFocusEffect(
useCallback(() => {
play();
return () => {
stop();
};
}, [play, stop])
);
const { orientation } = useOrientation();
useOrientationSettings();
useAndroidNavigationBar();
useWebSocket({
isPlaying: isPlaying,
pauseVideo: pause,
playVideo: play,
stopPlayback: stop,
});
return (
<View
style={{
width: screenDimensions.width,
height: screenDimensions.height,
position: "relative",
}}
className="flex flex-col items-center justify-center"
>
<StatusBar hidden />
<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"
>
<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={playSettings.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}
/>
</View>
);
}
export function usePoster(
playSettings: PlaybackType | null,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!playSettings?.item || !api) return undefined;
return playSettings.item.Type === "Audio"
? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: playSettings.item,
quality: 70,
width: 200,
});
}, [playSettings?.item, api]);
return poster ?? undefined;
}
export function useVideoSource(
playSettings: PlaybackType | null,
api: Api | null,
poster: string | undefined,
playUrl?: string | null
) {
const videoSource = useMemo(() => {
if (!playSettings || !api || !playUrl) {
return null;
}
const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
? Math.round(playSettings.item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: playUrl,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: playSettings.item?.AlbumArtist ?? undefined,
title: playSettings.item?.Name || "Unknown",
description: playSettings.item?.Overview ?? undefined,
imageUri: poster,
subtitle: playSettings.item?.Album ?? undefined,
},
};
}, [playSettings, api, poster]);
return videoSource;
}

View File

@@ -1,167 +0,0 @@
import { Controls } from "@/components/video-player/Controls";
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
PlaybackType,
usePlaySettings,
} from "@/providers/PlaySettingsProvider";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk";
import * as Haptics from "expo-haptics";
import { useFocusEffect } from "expo-router";
import { useAtomValue } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Pressable, StatusBar, useWindowDimensions, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, { OnProgressData, VideoRef } from "react-native-video";
export default function page() {
const { playSettings, playUrl } = usePlaySettings();
const api = useAtomValue(apiAtom);
const videoRef = useRef<VideoRef | null>(null);
const videoSource = useVideoSource(playSettings, api, playUrl);
const firstTime = useRef(true);
const dimensions = useWindowDimensions();
useOrientation();
useOrientationSettings();
useAndroidNavigationBar();
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 togglePlay = useCallback(async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
videoRef.current?.pause();
} else {
videoRef.current?.resume();
}
}, [isPlaying]);
const play = useCallback(() => {
setIsPlaying(true);
videoRef.current?.resume();
}, [videoRef]);
const stop = useCallback(() => {
setIsPlaying(false);
videoRef.current?.pause();
}, [videoRef]);
useFocusEffect(
useCallback(() => {
play();
return () => {
stop();
};
}, [play, stop])
);
const onProgress = useCallback(async (data: OnProgressData) => {
if (isSeeking.value === true) return;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBuffering(data.playableDuration === 0);
}, []);
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
return null;
return (
<View
style={{
width: dimensions.width,
height: dimensions.height,
position: "relative",
}}
className="flex flex-col items-center justify-center"
>
<StatusBar hidden />
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
className="absolute z-0 h-full w-full"
>
<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;
}
}}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
}}
/>
</Pressable>
<Controls
item={playSettings.item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
/>
</View>
);
}
export function useVideoSource(
playSettings: PlaybackType | null,
api: Api | null,
playUrl?: string | null
) {
const videoSource = useMemo(() => {
if (!playSettings || !api || !playUrl) {
return null;
}
const startPosition = 0;
return {
uri: playUrl,
isNetwork: false,
startPosition,
metadata: {
artist: playSettings.item?.AlbumArtist ?? undefined,
title: playSettings.item?.Name || "Unknown",
description: playSettings.item?.Overview ?? undefined,
subtitle: playSettings.item?.Album ?? undefined,
},
};
}, [playSettings, api]);
return videoSource;
}

View File

@@ -1,342 +0,0 @@
import { Controls } from "@/components/video-player/Controls";
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useWebSocket } from "@/hooks/useWebsockets";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
PlaybackType,
usePlaySettings,
} from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk";
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import * as Haptics from "expo-haptics";
import { useFocusEffect } from "expo-router";
import { useAtomValue } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Pressable, StatusBar, useWindowDimensions, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, {
OnProgressData,
SelectedTrackType,
VideoRef,
} from "react-native-video";
export default function page() {
const { playSettings, playUrl, playSessionId } = usePlaySettings();
const api = useAtomValue(apiAtom);
const [settings] = useSettings();
const videoRef = useRef<VideoRef | null>(null);
const poster = usePoster(playSettings, api);
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
const firstTime = useRef(true);
const dimensions = useWindowDimensions();
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);
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
return null;
const togglePlay = useCallback(
async (ticks: number) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(ticks),
isPaused: true,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
} else {
videoRef.current?.resume();
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(ticks),
isPaused: false,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
}
},
[isPlaying, api, playSettings?.item?.Id, videoRef, settings]
);
const play = useCallback(() => {
videoRef.current?.resume();
reportPlaybackStart();
}, [videoRef]);
const pause = useCallback(() => {
videoRef.current?.pause();
}, [videoRef]);
const stop = useCallback(() => {
setIsPlaybackStopped(true);
videoRef.current?.pause();
reportPlaybackStopped();
}, [videoRef]);
const reportPlaybackStopped = async () => {
await getPlaystateApi(api).onPlaybackStopped({
itemId: playSettings?.item?.Id!,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(progress.value),
playSessionId: playSessionId ? playSessionId : undefined,
});
};
const reportPlaybackStart = async () => {
await getPlaystateApi(api).onPlaybackStart({
itemId: playSettings?.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
};
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 (!playSettings?.item?.Id || data.currentTime === 0) return;
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item.Id,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.round(ticks),
isPaused: !isPlaying,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
},
[playSettings?.item.Id, isPlaying, api, isPlaybackStopped]
);
useFocusEffect(
useCallback(() => {
play();
return () => {
stop();
};
}, [play, stop])
);
useOrientation();
useOrientationSettings();
useAndroidNavigationBar();
useWebSocket({
isPlaying: isPlaying,
pauseVideo: pause,
playVideo: play,
stopPlayback: stop,
});
const selectedSubtitleTrack = useMemo(() => {
const a = playSettings?.mediaSource?.MediaStreams?.find(
(s) => s.Index === playSettings.subtitleIndex
);
console.log(a);
return a;
}, [playSettings]);
const [hlsSubTracks, setHlsSubTracks] = useState<
{
index: number;
language?: string | undefined;
selected?: boolean | undefined;
title?: string | undefined;
type: any;
}[]
>([]);
const selectedTextTrack = useMemo(() => {
for (let st of hlsSubTracks) {
if (st.title === selectedSubtitleTrack?.DisplayTitle) {
return {
type: SelectedTrackType.TITLE,
value: selectedSubtitleTrack?.DisplayTitle ?? "",
};
}
}
return undefined;
}, [hlsSubTracks]);
return (
<View
style={{
flex: 1,
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
width: dimensions.width,
height: dimensions.height,
position: "relative",
}}
>
<StatusBar hidden />
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
style={{
position: "absolute",
top: 0,
left: 0,
width: dimensions.width,
height: dimensions.height,
zIndex: 0,
}}
>
<Video
ref={videoRef}
source={videoSource}
style={{
width: dimensions.width,
height: dimensions.height,
}}
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) => {
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
}}
onTextTracks={(data) => {
console.log("onTextTracks ~", data);
setHlsSubTracks(data.textTracks as any);
}}
selectedTextTrack={selectedTextTrack}
/>
</Pressable>
<Controls
item={playSettings.item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
/>
</View>
);
}
export function usePoster(
playSettings: PlaybackType | null,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!playSettings?.item || !api) return undefined;
return playSettings.item.Type === "Audio"
? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: playSettings.item,
quality: 70,
width: 200,
});
}, [playSettings?.item, api]);
return poster ?? undefined;
}
export function useVideoSource(
playSettings: PlaybackType | null,
api: Api | null,
poster: string | undefined,
playUrl?: string | null
) {
const videoSource = useMemo(() => {
if (!playSettings || !api || !playUrl) {
return null;
}
const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
? Math.round(playSettings.item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: playUrl,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: playSettings.item?.AlbumArtist ?? undefined,
title: playSettings.item?.Name || "Unknown",
description: playSettings.item?.Overview ?? undefined,
imageUri: poster,
subtitle: playSettings.item?.Album ?? undefined,
},
};
}, [playSettings, api, poster]);
return videoSource;
}

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,15 +1,17 @@
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
getOrSetDeviceId,
getTokenFromStoraage,
getTokenFromStorage,
JellyfinProvider,
} from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { orientationAtom } from "@/utils/atoms/orientation";
import { Settings, useSettings } from "@/utils/atoms/settings";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
import { writeToLog } from "@/utils/log";
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";
@@ -19,7 +21,6 @@ import {
completeHandler,
download,
} from "@kesha-antonov/react-native-background-downloader";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as BackgroundFetch from "expo-background-fetch";
@@ -31,11 +32,11 @@ 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 { AppState } from "react-native";
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 { Toaster } from "sonner-native";
@@ -86,7 +87,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
const now = Date.now();
const settingsData = await AsyncStorage.getItem("settings");
const settingsData = storage.getString("settings");
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
@@ -96,19 +97,13 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
if (!settings?.autoDownload || !url)
return BackgroundFetch.BackgroundFetchResult.NoData;
const token = await getTokenFromStoraage();
const deviceId = await getOrSetDeviceId();
const token = getTokenFromStorage();
const deviceId = getOrSetDeviceId();
const baseDirectory = FileSystem.documentDirectory;
if (!token || !deviceId || !baseDirectory)
return BackgroundFetch.BackgroundFetchResult.NoData;
console.log({
token,
url,
deviceId,
});
const jobs = await getAllJobsByDeviceId({
deviceId,
authHeader: token,
@@ -120,14 +115,6 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
for (let job of jobs) {
if (job.status === "completed") {
const downloadUrl = url + "download/" + job.id;
console.log({
token,
deviceId,
baseDirectory,
url,
downloadUrl,
});
const tasks = await checkForExistingDownloads();
if (tasks.find((task) => task.id === job.id)) {
@@ -137,7 +124,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
download({
id: job.id,
url: url + "download/" + job.id,
url: downloadUrl,
destination: `${baseDirectory}${job.item.Id}.mp4`,
headers: {
Authorization: token,
@@ -191,7 +178,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
const checkAndRequestPermissions = async () => {
try {
const hasAskedBefore = await AsyncStorage.getItem(
const hasAskedBefore = storage.getString(
"hasAskedForNotificationPermission"
);
@@ -206,7 +193,7 @@ const checkAndRequestPermissions = async () => {
console.log("Notification permissions denied.");
}
await AsyncStorage.setItem("hasAskedForNotificationPermission", "true");
storage.set("hasAskedForNotificationPermission", "true");
} else {
console.log("Already asked for notification permissions before.");
}
@@ -231,6 +218,8 @@ export default function RootLayout() {
}
}, [loaded]);
Appearance.setColorScheme("dark");
if (!loaded) {
return null;
}
@@ -242,6 +231,18 @@ 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);
@@ -249,20 +250,6 @@ function Layout() {
useKeepAwake();
useNotificationObserver();
const queryClientRef = useRef<QueryClient>(
new QueryClient({
defaultOptions: {
queries: {
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retryOnMount: true,
},
},
})
);
useEffect(() => {
checkAndRequestPermissions();
}, []);
@@ -319,73 +306,59 @@ function Layout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClientRef.current}>
<QueryClientProvider client={queryClient}>
<ActionSheetProvider>
<JobQueueProvider>
<JellyfinProvider>
<PlaySettingsProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
}}
/>
<Stack.Screen
name="(auth)/play-video"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="(auth)/play-offline-video"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="(auth)/play-music"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<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>
<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>
@@ -395,9 +368,9 @@ function Layout() {
);
}
async function saveDownloadedItemInfo(item: BaseItemDto) {
function saveDownloadedItemInfo(item: BaseItemDto) {
try {
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
const downloadedItems = storage.getString("downloadedItems");
let items: BaseItemDto[] = downloadedItems
? JSON.parse(downloadedItems)
: [];
@@ -409,7 +382,7 @@ async function saveDownloadedItemInfo(item: BaseItemDto) {
items.push(item);
}
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
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

@@ -128,9 +128,9 @@ const Login: React.FC = () => {
} catch (e) {
const error = e as Error;
if (error.name === "AbortError") {
console.log(`Request to ${protocol}${url} timed out`);
console.error(`Request to ${protocol}${url} timed out`);
} else {
console.log(`Error checking ${protocol}${url}:`, error);
console.error(`Error checking ${protocol}${url}:`, error);
}
}
}
@@ -286,7 +286,7 @@ 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 h-full relative items-center justify-center w-full">
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">

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

BIN
bun.lockb

Binary file not shown.

View File

@@ -5,9 +5,9 @@ import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
interface Props extends React.ComponentProps<typeof View> {
source: MediaSourceInfo;
source?: MediaSourceInfo;
onChange: (value: number) => void;
selected?: number | null;
selected?: number | undefined;
}
export const AudioTrackSelector: React.FC<Props> = ({
@@ -17,7 +17,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
...props
}) => {
const audioStreams = useMemo(
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
[source]
);

View File

@@ -6,7 +6,6 @@ import { useMemo } from "react";
export type Bitrate = {
key: string;
value: number | undefined;
height?: number;
};
export const BITRATES: Bitrate[] = [
@@ -27,17 +26,14 @@ export 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));

View File

@@ -3,9 +3,10 @@ import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
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,62 +18,49 @@ import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { router, useFocusEffect } 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, BITRATES, BitrateSelector } from "./BitrateSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button";
import { Text } from "./common/Text";
import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
import { toast } from "sonner-native";
import iosFmp4 from "@/utils/profiles/iosFmp4";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
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 [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
const { processes, startBackgroundDownload } = useDownload();
const { startRemuxing } = useRemuxHlsToMp4(item);
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
const { startRemuxing } = useRemuxHlsToMp4();
const [selectedMediaSource, setSelectedMediaSource] = useState<
MediaSourceInfo | undefined
>(undefined);
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,
});
useFocusEffect(
useCallback(() => {
if (!settings) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(item, settings);
// 4. Set states
setSelectedMediaSource(mediaSource);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [item, settings])
);
const userCanDownload = useMemo(() => {
return user?.Policy?.EnableContentDownloading;
}, [user]);
const userCanDownload = useMemo(() => user?.Policy?.EnableContentDownloading, [user]);
const usingOptimizedServer = useMemo(() => settings?.downloadMethod === "optimized", [settings]);
/**
* Bottom sheet
@@ -89,115 +77,154 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
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 = iosFmp4;
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;
let fileExtension: string | undefined | null = "mp4";
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") {
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;
}
} else if (mediaSource.TranscodingUrl) {
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
fileExtension = mediaSource.TranscodingContainer;
}
if (!url) throw new Error("No url");
if (!fileExtension) throw new Error("No file extension");
const {mediaSource: source, url} = res;
if (settings?.downloadMethod === "optimized") {
return await startBackgroundDownload(url, item, fileExtension);
} else {
return await startRemuxing(url);
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);
}
}
}, [
api,
item,
startBackgroundDownload,
user?.Id,
pendingItems,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
settings,
maxBitrate,
settings?.downloadMethod,
usingOptimizedServer,
startBackgroundDownload,
startRemuxing,
]);
/**
* Check if item is downloaded
*/
const { downloadedFiles } = useDownload();
const isDownloaded = useMemo(() => {
if (!downloadedFiles) return false;
return downloadedFiles.some((file) => file.Id === item.Id);
}, [downloadedFiles, item.Id]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
@@ -208,31 +235,38 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
),
[]
);
// endregion helper functions
const process = useMemo(() => {
if (!processes) return null;
// 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);
return processes.find((process) => process?.item?.Id === item.Id);
}, [processes, item.Id]);
// 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}
>
{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"
@@ -240,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>
) : isDownloaded ? (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<Ionicons name="cloud-download" size={26} color="#9333ea" />
<TouchableOpacity onPress={onDownloadedPress}>
{DownloadedIconComponent()}
</TouchableOpacity>
) : (
<TouchableOpacity onPress={handlePresentModalPress}>
<Ionicons name="cloud-download-outline" size={24} color="white" />
{MissingDownloadIconComponent()}
</TouchableOpacity>
)}
<BottomSheetModal
@@ -281,62 +307,44 @@ 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) {
if (!item.Id) {
throw new Error("No item id");
}
closeModal();
if (settings?.downloadMethod === "remux") {
queueActions.enqueue(queue, setQueue, {
id: item.Id,
execute: async () => {
await initiateDownload();
},
item,
});
} else {
initiateDownload();
}
} else {
toast.error("You are not allowed to download files.");
}
}}
onPress={acceptDownloadOptions}
color="purple"
>
Download
</Button>
<View className="opacity-70 text-center w-full flex items-center">
{settings?.downloadMethod === "optimized" ? (
<Text className="text-xs">Using optimized server</Text>
) : (
<Text className="text-xs">Using default method</Text>
)}
<Text className="text-xs">
{usingOptimizedServer ? "Using optimized server" : "Using default method"}
</Text>
</View>
</View>
</BottomSheetView>
@@ -344,3 +352,17 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
</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" />
)}
/>
)
}

View File

@@ -12,11 +12,8 @@ export const GenreTags: React.FC<GenreTagsProps> = ({ genres }) => {
return (
<View className="flex flex-row flex-wrap mt-2">
{genres.map((genre) => (
<View
key={genre}
className="bg-neutral-800 rounded-full px-2 py-1 mr-1"
>
{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 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,126 +11,73 @@ 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 { useOrientation } from "@/hooks/useOrientation";
import { apiAtom } from "@/providers/JellyfinProvider";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useFocusEffect, useNavigation } from "expo-router";
import { useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, View } from "react-native";
import React, { useEffect, useMemo, useState } from "react";
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
export type SelectedOptions = {
bitrate: Bitrate;
mediaSource: MediaSourceInfo | undefined;
audioIndex: number | undefined;
subtitleIndex: number;
};
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
({ item }) => {
const [api] = useAtom(apiAtom);
const { setPlaySettings, playUrl, playSettings } = usePlaySettings();
const [settings] = useSettings();
const { orientation } = useOrientation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
useImageColors({ item });
const [loadingLogo, setLoadingLogo] = useState(true);
const [orientation, setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP
);
useFocusEffect(
useCallback(() => {
if (!settings) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(item, settings);
setPlaySettings({
item,
bitrate,
mediaSource,
audioIndex,
subtitleIndex,
});
if (!mediaSource) {
Alert.alert("Error", "No media source found for this item.");
navigation.goBack();
}
}, [item, settings])
);
const selectedMediaSource = useMemo(() => {
return playSettings?.mediaSource || undefined;
}, [playSettings?.mediaSource]);
const setSelectedMediaSource = (mediaSource: MediaSourceInfo) => {
setPlaySettings((prev) => ({
...prev,
mediaSource,
}));
};
const selectedAudioStream = useMemo(() => {
return playSettings?.audioIndex;
}, [playSettings?.audioIndex]);
const setSelectedAudioStream = (audioIndex: number) => {
setPlaySettings((prev) => ({
...prev,
audioIndex,
}));
};
const selectedSubtitleStream = useMemo(() => {
return playSettings?.subtitleIndex;
}, [playSettings?.subtitleIndex]);
const setSelectedSubtitleStream = (subtitleIndex: number) => {
setPlaySettings((prev) => ({
...prev,
subtitleIndex,
}));
};
const maxBitrate = useMemo(() => {
return playSettings?.bitrate;
}, [playSettings?.bitrate]);
const setMaxBitrate = (bitrate: Bitrate | undefined) => {
console.log("setMaxBitrate", bitrate);
setPlaySettings((prev) => ({
...prev,
bitrate,
}));
};
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
setOrientation(event.orientationInfo.orientation);
}
);
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
setOrientation(initialOrientation);
});
return () => {
ScreenOrientation.removeOrientationChangeListener(subscription);
};
}, []);
const [headerHeight, setHeaderHeight] = useState(350);
useImageColors({ item });
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
>(undefined);
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(item, settings);
// 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,
]);
useEffect(() => {
navigation.setOptions({
@@ -140,7 +87,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<Chromecast background="blur" width={22} height={22} />
{item.Type !== "Program" && (
<View className="flex flex-row items-center space-x-2">
<DownloadItem item={item} />
<DownloadSingleItem item={item} />
<PlayedStatus item={item} />
</View>
)}
@@ -150,13 +97,9 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
}, [item]);
useEffect(() => {
// If landscape
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
setHeaderHeight(230);
return;
}
if (item.Type === "Movie") setHeaderHeight(500);
else if (item.Type === "Movie") setHeaderHeight(500);
else setHeaderHeight(350);
}, [item.Type, orientation]);
@@ -166,7 +109,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
const insets = useSafeAreaInsets();
if (!selectedOptions) return null;
return (
<View
@@ -219,34 +162,63 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<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={item}
onChange={setSelectedMediaSource}
selected={selectedMediaSource}
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>
)}
<PlayButton className="grow" />
<PlayButton
className="grow"
selectedOptions={selectedOptions}
item={item}
/>
</View>
{item.Type === "Episode" && (
@@ -260,10 +232,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
{item.People && item.People.length > 0 && (
<View className="mb-4">
{item.People.slice(0, 3).map((person) => (
{item.People.slice(0, 3).map((person, idx) => (
<MoreMoviesWithActor
currentItem={item}
key={person.Id}
key={idx}
actorId={person.Id!}
className="mb-4"
/>

View File

@@ -26,23 +26,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
(x) => x.Type === "Video"
)?.DisplayTitle || "",
[item.MediaSources, selected]
[item, selected]
);
useEffect(() => {
if (!selected && item.MediaSources && item.MediaSources.length > 0) {
onChange(item.MediaSources[0]);
}
}, [item.MediaSources, selected]);
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"
@@ -88,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

@@ -1,14 +1,18 @@
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, MaterialCommunityIcons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo } from "react";
import { Alert, Linking, TouchableOpacity, View } from "react-native";
import { useCallback, useEffect } from "react";
import { Alert, TouchableOpacity, View } from "react-native";
import CastContext, {
CastButton,
PlayServicesState,
@@ -26,20 +30,22 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { Button } from "./Button";
import { Text } from "./common/Text";
import { useRouter } from "expo-router";
import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { SelectedOptions } from "./ItemContent";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
interface Props extends React.ComponentProps<typeof Button> {}
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
selectedOptions: SelectedOptions;
}
const ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ ...props }) => {
const { playSettings, playUrl: url } = usePlaySettings();
export const PlayButton: React.FC<Props> = ({
item,
selectedOptions,
...props
}: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus();
@@ -58,32 +64,32 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
const colorChangeProgress = useSharedValue(0);
const [settings] = useSettings();
const directStream = useMemo(() => {
return !url?.includes("m3u8");
}, [url]);
const item = useMemo(() => {
return playSettings?.item;
}, [playSettings?.item]);
const onPress = useCallback(async () => {
if (!url || !item) {
console.warn(
"No URL or item provided to PlayButton",
url?.slice(0, 100),
item?.Id
);
return;
}
if (!client) {
const vlcLink = "vlc://" + url;
if (vlcLink && settings?.openInVLC) {
Linking.openURL(vlcLink);
const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => {
if (!bitrateValue) {
router.push(`/player/direct-player?${q}`);
return;
}
router.push(`/player/transcoding-player?${q}`);
},
[router]
);
router.push("/play-video");
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();
if (!client) {
goToPlayer(queryString, selectedOptions.bitrate?.value);
return;
}
@@ -106,25 +112,17 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
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();
return;
}
// Get a new URL with the Chromecast device profile:
const data = await getStreamUrl({
api,
deviceProfile: chromecastProfile,
item,
mediaSourceId: playSettings?.mediaSource?.Id,
startTimeTicks: 0,
maxStreamingBitrate: playSettings?.bitrate?.value,
audioStreamIndex: playSettings?.audioIndex ?? 0,
subtitleStreamIndex: playSettings?.subtitleIndex ?? -1,
deviceProfile: chromecastProfile,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
forceDirectPlay: settings?.forceDirectPlay,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
if (!data?.url) {
@@ -205,7 +203,7 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
});
break;
case 1:
router.push("/play-video");
goToPlayer(queryString, selectedOptions.bitrate?.value);
break;
case cancelButtonIndex:
break;
@@ -213,16 +211,15 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
}
);
}, [
url,
item,
client,
settings,
api,
user,
playSettings,
router,
showActionSheetWithOptions,
mediaStatus,
selectedOptions,
]);
const derivedTargetWidth = useDerivedValue(() => {
@@ -317,10 +314,11 @@ export const PlayButton: React.FC<Props> = ({ ...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">
@@ -372,7 +370,7 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
</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}
@@ -382,7 +380,7 @@ export const PlayButton: React.FC<Props> = ({ ...props }) => {
<Text className="text-neutral-500 ml-1">
{directStream ? "Direct stream" : "Transcoded stream"}
</Text>
</View>
</View> */}
</View>
);
};

View File

@@ -41,9 +41,6 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
queryClient.invalidateQueries({
queryKey: ["seasons"],
});
queryClient.invalidateQueries({
queryKey: ["nextUp-all"],
});
queryClient.invalidateQueries({
queryKey: ["home"],
});

View File

@@ -6,9 +6,9 @@ import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
interface Props extends React.ComponentProps<typeof View> {
source: MediaSourceInfo;
source?: MediaSourceInfo;
onChange: (value: number) => void;
selected?: number | null;
selected?: number | undefined;
}
export const SubtitleTrackSelector: React.FC<Props> = ({
@@ -18,7 +18,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
...props
}) => {
const subtitleStreams = useMemo(
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
() => source?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
[source]
);

View File

@@ -16,6 +16,7 @@ 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;
@@ -32,6 +33,7 @@ export const HorizontalScroll = forwardRef<
<T,>(
{
data = [],
keyExtractor,
renderItem,
containerStyle,
contentContainerStyle,
@@ -91,6 +93,7 @@ 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">
@@ -98,6 +101,7 @@ export const HorizontalScroll = forwardRef<
</Text>
</View>
)}
{...props}
/>
);
}

View File

@@ -26,7 +26,7 @@ import { storage } from "@/utils/mmkv";
interface Props extends ViewProps {}
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
const { processes, startDownload } = useDownload();
const { processes } = useDownload();
if (processes?.length === 0)
return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
@@ -40,7 +40,7 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
<Text className="text-lg font-bold mb-2">Active downloads</Text>
<View className="space-y-2">
{processes?.map((p) => (
<DownloadCard key={p.id} process={p} />
<DownloadCard key={p.item.Id} process={p} />
))}
</View>
</View>
@@ -77,7 +77,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
await queryClient.refetchQueries({ queryKey: ["jobs"] });
}
} else {
FFmpegKit.cancel();
FFmpegKit.cancel(Number(id));
setProcesses((prev) => prev.filter((p) => p.id !== id));
}
},
@@ -85,7 +85,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
toast.success("Download canceled");
},
onError: (e) => {
console.log(e);
console.error(e);
toast.error("Could not cancel download");
},
});
@@ -95,7 +95,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const length = p?.item?.RunTimeTicks || 0;
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
return formatTimeString(timeLeft, true);
return formatTimeString(timeLeft, "tick");
};
const base64Image = useMemo(() => {

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,19 +1,20 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as Haptics from "expo-haptics";
import React, { useCallback, useMemo, useRef } from "react";
import React, {useCallback, useMemo} from "react";
import { TouchableOpacity, View } from "react-native";
import {
ActionSheetProvider,
useActionSheet,
} from "@expo/react-native-action-sheet";
import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
import { Text } from "../common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import {useDownload} from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { Image } from "expo-image";
import { ItemCardText } from "../ItemCardText";
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;
@@ -26,12 +27,12 @@ interface EpisodeCardProps {
*/
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
const { deleteFile } = useDownload();
const { openFile } = useFileOpener();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const base64Image = useMemo(() => {
return storage.getString(item.Id!);
}, []);
}, [item]);
const handleOpenFile = useCallback(() => {
openFile(item);
@@ -76,32 +77,48 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
<TouchableOpacity
onPress={handleOpenFile}
onLongPress={showActionSheet}
className="flex flex-col w-44 mr-2"
className="flex flex-col 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 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-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 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>
)}
<ItemCardText item={item} />
</View>
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">{item.Overview}</Text>
</TouchableOpacity>
);
};

View File

@@ -1,20 +1,18 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as Haptics from "expo-haptics";
import React, { useCallback, useMemo } 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 Haptics from "expo-haptics";
import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import { runtimeTicksToMinutes } from "@/utils/time";
import { Text } from "../common/Text";
import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
import { DownloadSize } from "@/components/downloads/DownloadSize";
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 { Image } from "expo-image";
import { ItemCardText } from "../ItemCardText";
interface MovieCardProps {
@@ -28,7 +26,7 @@ interface MovieCardProps {
*/
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const { deleteFile } = useDownload();
const { openFile } = useFileOpener();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const handleOpenFile = useCallback(() => {
@@ -100,6 +98,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
</View>
)}
<ItemCardText item={item} />
<DownloadSize items={[item]} />
</TouchableOpacity>
);
};

View File

@@ -1,55 +1,82 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { ScrollView, 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]);
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => {
return a.IndexNumber! > b.IndexNumber! ? 1 : -1;
};
}, [showActionSheetWithOptions, deleteSeries]);
return (
<View>
<View className="flex flex-row items-center justify-between px-4">
<Text className="text-lg 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 px-4">TV-Series</Text>
{groupBySeason.map((seasonItems, seasonIndex) => (
<View key={seasonIndex}>
<Text className="mb-2 font-semibold px-4">
{seasonItems[0].SeasonName}
</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{seasonItems.sort(sortByIndex)?.map((item, index) => (
<EpisodeCard item={item} key={index} />
))}
</View>
</ScrollView>
</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

@@ -28,11 +28,15 @@ export const ScrollingCollectionList: React.FC<Props> = ({
queryKey,
...props
}) => {
// console.log(queryKey);
const { data, isLoading } = useQuery({
queryKey,
queryKey: queryKey,
queryFn,
enabled: !disabled,
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
});
if (disabled || !title) return null;

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

@@ -103,7 +103,7 @@ export const SongsListItem: React.FC<Props> = ({
});
} else {
console.log("Playing on device", data.url, item.Id);
router.push("/play-music");
router.push("/music-player");
}
}, []);

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,24 +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}
keyExtractor={(i, idx) => i.Id.toString()}
height={247}
data={item?.People || []}
renderItem={(item, index) => (
data={destinctPeople}
renderItem={(i) => (
<TouchableOpacity
onPress={() => {
router.push(`/actors/${item.Id}`);
router.push(`/actors/${i.Id}`);
}}
key={item.Id}
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

@@ -0,0 +1,112 @@
import {BaseItemDto} from "@jellyfin/sdk/lib/generated-client/models";
import {useEffect, useMemo} from "react";
import {TouchableOpacity, View} from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import {Text} from "../common/Text";
type Props = {
item: BaseItemDto;
seasons: BaseItemDto[];
initialSeasonIndex?: number;
state: SeasonIndexState;
onSelect: (season: BaseItemDto) => void
};
type SeasonKeys = {
id: keyof BaseItemDto,
title: keyof BaseItemDto,
index: keyof BaseItemDto
}
export type SeasonIndexState = {
[seriesId: string]: number | null | undefined;
};
export const SeasonDropdown: React.FC<Props> = ({
item,
seasons,
initialSeasonIndex,
state,
onSelect
}) => {
const keys = useMemo<SeasonKeys>(() =>
item.Type === "Episode" ? {
id: "ParentId",
title: "SeasonName",
index: "ParentIndexNumber"
}
: {
id: "Id",
title: "Name",
index: "IndexNumber"
}, [item]
);
const seasonIndex = useMemo(() => state[item[keys.id] ?? ""], [state]);
useEffect(() => {
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
let initialIndex: number | undefined;
if (initialSeasonIndex !== undefined) {
// Use the provided initialSeasonIndex if it exists in the seasons
const seasonExists = seasons.some(
(season: any) => season[keys.index] === initialSeasonIndex
);
if (seasonExists) {
initialIndex = initialSeasonIndex;
}
}
if (initialIndex === undefined) {
// Fall back to the previous logic if initialIndex is not set
const season1 = seasons.find((season: any) => season[keys.index] === 1);
const season0 = seasons.find((season: any) => season[keys.index] === 0);
const firstSeason = season1 || season0 || seasons[0];
onSelect(firstSeason)
}
if (initialIndex !== undefined) {
const initialSeason = seasons.find((season: any) =>
season[keys.index] === initialIndex
)
if (initialSeason) onSelect(initialSeason!)
else throw Error("Initial index could not be found!")
}
}
}, [seasons, seasonIndex, item[keys.id], initialSeasonIndex]);
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => a[keys.index] - b[keys.index];
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-row px-4">
<TouchableOpacity
className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>Season {seasonIndex}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
{seasons?.sort(sortByIndex).map((season: any) => (
<DropdownMenu.Item
key={season[keys.title]}
onSelect={() => onSelect(season)}
>
<DropdownMenu.ItemTitle>{season[keys.title]}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};

View File

@@ -2,30 +2,24 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runtimeTicksToSeconds } from "@/utils/time";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { View } from "react-native";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { DownloadItem } from "../DownloadItem";
import {DownloadItems, DownloadSingleItem} from "../DownloadItem";
import { Loader } from "../Loader";
import { Text } from "../common/Text";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Image } from "expo-image";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import {SeasonDropdown, SeasonIndexState} from "@/components/series/SeasonDropdown";
import {Ionicons, MaterialCommunityIcons} from "@expo/vector-icons";
type Props = {
item: BaseItemDto;
initialSeasonIndex?: number;
};
type SeasonIndexState = {
[seriesId: string]: number;
};
export const seasonIndexAtom = atom<SeasonIndexState>({});
export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
@@ -35,8 +29,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
const seasonIndex = seasonIndexState[item.Id ?? ""];
const router = useRouter();
const { data: seasons } = useQuery({
queryKey: ["seasons", item.Id],
queryFn: async () => {
@@ -61,37 +53,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
enabled: !!api && !!user?.Id && !!item.Id,
});
useEffect(() => {
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
let initialIndex: number | undefined;
if (initialSeasonIndex !== undefined) {
// Use the provided initialSeasonIndex if it exists in the seasons
const seasonExists = seasons.some(
(season: any) => season.IndexNumber === initialSeasonIndex
);
if (seasonExists) {
initialIndex = initialSeasonIndex;
}
}
if (initialIndex === undefined) {
// Fall back to the previous logic if initialIndex is not set
const season1 = seasons.find((season: any) => season.IndexNumber === 1);
const season0 = seasons.find((season: any) => season.IndexNumber === 0);
const firstSeason = season1 || season0 || seasons[0];
initialIndex = firstSeason.IndexNumber;
}
if (initialIndex !== undefined) {
setSeasonIndexState((prev) => ({
...prev,
[item.Id ?? ""]: initialIndex,
}));
}
}
}, [seasons, seasonIndex, setSeasonIndexState, item.Id, initialSeasonIndex]);
const selectedSeasonId: string | null = useMemo(
() =>
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
@@ -148,39 +109,27 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
minHeight: 144 * nrOfEpisodes,
}}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-row px-4">
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>Season {seasonIndex}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
{seasons?.map((season: any) => (
<DropdownMenu.Item
key={season.Name}
onSelect={() => {
setSeasonIndexState((prev) => ({
...prev,
[item.Id ?? ""]: season.IndexNumber,
}));
}}
>
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
<View className="flex flex-row justify-start items-center">
<SeasonDropdown
item={item}
seasons={seasons}
state={seasonIndexState}
onSelect={(season) => {
setSeasonIndexState((prev) => ({
...prev,
[item.Id ?? ""]: season.IndexNumber,
}));
}} />
<DownloadItems
items={episodes || []}
MissingDownloadIconComponent={() => (
<MaterialCommunityIcons name="download-multiple" size={24} color="white"/>
)}
DownloadedIconComponent={() => (
<MaterialCommunityIcons name="check-all" size={24} color="#9333ea"/>
)}
/>
</View>
<View className="px-4 flex flex-col my-4">
{isFetching ? (
<View
@@ -218,7 +167,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
</Text>
</View>
<View className="self-start ml-auto -mt-0.5">
<DownloadItem item={e} />
<DownloadSingleItem item={e} />
</View>
</View>

View File

@@ -3,6 +3,7 @@ import { TouchableOpacity, View, ViewProps } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { LANGUAGES } from "@/constants/Languages";
import { TextInput } from "react-native-gesture-handler";
interface Props extends ViewProps {}
@@ -122,6 +123,45 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
</DropdownMenu.Root>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Subtitle Size</Text>
<Text className="text-xs opacity-50">
Choose a default subtitle size for direct play (only works for
some subtitle formats).
</Text>
</View>
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() =>
updateSettings({
subtitleSize: Math.max(0, settings.subtitleSize - 5),
})
}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
{settings.subtitleSize}
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() =>
updateSettings({
subtitleSize: Math.min(120, settings.subtitleSize + 5),
})
}
>
<Text>+</Text>
</TouchableOpacity>
</View>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4

View File

@@ -4,21 +4,21 @@ import {
getOrSetDeviceId,
userAtom,
} from "@/providers/JellyfinProvider";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import {ScreenOrientationEnum, Settings, useSettings} from "@/utils/atoms/settings";
import {
BACKGROUND_FETCH_TASK,
registerBackgroundFetchAsync,
unregisterBackgroundFetchAsync,
} from "@/utils/background-tasks";
import { getStatistics } from "@/utils/optimize-server";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import * as BackgroundFetch from "expo-background-fetch";
import * as ScreenOrientation from "expo-screen-orientation";
import * as TaskManager from "expo-task-manager";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import {useEffect, useState} from "react";
import {
ActivityIndicator,
Linking,
Switch,
TouchableOpacity,
@@ -32,8 +32,7 @@ import { Input } from "../common/Input";
import { Text } from "../common/Text";
import { Loader } from "../Loader";
import { MediaToggles } from "./MediaToggles";
import axios from "axios";
import { getStatistics } from "@/utils/optimize-server";
import {Stepper} from "@/components/inputs/Stepper";
interface Props extends ViewProps {}
@@ -64,7 +63,7 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
if (settings?.autoDownload === true && !registered) {
registerBackgroundFetchAsync();
toast.success("Background downlodas enabled");
toast.success("Background downloads enabled");
} else if (settings?.autoDownload === false && registered) {
unregisterBackgroundFetchAsync();
toast.info("Background downloads disabled");
@@ -248,22 +247,6 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
</DropdownMenu.Root>
</View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col shrink">
<Text className="font-semibold">Use external player (VLC)</Text>
<Text className="text-xs opacity-50 shrink">
Open all videos in VLC instead of the default player. This
requries VLC to be installed on the phone.
</Text>
</View>
<Switch
value={settings.openInVLC}
onValueChange={(value) => {
updateSettings({ openInVLC: value, forceDirectPlay: value });
}}
/>
</View>
<View className="flex flex-col">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col">
@@ -334,79 +317,6 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
)}
</View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col shrink">
<Text className="font-semibold">Force direct play</Text>
<Text className="text-xs opacity-50 shrink">
This will always request direct play. This is good if you want
to try to stream movies you think the device supports.
</Text>
</View>
<Switch
value={settings.forceDirectPlay}
onValueChange={(value) =>
updateSettings({ forceDirectPlay: value })
}
/>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
${settings.forceDirectPlay ? "opacity-50 select-none" : ""}
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Device profile</Text>
<Text className="text-xs opacity-50">
A profile used for deciding what audio and video codecs the
device supports.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>{settings.deviceProfile}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ deviceProfile: "Expo" });
}}
>
<DropdownMenu.ItemTitle>Expo</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ deviceProfile: "Native" });
}}
>
<DropdownMenu.ItemTitle>Native</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="3"
onSelect={() => {
updateSettings({ deviceProfile: "Old" });
}}
>
<DropdownMenu.ItemTitle>Old</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-col">
<View
className={`
@@ -494,6 +404,26 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
</View>
)}
</View>
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="shrink">
<Text className="font-semibold">Show Custom Menu Links</Text>
<Text className="text-xs opacity-50">
Show custom menu links defined inside your Jellyfin web config.json file
</Text>
<TouchableOpacity
onPress={() =>
Linking.openURL("https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links")
}
>
<Text className="text-xs text-purple-600">More info</Text>
</TouchableOpacity>
</View>
<Switch
value={settings.showCustomMenuLinks}
onValueChange={(value) => updateSettings({ showCustomMenuLinks: value })}
/>
</View>
</View>
</View>
@@ -554,7 +484,44 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View
pointerEvents={
settings.downloadMethod === "remux" ? "auto" : "none"
}
className={`
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
${
settings.downloadMethod === "remux"
? "opacity-100"
: "opacity-50"
}`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Remux max download</Text>
<Text className="text-xs opacity-50 shrink">
This is the total media you want to be able to download at the same time.
</Text>
</View>
<Stepper
value={settings.remuxConcurrentLimit}
step={1}
min={1}
max={4}
onUpdate={(value) => updateSettings({remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"]})}
/>
</View>
<View
pointerEvents={
settings.downloadMethod === "optimized" ? "auto" : "none"
}
className={`
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
${
settings.downloadMethod === "optimized"
? "opacity-100"
: "opacity-50"
}`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Auto download</Text>
<Text className="text-xs opacity-50 shrink">

View File

@@ -1,497 +0,0 @@
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { writeToLog } from "@/utils/log";
import { formatTimeString, ticksToSeconds } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Dimensions,
Platform,
Pressable,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import Animated, {
runOnJS,
SharedValue,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { VideoRef } from "react-native-video";
import { Text } from "../common/Text";
import { Loader } from "../Loader";
interface Props {
item: BaseItemDto;
videoRef: React.MutableRefObject<VideoRef | null>;
isPlaying: boolean;
isSeeking: SharedValue<boolean>;
cacheProgress: SharedValue<number>;
progress: SharedValue<number>;
isBuffering: boolean;
showControls: boolean;
ignoreSafeAreas?: boolean;
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
enableTrickplay?: boolean;
togglePlay: (ticks: number) => void;
setShowControls: (shown: boolean) => void;
}
export const Controls: React.FC<Props> = ({
item,
videoRef,
togglePlay,
isPlaying,
isSeeking,
progress,
isBuffering,
cacheProgress,
showControls,
setShowControls,
ignoreSafeAreas,
setIgnoreSafeAreas,
enableTrickplay = true,
}) => {
const [settings] = useSettings();
const router = useRouter();
const insets = useSafeAreaInsets();
const { setPlaySettings } = usePlaySettings();
const windowDimensions = Dimensions.get("window");
const { previousItem, nextItem } = useAdjacentItems({ item });
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
item,
enableTrickplay
);
const [currentTime, setCurrentTime] = useState(0); // Seconds
const [remainingTime, setRemainingTime] = useState(0); // Seconds
const min = useSharedValue(0);
const max = useSharedValue(item.RunTimeTicks || 0);
const wasPlayingRef = useRef(false);
const { showSkipButton, skipIntro } = useIntroSkipper(
item.Id,
currentTime,
videoRef
);
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
item.Id,
currentTime,
videoRef
);
const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(previousItem, settings);
setPlaySettings({
item: previousItem,
bitrate,
mediaSource,
audioIndex,
subtitleIndex,
});
router.replace("/play-video");
}, [previousItem, settings]);
const goToNextItem = useCallback(() => {
if (!nextItem || !settings) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(nextItem, settings);
setPlaySettings({
item: nextItem,
bitrate,
mediaSource,
audioIndex,
subtitleIndex,
});
router.replace("/play-video");
}, [nextItem, settings]);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = ticksToSeconds(currentProgress);
const remaining = ticksToSeconds(maxValue - currentProgress);
setCurrentTime(current);
setRemainingTime(remaining);
if (currentProgress === maxValue) {
setShowControls(true);
// Automatically play the next item if it exists
goToNextItem();
}
},
[goToNextItem]
);
useAnimatedReaction(
() => ({
progress: progress.value,
max: max.value,
isSeeking: isSeeking.value,
}),
(result) => {
if (result.isSeeking === false) {
runOnJS(updateTimes)(result.progress, result.max);
}
},
[updateTimes]
);
useEffect(() => {
if (item) {
progress.value = item?.UserData?.PlaybackPositionTicks || 0;
max.value = item.RunTimeTicks || 0;
}
}, [item]);
const toggleControls = () => setShowControls(!showControls);
const handleSliderComplete = useCallback((value: number) => {
progress.value = value;
isSeeking.value = false;
videoRef.current?.seek(Math.max(0, Math.floor(value / 10000000)));
if (wasPlayingRef.current === true) videoRef.current?.resume();
}, []);
const handleSliderChange = (value: number) => {
calculateTrickplayUrl(value);
};
const handleSliderStart = useCallback(() => {
if (showControls === false) return;
wasPlayingRef.current = isPlaying;
videoRef.current?.pause();
isSeeking.value = true;
}, [showControls, isPlaying]);
const handleSkipBackward = useCallback(async () => {
console.log("handleSkipBackward");
if (!settings?.rewindSkipTime) return;
wasPlayingRef.current = isPlaying;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
setTimeout(() => {
if (wasPlayingRef.current === true) videoRef.current?.resume();
}, 10);
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings, isPlaying]);
const handleSkipForward = useCallback(async () => {
console.log("handleSkipForward");
if (!settings?.forwardSkipTime) return;
wasPlayingRef.current = isPlaying;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
setTimeout(() => {
if (wasPlayingRef.current === true) videoRef.current?.resume();
}, 10);
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings, isPlaying]);
const toggleIgnoreSafeAreas = useCallback(() => {
setIgnoreSafeAreas((prev) => !prev);
}, []);
return (
<View
style={[
{
position: "absolute",
top: 0,
left: 0,
width: windowDimensions.width,
height: windowDimensions.height,
},
]}
>
<View
style={[
{
position: "absolute",
bottom: insets.bottom + 97,
right: insets.right,
},
]}
className={`z-10 p-4
${showSkipButton ? "opacity-100" : "opacity-0"}
`}
>
<TouchableOpacity
onPress={skipIntro}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Intro</Text>
</TouchableOpacity>
</View>
<View
style={{
position: "absolute",
bottom: insets.bottom + 94,
right: insets.right,
height: 70,
}}
pointerEvents={showSkipCreditButton ? "auto" : "none"}
className={`z-10 p-4 ${
showSkipCreditButton ? "opacity-100" : "opacity-0"
}`}
>
<TouchableOpacity
onPress={skipCredit}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Credits</Text>
</TouchableOpacity>
</View>
<Pressable
onPress={() => {
toggleControls();
}}
>
<View
style={[
{
position: "absolute",
top: 0,
left: 0,
width: windowDimensions.width + 100,
height: windowDimensions.height + 100,
opacity: showControls ? 1 : 0,
},
]}
className={`bg-black/50 z-0`}
></View>
</Pressable>
<View
style={{
position: "absolute",
top: 0,
left: 0,
width: windowDimensions.width,
height: windowDimensions.height,
}}
pointerEvents="none"
className={`flex flex-col items-center justify-center
${isBuffering ? "opacity-100" : "opacity-0"}
`}
>
<Loader />
</View>
<View
style={[
{
position: "absolute",
top: insets.top,
right: insets.right,
opacity: showControls ? 1 : 0,
},
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-row items-center space-x-2 z-10 p-4`}
>
<TouchableOpacity
onPress={toggleIgnoreSafeAreas}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeAreas ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<View
style={[
{
position: "absolute",
width: windowDimensions.width - insets.left - insets.right,
maxHeight: windowDimensions.height,
left: insets.left,
bottom: Platform.OS === "ios" ? insets.bottom : insets.bottom,
opacity: showControls ? 1 : 0,
},
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-col p-4 `}
>
<View className="shrink flex flex-col justify-center h-full mb-2">
<Text className="font-bold">{item?.Name}</Text>
{item?.Type === "Episode" && (
<Text className="opacity-50">{item.SeriesName}</Text>
)}
{item?.Type === "Movie" && (
<Text className="text-xs opacity-50">{item?.ProductionYear}</Text>
)}
{item?.Type === "Audio" && (
<Text className="text-xs opacity-50">{item?.Album}</Text>
)}
</View>
<View
className={`flex flex-col-reverse py-4 px-4 rounded-2xl items-center bg-neutral-800/90`}
>
<View className="flex flex-row items-center space-x-4">
<TouchableOpacity
style={{
opacity: !previousItem ? 0.5 : 1,
}}
onPress={goToPreviousItem}
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipBackward}>
<Ionicons
name="refresh-outline"
size={26}
color="white"
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
togglePlay(progress.value);
}}
>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={30}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<Ionicons name="refresh-outline" size={26} color="white" />
</TouchableOpacity>
<TouchableOpacity
style={{
opacity: !nextItem ? 0.5 : 1,
}}
onPress={goToNextItem}
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
</View>
<View className={`flex flex-col w-full shrink`}>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#000",
heartbeatColor: "#999",
}}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
return (
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
width: tileWidth,
height: tileHeight,
marginLeft: -tileWidth / 4,
marginTop: -tileHeight / 4 - 60,
zIndex: 10,
}}
className=" bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
}}
source={{ uri: url }}
contentFit="cover"
/>
</View>
);
}}
sliderHeight={10}
thumbWidth={0}
progress={progress}
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime)}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime)}
</Text>
</View>
</View>
</View>
</View>
</View>
);
};

View File

@@ -0,0 +1,68 @@
import React, { useEffect } from "react";
import { View, StyleSheet } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import { Slider } from "react-native-awesome-slider";
import * as Brightness from "expo-brightness";
import { Ionicons } from "@expo/vector-icons";
import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
const BrightnessSlider = () => {
const brightness = useSharedValue(50);
const min = useSharedValue(0);
const max = useSharedValue(100);
useEffect(() => {
const fetchInitialBrightness = async () => {
const initialBrightness = await Brightness.getBrightnessAsync();
console.log("initialBrightness", initialBrightness);
brightness.value = initialBrightness * 100;
};
fetchInitialBrightness();
}, []);
const handleValueChange = async (value: number) => {
brightness.value = value;
await Brightness.setBrightnessAsync(value / 100);
};
return (
<View style={styles.sliderContainer}>
<Slider
progress={brightness}
minimumValue={min}
maximumValue={max}
thumbWidth={0}
onValueChange={handleValueChange}
containerStyle={{
borderRadius: 50,
}}
theme={{
minimumTrackTintColor: "#FDFDFD",
maximumTrackTintColor: "#5A5A5A",
bubbleBackgroundColor: "transparent", // Hide the value bubble
bubbleTextColor: "transparent", // Hide the value text
}}
/>
<Ionicons
name="sunny"
size={20}
color="#FDFDFD"
style={{
marginLeft: 8,
}}
/>
</View>
);
};
const styles = StyleSheet.create({
sliderContainer: {
width: 150,
display: "flex",
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
},
});
export default BrightnessSlider;

View File

@@ -0,0 +1,688 @@
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import {
TrackInfo,
VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { writeToLog } from "@/utils/log";
import {
formatTimeString,
msToTicks,
secondsToMs,
ticksToMs,
ticksToSeconds,
} from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Dimensions,
Platform,
Pressable,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
SharedValue,
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import {
SafeAreaView,
useSafeAreaInsets,
} from "react-native-safe-area-context";
import { VideoRef } from "react-native-video";
import { ControlProvider } from "./contexts/ControlContext";
import { VideoProvider } from "./contexts/VideoContext";
import * as Haptics from "expo-haptics";
import DropdownViewDirect from "./dropdown/DropdownViewDirect";
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
import BrightnessSlider from "./BrightnessSlider";
import SkipButton from "./SkipButton";
import { debounce } from "lodash";
interface Props {
item: BaseItemDto;
videoRef: React.MutableRefObject<VlcPlayerViewRef | VideoRef | null>;
isPlaying: boolean;
isSeeking: SharedValue<boolean>;
cacheProgress: SharedValue<number>;
progress: SharedValue<number>;
isBuffering: boolean;
showControls: boolean;
ignoreSafeAreas?: boolean;
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
enableTrickplay?: boolean;
togglePlay: () => void;
setShowControls: (shown: boolean) => void;
offline?: boolean;
isVideoLoaded?: boolean;
mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void;
play: (() => Promise<void>) | (() => void);
pause: () => void;
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
getSubtitleTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
setSubtitleURL?: (url: string, customName: string) => void;
setSubtitleTrack?: (index: number) => void;
setAudioTrack?: (index: number) => void;
stop?: (() => Promise<void>) | (() => void);
isVlc?: boolean;
}
export const Controls: React.FC<Props> = ({
item,
seek,
play,
pause,
togglePlay,
isPlaying,
isSeeking,
progress,
isBuffering,
cacheProgress,
showControls,
setShowControls,
ignoreSafeAreas,
setIgnoreSafeAreas,
mediaSource,
isVideoLoaded,
getAudioTracks,
getSubtitleTracks,
setSubtitleURL,
setSubtitleTrack,
setAudioTrack,
stop,
offline = false,
enableTrickplay = true,
isVlc = false,
}) => {
const [settings] = useSettings();
const router = useRouter();
const insets = useSafeAreaInsets();
const { previousItem, nextItem } = useAdjacentItems({ item });
const {
trickPlayUrl,
calculateTrickplayUrl,
trickplayInfo,
prefetchAllTrickplayImages,
} = useTrickplay(item, !offline && enableTrickplay);
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(0);
const min = useSharedValue(0);
const max = useSharedValue(item.RunTimeTicks || 0);
const wasPlayingRef = useRef(false);
const lastProgressRef = useRef<number>(0);
const { showSkipButton, skipIntro } = useIntroSkipper(
offline ? undefined : item.Id,
currentTime,
seek,
play,
isVlc
);
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
offline ? undefined : item.Id,
currentTime,
seek,
play,
isVlc
);
const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(previousItem, settings);
const queryParams = new URLSearchParams({
itemId: previousItem.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex?.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrate.toString(),
}).toString();
if (!bitrate.value) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
}, [previousItem, settings]);
const goToNextItem = useCallback(() => {
if (!nextItem || !settings) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(nextItem, settings);
const queryParams = new URLSearchParams({
itemId: nextItem.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex?.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrate.toString(),
}).toString();
if (!bitrate.value) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
}, [nextItem, settings]);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
const remaining = isVlc
? maxValue - currentProgress
: ticksToSeconds(maxValue - currentProgress);
setCurrentTime(current);
setRemainingTime(remaining);
// Currently doesm't work in VLC because of some corrupted timestamps, will need to find a workaround.
if (currentProgress === maxValue) {
setShowControls(true);
// Automatically play the next item if it exists
goToNextItem();
}
},
[goToNextItem, isVlc]
);
useAnimatedReaction(
() => ({
progress: progress.value,
max: max.value,
isSeeking: isSeeking.value,
}),
(result) => {
// console.log("Progress changed", result);
if (result.isSeeking === false) {
runOnJS(updateTimes)(result.progress, result.max);
}
},
[updateTimes]
);
useEffect(() => {
if (item) {
progress.value = isVlc
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
: item?.UserData?.PlaybackPositionTicks || 0;
max.value = isVlc
? ticksToMs(item.RunTimeTicks || 0)
: item.RunTimeTicks || 0;
}
}, [item, isVlc]);
useEffect(() => {
prefetchAllTrickplayImages();
}, []);
const toggleControls = () => setShowControls(!showControls);
const handleSliderStart = useCallback(() => {
if (showControls === false) return;
setIsSliding(true);
wasPlayingRef.current = isPlaying;
lastProgressRef.current = progress.value;
pause();
isSeeking.value = true;
}, [showControls, isPlaying]);
const [isSliding, setIsSliding] = useState(false);
const handleSliderComplete = useCallback(
async (value: number) => {
isSeeking.value = false;
progress.value = value;
setIsSliding(false);
await seek(
Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))
);
if (wasPlayingRef.current === true) play();
},
[isVlc]
);
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
const handleSliderChange = useCallback(
debounce((value: number) => {
const progressInTicks = msToTicks(value);
console.log("Progress in ticks", progressInTicks);
calculateTrickplayUrl(progressInTicks);
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTime({ hours, minutes, seconds });
}, 20), // 100ms debounce delay
[]
);
const handleSkipBackward = useCallback(async () => {
if (!settings?.rewindSkipTime) return;
wasPlayingRef.current = isPlaying;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
: Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
await seek(newTime);
if (wasPlayingRef.current === true) play();
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings, isPlaying, isVlc]);
const handleSkipForward = useCallback(async () => {
if (!settings?.forwardSkipTime) return;
wasPlayingRef.current = isPlaying;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
try {
const curr = progress.value;
console.log(curr);
if (curr !== undefined) {
const newTime = isVlc
? curr + secondsToMs(settings.forwardSkipTime)
: ticksToSeconds(curr) + settings.forwardSkipTime;
await seek(Math.max(0, newTime));
if (wasPlayingRef.current === true) play();
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings, isPlaying, isVlc]);
const toggleIgnoreSafeAreas = useCallback(() => {
setIgnoreSafeAreas((prev) => !prev);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}, []);
const memoizedRenderBubble = useCallback(() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
console.log("time, ", time);
return (
<View
style={{
position: "absolute",
left: -57,
bottom: 15,
paddingTop: 30,
paddingBottom: 5,
width: tileWidth * 1.5,
backgroundColor: "rgba(0, 0, 0, 0.6)",
justifyContent: "center",
alignItems: "center",
}}
>
<View
style={{
width: tileWidth,
height: tileHeight,
alignSelf: "center",
transform: [{ scale: 1.4 }],
borderRadius: 5,
}}
className="bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
resizeMode: "cover",
}}
source={{ uri: url }}
contentFit="cover"
/>
</View>
<Text
style={{
marginTop: 30,
fontSize: 16,
}}
>
{`${time.hours > 0 ? `${time.hours}:` : ""}${
time.minutes < 10 ? `0${time.minutes}` : time.minutes
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
</Text>
</View>
);
}, [trickPlayUrl, trickplayInfo, time]);
return (
<ControlProvider
item={item}
mediaSource={mediaSource}
isVideoLoaded={isVideoLoaded}
>
<VideoProvider
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
setAudioTrack={setAudioTrack}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
>
{!mediaSource?.TranscodingUrl ? (
<DropdownViewDirect showControls={showControls} />
) : (
<DropdownViewTranscoding showControls={showControls} />
)}
</VideoProvider>
<Pressable
onPressIn={() => {
toggleControls();
}}
style={{
position: "absolute",
width: Dimensions.get("window").width,
height: Dimensions.get("window").height,
}}
></Pressable>
<View
style={[
{
position: "absolute",
top: 0,
right: 0,
opacity: showControls ? 1 : 0,
},
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
>
{previousItem && (
<TouchableOpacity
onPress={goToPreviousItem}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
)}
{nextItem && (
<TouchableOpacity
onPress={goToNextItem}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
)}
{mediaSource?.TranscodingUrl && (
<TouchableOpacity
onPress={toggleIgnoreSafeAreas}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeAreas ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
)}
<TouchableOpacity
onPress={async () => {
if (stop) await stop();
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<View
style={{
position: "absolute",
top: "50%", // Center vertically
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
transform: [{ translateY: -22.5 }], // Adjust for the button's height (half of 45)
paddingHorizontal: "28%", // Add some padding to the left and right
opacity: showControls ? 1 : 0,
}}
pointerEvents={showControls ? "box-none" : "none"}
>
<View
style={{
position: "absolute",
alignItems: "center",
transform: [{ rotate: "270deg" }], // Rotate the slider to make it vertical
bottom: 30,
}}
>
<BrightnessSlider />
</View>
<TouchableOpacity onPress={handleSkipBackward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name="refresh-outline"
size={50}
color="white"
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{settings?.rewindSkipTime}
</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
togglePlay();
}}
>
{!isBuffering ? (
<Ionicons
name={isPlaying ? "pause" : "play"}
size={50}
color="white"
/>
) : (
<Loader size={"large"} />
)}
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name="refresh-outline" size={50} color="white" />
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{settings?.forwardSkipTime}
</Text>
</View>
</TouchableOpacity>
</View>
<View
style={[
{
position: "absolute",
right: 0,
left: 0,
bottom: 0,
opacity: showControls ? 1 : 0,
},
]}
pointerEvents={showControls ? "box-none" : "none"}
className={`flex flex-col p-4`}
>
<View
className="shrink flex flex-col justify-center h-full mb-2"
style={{
flexDirection: "row",
justifyContent: "space-between",
}}
>
<View
style={{
flexDirection: "column",
alignSelf: "flex-end", // Shrink height based on content
}}
>
<Text className="font-bold">{item?.Name}</Text>
{item?.Type === "Episode" && (
<Text className="opacity-50">{item.SeriesName}</Text>
)}
{item?.Type === "Movie" && (
<Text className="text-xs opacity-50">{item?.ProductionYear}</Text>
)}
{item?.Type === "Audio" && (
<Text className="text-xs opacity-50">{item?.Album}</Text>
)}
</View>
<View
style={{
flexDirection: "column",
alignSelf: "flex-end",
marginRight: insets.right,
}}
>
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
buttonText="Skip Intro"
/>
<SkipButton
showButton={showSkipCreditButton}
onPress={skipCredit}
buttonText="Skip Credits"
/>
</View>
</View>
<View
className={`flex flex-col-reverse py-4 pb-1 px-4 rounded-lg items-center bg-neutral-800`}
>
<View className={`flex flex-col w-full shrink`}>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#666",
heartbeatColor: "#999",
}}
renderThumb={() => (
<View
style={{
width: 18,
height: 18,
left: -2,
borderRadius: 10,
backgroundColor: "#fff",
justifyContent: "center",
alignItems: "center",
}}
/>
)}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => isSliding && memoizedRenderBubble()}
sliderHeight={10}
thumbWidth={0}
progress={progress}
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
</Text>
</View>
</View>
</View>
</View>
</ControlProvider>
);
};

View File

@@ -0,0 +1,39 @@
import React from "react";
import { View, TouchableOpacity, Text, StyleSheet } from "react-native";
interface SkipButtonProps {
onPress: () => void;
showButton: boolean;
buttonText: string;
}
const SkipButton: React.FC<SkipButtonProps> = ({
onPress,
showButton,
buttonText,
}) => {
return (
<View style={{ display: showButton ? "flex" : "none" }}>
<TouchableOpacity onPress={onPress} style={styles.button}>
<Text style={styles.text}>{buttonText}</Text>
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
button: {
backgroundColor: "rgba(0, 0, 0, 0.75)",
borderRadius: 5,
paddingHorizontal: 10,
paddingVertical: 15,
borderWidth: 2,
borderColor: "#5A5454",
},
text: {
color: "white",
fontWeight: "bold",
},
});
export default SkipButton;

View File

@@ -0,0 +1,144 @@
import { useTrickplay } from '@/hooks/useTrickplay';
import { formatTimeString, msToTicks, ticksToSeconds } from '@/utils/time';
import React, { useRef, useState } from 'react';
import { View, Text } from 'react-native';
import { Image } from "expo-image";
import { Slider } from "react-native-awesome-slider";
import { SharedValue, useSharedValue } from 'react-native-reanimated';
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
interface SliderScrubberProps {
cacheProgress: SharedValue<number>;
handleSliderStart: () => void;
handleSliderComplete: (value: number) => void;
progress: SharedValue<number>;
min: SharedValue<number>;
max: SharedValue<number>;
currentTime: number;
remainingTime: number;
item: BaseItemDto;
}
const SliderScrubber: React.FC<SliderScrubberProps> = ({
cacheProgress,
handleSliderStart,
handleSliderComplete,
progress,
min,
max,
currentTime,
remainingTime,
item,
}) => {
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
item,
);
const handleSliderChange = (value: number) => {
const progressInTicks = msToTicks(value);
calculateTrickplayUrl(progressInTicks);
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTime({ hours, minutes, seconds });
};
return (
<View className={`flex flex-col w-full shrink`}>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#000",
heartbeatColor: "#999",
}}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
return (
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
width: tileWidth,
height: tileHeight,
marginLeft: -tileWidth / 4,
marginTop: -tileHeight / 4 - 60,
zIndex: 10,
}}
className=" bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
}}
source={{ uri: url }}
contentFit="cover"
/>
<Text
style={{
position: "absolute",
bottom: 5,
left: 5,
color: "white",
backgroundColor: "rgba(0, 0, 0, 0.5)",
padding: 5,
borderRadius: 5,
}}
>
{`${time.hours > 0 ? `${time.hours}:` : ""}${
time.minutes < 10 ? `0${time.minutes}` : time.minutes
}:${
time.seconds < 10 ? `0${time.seconds}` : time.seconds
}`}
</Text>
</View>
);
}}
sliderHeight={10}
thumbWidth={0}
progress={progress}
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime, "ms")}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime, "ms")}
</Text>
</View>
</View>
);
};
export default SliderScrubber;

View File

@@ -0,0 +1,34 @@
import { TrackInfo } from '@/modules/vlc-player';
import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client';
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface ControlContextProps {
item: BaseItemDto;
mediaSource: MediaSourceInfo | null | undefined;
isVideoLoaded: boolean | undefined;
}
const ControlContext = createContext<ControlContextProps | undefined>(undefined);
interface ControlProviderProps {
children: ReactNode;
item: BaseItemDto;
mediaSource: MediaSourceInfo | null | undefined;
isVideoLoaded: boolean | undefined;
}
export const ControlProvider: React.FC<ControlProviderProps> = ({ children, item, mediaSource, isVideoLoaded }) => {
return (
<ControlContext.Provider value={{ item, mediaSource, isVideoLoaded }}>
{children}
</ControlContext.Provider>
);
};
export const useControlContext = () => {
const context = useContext(ControlContext);
if (context === undefined) {
throw new Error('useControlContext must be used within a ControlProvider');
}
return context;
};

View File

@@ -0,0 +1,98 @@
import { TrackInfo } from "@/modules/vlc-player";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import React, {
createContext,
useContext,
useState,
ReactNode,
useEffect,
} from "react";
import { useControlContext } from "./ControlContext";
interface VideoContextProps {
audioTracks: TrackInfo[] | null;
subtitleTracks: TrackInfo[] | null;
setAudioTrack: ((index: number) => void) | undefined;
setSubtitleTrack: ((index: number) => void) | undefined;
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
}
const VideoContext = createContext<VideoContextProps | undefined>(undefined);
interface VideoProviderProps {
children: ReactNode;
getAudioTracks:
| (() => Promise<TrackInfo[] | null>)
| (() => TrackInfo[])
| undefined;
getSubtitleTracks:
| (() => Promise<TrackInfo[] | null>)
| (() => TrackInfo[])
| undefined;
setAudioTrack: ((index: number) => void) | undefined;
setSubtitleTrack: ((index: number) => void) | undefined;
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
}
export const VideoProvider: React.FC<VideoProviderProps> = ({
children,
getSubtitleTracks,
getAudioTracks,
setSubtitleTrack,
setSubtitleURL,
setAudioTrack,
}) => {
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
null
);
const ControlContext = useControlContext();
const isVideoLoaded = ControlContext?.isVideoLoaded;
useEffect(() => {
const fetchTracks = async () => {
if (
getSubtitleTracks &&
(subtitleTracks === null || subtitleTracks.length === 0)
) {
const subtitles = await getSubtitleTracks();
console.log("Getting embeded subtitles...", subtitles);
setSubtitleTracks(subtitles);
}
if (
getAudioTracks &&
(audioTracks === null || audioTracks.length === 0)
) {
const audio = await getAudioTracks();
setAudioTracks(audio);
}
};
fetchTracks();
}, [isVideoLoaded, getAudioTracks, getSubtitleTracks]);
return (
<VideoContext.Provider
value={{
audioTracks,
subtitleTracks,
setSubtitleTrack,
setSubtitleURL,
setAudioTrack,
}}
>
{children}
</VideoContext.Provider>
);
};
export const useVideoContext = () => {
const context = useContext(VideoContext);
if (context === undefined) {
throw new Error("useVideoContext must be used within a VideoProvider");
}
return context;
};

View File

@@ -0,0 +1,181 @@
import React, { useMemo, useState } from "react";
import { View, TouchableOpacity } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import * as DropdownMenu from "zeego/dropdown-menu";
import { useControlContext } from "../contexts/ControlContext";
import { useVideoContext } from "../contexts/VideoContext";
import { EmbeddedSubtitle, ExternalSubtitle } from "../types";
import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useLocalSearchParams } from "expo-router";
interface DropdownViewDirectProps {
showControls: boolean;
offline?: boolean; // used to disable external subs for downloads
}
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
showControls,
offline = false,
}) => {
const api = useAtomValue(apiAtom);
const ControlContext = useControlContext();
const mediaSource = ControlContext?.mediaSource;
const item = ControlContext?.item;
const isVideoLoaded = ControlContext?.isVideoLoaded;
const videoContext = useVideoContext();
const {
subtitleTracks,
audioTracks,
setSubtitleURL,
setSubtitleTrack,
setAudioTrack,
} = videoContext;
const allSubtitleTracksForDirectPlay = useMemo(() => {
if (mediaSource?.TranscodingUrl) return null;
const embeddedSubs =
subtitleTracks
?.map((s) => ({
name: s.name,
index: s.index,
deliveryUrl: undefined,
}))
.filter((sub) => !sub.name.endsWith("[External]")) || [];
const externalSubs =
mediaSource?.MediaStreams?.filter(
(stream) => stream.Type === "Subtitle" && !!stream.DeliveryUrl
).map((s) => ({
name: s.DisplayTitle! + " [External]",
index: s.Index!,
deliveryUrl: s.DeliveryUrl,
})) || [];
// Combine embedded subs with external subs only if not offline
if (!offline) {
return [...embeddedSubs, ...externalSubs] as (
| EmbeddedSubtitle
| ExternalSubtitle
)[];
}
return embeddedSubs as EmbeddedSubtitle[];
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams, offline]);
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
const [selectedSubtitleIndex, setSelectedSubtitleIndex] = useState<Number>(
parseInt(subtitleIndex)
);
const [selectedAudioIndex, setSelectedAudioIndex] = useState<Number>(
parseInt(audioIndex)
);
return (
<View
style={{
position: "absolute",
zIndex: 1000,
opacity: showControls ? 1 : 0,
}}
className="p-4"
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="subtitle-trigger">
Subtitle
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
<DropdownMenu.CheckboxItem
key={`subtitle-item-${idx}`}
value={selectedSubtitleIndex === sub.index}
onValueChange={() => {
if ("deliveryUrl" in sub && sub.deliveryUrl) {
setSubtitleURL &&
setSubtitleURL(
api?.basePath + sub.deliveryUrl,
sub.name
);
console.log(
"Set external subtitle: ",
api?.basePath + sub.deliveryUrl
);
} else {
console.log("Set sub index: ", sub.index);
setSubtitleTrack && setSubtitleTrack(sub.index);
}
setSelectedSubtitleIndex(sub.index);
console.log("Subtitle: ", sub);
}}
>
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="audio-trigger">
Audio
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{audioTracks?.map((track, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={selectedAudioIndex === track.index}
onValueChange={() => {
setSelectedAudioIndex(track.index);
setAudioTrack && setAudioTrack(track.index);
}}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{track.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};
export default DropdownViewDirect;

View File

@@ -0,0 +1,272 @@
import React, { useCallback, useMemo, useState } from "react";
import { View, TouchableOpacity } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import * as DropdownMenu from "zeego/dropdown-menu";
import { useControlContext } from "../contexts/ControlContext";
import { useVideoContext } from "../contexts/VideoContext";
import { TranscodedSubtitle } from "../types";
import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useLocalSearchParams, useRouter } from "expo-router";
interface DropdownViewProps {
showControls: boolean;
offline?: boolean; // used to disable external subs for downloads
}
const DropdownView: React.FC<DropdownViewProps> = ({
showControls,
offline = false,
}) => {
const router = useRouter();
const api = useAtomValue(apiAtom);
const ControlContext = useControlContext();
const mediaSource = ControlContext?.mediaSource;
const item = ControlContext?.item;
const isVideoLoaded = ControlContext?.isVideoLoaded;
const videoContext = useVideoContext();
const { subtitleTracks, setSubtitleTrack } = videoContext;
const { subtitleIndex, audioIndex, bitrateValue } = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
// Either its on a text subtitle or its on not on any subtitle therefore it should show all the embedded HLS subtitles.
const isOnTextSubtitle =
mediaSource?.MediaStreams?.find(
(x) => x.Index === parseInt(subtitleIndex) && x.IsTextSubtitleStream
) || subtitleIndex === "-1";
const allSubs =
mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [];
const textBasedSubs = allSubs.filter((x) => x.IsTextSubtitleStream);
// This is used in the case where it is transcoding stream.
const chosenSubtitle = textBasedSubs.find(
(x) => x.Index === parseInt(subtitleIndex)
);
let initialSubtitleIndex = -1;
if (!isOnTextSubtitle) {
initialSubtitleIndex = parseInt(subtitleIndex);
} else if (chosenSubtitle) {
initialSubtitleIndex = textBasedSubs.indexOf(chosenSubtitle);
}
const [selectedSubtitleIndex, setSelectedSubtitleIndex] =
useState<number>(initialSubtitleIndex);
const [selectedAudioIndex, setSelectedAudioIndex] = useState<number>(
parseInt(audioIndex)
);
const allSubtitleTracksForTranscodingStream = useMemo(() => {
const disableSubtitle = {
name: "Disable",
index: -1,
IsTextSubtitleStream: true,
} as TranscodedSubtitle;
if (isOnTextSubtitle) {
const textSubtitles =
subtitleTracks?.map((s) => ({
name: s.name,
index: s.index,
IsTextSubtitleStream: true,
})) || [];
const imageSubtitles = allSubs
.filter((x) => !x.IsTextSubtitleStream)
.map(
(x) =>
({
name: x.DisplayTitle!,
index: x.Index!,
IsTextSubtitleStream: x.IsTextSubtitleStream,
} as TranscodedSubtitle)
);
const textSubtitlesMap = new Map(textSubtitles.map((s) => [s.name, s]));
const imageSubtitlesMap = new Map(imageSubtitles.map((s) => [s.name, s]));
const sortedSubtitles = Array.from(
new Set(
allSubs
.map((sub) => {
const displayTitle = sub.DisplayTitle ?? "";
if (textSubtitlesMap.has(displayTitle)) {
return textSubtitlesMap.get(displayTitle);
}
if (imageSubtitlesMap.has(displayTitle)) {
return imageSubtitlesMap.get(displayTitle);
}
return null;
})
.filter(
(subtitle): subtitle is TranscodedSubtitle => subtitle !== null
)
)
);
return [disableSubtitle, ...sortedSubtitles];
}
const transcodedSubtitle: TranscodedSubtitle[] = allSubs.map((x) => ({
name: x.DisplayTitle!,
index: x.Index!,
IsTextSubtitleStream: x.IsTextSubtitleStream!,
}));
return [disableSubtitle, ...transcodedSubtitle];
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
const ChangeTranscodingSubtitle = useCallback(
(subtitleIndex: number) => {
const queryParams = new URLSearchParams({
itemId: item.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex?.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue,
}).toString();
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
},
[mediaSource]
);
// Audio tracks for transcoding streams.
const allAudio =
mediaSource?.MediaStreams?.filter((x) => x.Type === "Audio").map((x) => ({
name: x.DisplayTitle!,
index: x.Index!,
})) || [];
const ChangeTranscodingAudio = useCallback(
(audioIndex: number, currentSelectedSubtitleIndex: number) => {
let newSubtitleIndex: number;
if (!isOnTextSubtitle) {
newSubtitleIndex = parseInt(subtitleIndex);
} else if (
currentSelectedSubtitleIndex >= 0 &&
currentSelectedSubtitleIndex < textBasedSubs.length
) {
console.log("setHere SubtitleIndex", currentSelectedSubtitleIndex);
newSubtitleIndex = textBasedSubs[currentSelectedSubtitleIndex].Index!;
console.log("newSubtitleIndex", newSubtitleIndex);
} else {
newSubtitleIndex = -1;
}
const queryParams = new URLSearchParams({
itemId: item.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: newSubtitleIndex?.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue,
}).toString();
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
},
[mediaSource]
);
return (
<View
style={{
position: "absolute",
zIndex: 1000,
opacity: showControls ? 1 : 0,
}}
className="p-4"
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="subtitle-trigger">
Subtitle
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{allSubtitleTracksForTranscodingStream?.map(
(sub, idx: number) => (
<DropdownMenu.CheckboxItem
value={selectedSubtitleIndex === sub.index}
key={`subtitle-item-${idx}`}
onValueChange={() => {
console.log("sub", sub);
if (selectedSubtitleIndex === sub?.index) return;
setSelectedSubtitleIndex(sub.index);
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
setSubtitleTrack && setSubtitleTrack(sub.index);
return;
}
ChangeTranscodingSubtitle(sub.index);
}}
>
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
)
)}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="audio-trigger">
Audio
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{allAudio?.map((track, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={selectedAudioIndex === track.index}
onValueChange={() => {
if (selectedAudioIndex === track.index) return;
setSelectedAudioIndex(track.index);
ChangeTranscodingAudio(track.index, selectedSubtitleIndex);
}}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{track.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};
export default DropdownView;

View File

@@ -0,0 +1,19 @@
type EmbeddedSubtitle = {
name: string;
index: number;
};
type ExternalSubtitle = {
name: string;
index: number;
isExternal: boolean;
deliveryUrl: string;
};
type TranscodedSubtitle = {
name: string;
index: number;
IsTextSubtitleStream: boolean;
};
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle };

View File

@@ -0,0 +1,73 @@
import {
TrackInfo,
VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types";
import React, { useEffect, useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "../common/Text";
interface Props extends ViewProps {
playerRef: React.RefObject<VlcPlayerViewRef>;
}
export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
null
);
useEffect(() => {
const fetchTracks = async () => {
if (playerRef.current) {
const audio = await playerRef.current.getAudioTracks();
const subtitles = await playerRef.current.getSubtitleTracks();
setAudioTracks(audio);
setSubtitleTracks(subtitles);
}
};
fetchTracks();
}, [playerRef]);
const insets = useSafeAreaInsets();
return (
<View
style={{
position: "absolute",
top: insets.top,
left: insets.left + 8,
zIndex: 100,
}}
{...props}
>
<Text className="font-bold">Playback State:</Text>
<Text className="font-bold mt-2.5">Audio Tracks:</Text>
{audioTracks &&
audioTracks.map((track, index) => (
<Text key={index}>
{track.name} (Index: {track.index})
</Text>
))}
<Text className="font-bold mt-2.5">Subtitle Tracks:</Text>
{subtitleTracks &&
subtitleTracks.map((track, index) => (
<Text key={index}>
{track.name} (Index: {track.index})
</Text>
))}
<TouchableOpacity
className="mt-2.5 bg-blue-500 p-2 rounded"
onPress={() => {
if (playerRef.current) {
playerRef.current.getAudioTracks().then(setAudioTracks);
playerRef.current.getSubtitleTracks().then(setSubtitleTracks);
}
}}
>
<Text className="text-white text-center">Refresh Tracks</Text>
</TouchableOpacity>
</View>
);
};

View File

@@ -22,13 +22,13 @@
}
},
"production": {
"channel": "0.18.0",
"channel": "0.22.0",
"android": {
"image": "latest"
}
},
"production-apk": {
"channel": "0.18.0",
"channel": "0.22.0",
"android": {
"buildType": "apk",
"image": "latest"

15
edge-to-edge-fix.patch Normal file
View File

@@ -0,0 +1,15 @@
--- expo.js.original 2024-11-10 09:08:19
+++ node_modules/react-native-edge-to-edge/dist/commonjs/expo.js 2024-11-10 09:08:23
@@ -19,10 +19,8 @@
const {
barStyle
} = androidStatusBar;
+ const android = props?.android || {};
const {
- android = {}
- } = props;
- const {
parentTheme = "Default"
} = android;
config.modResults.resources.style = config.modResults.resources.style?.map(style => {
\ No newline at end of file

View File

@@ -18,7 +18,6 @@ export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
const parentId = item?.AlbumId || item?.ParentId;
const indexNumber = item?.IndexNumber;
console.log("Getting previous item for " + indexNumber);
if (
!api ||
!parentId ||
@@ -26,12 +25,6 @@ export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
indexNumber === null ||
indexNumber - 1 < 1
) {
console.log("No previous item", {
itemIndex: indexNumber,
itemId: item?.Id,
parentId: parentId,
indexNumber: indexNumber,
});
return null;
}

View File

@@ -1,17 +0,0 @@
import * as NavigationBar from "expo-navigation-bar";
import { useEffect } from "react";
import { Platform } from "react-native";
export const useAndroidNavigationBar = () => {
useEffect(() => {
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("hidden");
NavigationBar.setBehaviorAsync("overlay-swipe");
return () => {
NavigationBar.setVisibilityAsync("visible");
NavigationBar.setBehaviorAsync("inset-swipe");
};
}
}, []);
};

View File

@@ -4,6 +4,7 @@ import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import { msToSeconds, secondsToMs } from "@/utils/time";
interface CreditTimestamps {
Introduction: {
@@ -21,16 +22,29 @@ interface CreditTimestamps {
export const useCreditSkipper = (
itemId: string | undefined,
currentTime: number,
videoRef: React.RefObject<any>
seek: (time: number) => void,
play: () => void,
isVlc: boolean = false
) => {
const [api] = useAtom(apiAtom);
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
if (isVlc) {
currentTime = msToSeconds(currentTime);
}
const wrappedSeek = (seconds: number) => {
if (isVlc) {
seek(secondsToMs(seconds));
return;
}
seek(seconds);
};
const { data: creditTimestamps } = useQuery<CreditTimestamps | null>({
queryKey: ["creditTimestamps", itemId],
queryFn: async () => {
if (!itemId) {
console.log("No item id");
return null;
}
@@ -61,17 +75,17 @@ export const useCreditSkipper = (
}, [creditTimestamps, currentTime]);
const skipCredit = useCallback(() => {
console.log("skipCredits");
if (!creditTimestamps || !videoRef.current) return;
if (!creditTimestamps) return;
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
try {
videoRef.current.seek(creditTimestamps.Credits.End);
wrappedSeek(creditTimestamps.Credits.End);
setTimeout(() => {
videoRef.current?.resume();
play();
}, 200);
} catch (error) {
writeToLog("ERROR", "Error skipping intro", error);
}
}, [creditTimestamps, videoRef]);
}, [creditTimestamps]);
return { showSkipCreditButton, skipCredit };
};

View File

@@ -0,0 +1,54 @@
import { Bitrate, BITRATES } from "@/components/BitrateSelector";
import { Settings } from "@/utils/atoms/settings";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { useMemo } from "react";
const useDefaultPlaySettings = (
item: BaseItemDto,
settings: Settings | null
) => {
const playSettings = useMemo(() => {
// 1. Get first media source
const mediaSource = item.MediaSources?.[0];
// 2. Get default or preferred audio
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
(x) => x.Language === settings?.defaultAudioLanguage
)?.Index;
const firstAudioIndex = mediaSource?.MediaStreams?.find(
(x) => x.Type === "Audio"
)?.Index;
// 3. Get default or preferred subtitle
const preferedSubtitleIndex = mediaSource?.MediaStreams?.find(
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
)?.Index;
const defaultSubtitleIndex = mediaSource?.MediaStreams?.find(
(stream) => stream.Type === "Subtitle" && stream.IsDefault
)?.Index;
// 4. Get default bitrate
const bitrate = BITRATES[0];
return {
defaultAudioIndex:
preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined,
defaultSubtitleIndex:
preferedSubtitleIndex || defaultSubtitleIndex || undefined,
defaultMediaSource: mediaSource || undefined,
defaultBitrate: bitrate || undefined,
};
}, [
item.MediaSources,
settings?.defaultAudioLanguage,
settings?.defaultSubtitleLanguage,
]);
return playSettings;
};
export default useDefaultPlaySettings;

View File

@@ -1,5 +1,3 @@
// hooks/useFileOpener.ts
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { writeToLog } from "@/utils/log";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
@@ -7,46 +5,44 @@ import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router";
import { useCallback } from "react";
export const useFileOpener = () => {
export const getDownloadedFileUrl = async (itemId: string): Promise<string> => {
const directory = FileSystem.documentDirectory;
if (!directory) {
throw new Error("Document directory is not available");
}
if (!itemId) {
throw new Error("Item ID is not available");
}
const files = await FileSystem.readDirectoryAsync(directory);
const path = itemId!;
const matchingFile = files.find((file) => file.startsWith(path));
if (!matchingFile) {
throw new Error(`No file found for item ${path}`);
}
return `${directory}${matchingFile}`;
};
export const useDownloadedFileOpener = () => {
const router = useRouter();
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
const openFile = useCallback(async (item: BaseItemDto) => {
const directory = FileSystem.documentDirectory;
if (!directory) {
throw new Error("Document directory is not available");
}
if (!item.Id) {
throw new Error("Item ID is not available");
}
try {
const files = await FileSystem.readDirectoryAsync(directory);
for (let f of files) {
console.log(f);
const openFile = useCallback(
async (item: BaseItemDto) => {
try {
// @ts-expect-error
router.push("/player/direct-player?offline=true&itemId=" + item.Id);
} catch (error) {
writeToLog("ERROR", "Error opening file", error);
console.error("Error opening file:", error);
}
const path = item.Id!;
const matchingFile = files.find((file) => file.startsWith(path));
if (!matchingFile) {
throw new Error(`No file found for item ${path}`);
}
const url = `${directory}${matchingFile}`;
setOfflineSettings({
item,
});
setPlayUrl(url);
router.push("/play-offline-video");
} catch (error) {
writeToLog("ERROR", "Error opening file", error);
console.error("Error opening file:", error);
}
}, []);
},
[setOfflineSettings, setPlayUrl, router]
);
return { openFile };
};

View File

@@ -54,7 +54,6 @@ export const useImageColors = ({
// If colors are cached, use them and exit
if (_primary && _text) {
console.info("useImageColors ~ Using cached colors for performance.");
setPrimaryColor({
primary: _primary,
text: _text,

View File

@@ -1,14 +1,11 @@
import { useState, useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as FileSystem from "expo-file-system";
import { storage } from "@/utils/mmkv";
import { useCallback } from "react";
const useImageStorage = () => {
const saveBase64Image = useCallback(async (base64: string, key: string) => {
try {
// Save the base64 string to AsyncStorage
// Save the base64 string to storage
storage.set(key, base64);
console.log("Image saved successfully");
} catch (error) {
console.error("Error saving image:", error);
throw error;
@@ -70,7 +67,7 @@ const useImageStorage = () => {
const loadImage = useCallback(async (key: string) => {
try {
// Retrieve the base64 string from AsyncStorage
// Retrieve the base64 string from storage
const base64Image = storage.getString(key);
if (base64Image !== null) {
// Set the loaded image state

View File

@@ -4,6 +4,7 @@ import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import { msToSeconds, secondsToMs } from "@/utils/time";
interface IntroTimestamps {
EpisodeId: string;
@@ -14,19 +15,36 @@ interface IntroTimestamps {
Valid: boolean;
}
/**
* Custom hook to handle skipping intros in a media player.
*
* @param {number} currentTime - The current playback time in seconds.
*/
export const useIntroSkipper = (
itemId: string | undefined,
currentTime: number,
videoRef: React.RefObject<any>
seek: (ticks: number) => void,
play: () => void,
isVlc: boolean = false
) => {
const [api] = useAtom(apiAtom);
const [showSkipButton, setShowSkipButton] = useState(false);
if (isVlc) {
currentTime = msToSeconds(currentTime);
}
const wrappedSeek = (seconds: number) => {
if (isVlc) {
seek(secondsToMs(seconds));
return;
}
seek(seconds);
};
const { data: introTimestamps } = useQuery<IntroTimestamps | null>({
queryKey: ["introTimestamps", itemId],
queryFn: async () => {
if (!itemId) {
console.log("No item id");
return null;
}
@@ -58,16 +76,16 @@ export const useIntroSkipper = (
const skipIntro = useCallback(() => {
console.log("skipIntro");
if (!introTimestamps || !videoRef.current) return;
if (!introTimestamps) return;
try {
videoRef.current.seek(introTimestamps.IntroEnd);
wrappedSeek(introTimestamps.IntroEnd);
setTimeout(() => {
videoRef.current?.resume();
play();
}, 200);
} catch (error) {
writeToLog("ERROR", "Error skipping intro", error);
}
}, [introTimestamps, videoRef]);
}, [introTimestamps]);
return { showSkipButton, skipIntro };
};

View File

@@ -1,27 +0,0 @@
// hooks/useNavigationBarVisibility.ts
import { useEffect } from "react";
import { Platform } from "react-native";
import * as NavigationBar from "expo-navigation-bar";
export const useNavigationBarVisibility = (isPlaying: boolean | null) => {
useEffect(() => {
const handleVisibility = async () => {
if (Platform.OS === "android") {
if (isPlaying) {
await NavigationBar.setVisibilityAsync("hidden");
} else {
await NavigationBar.setVisibilityAsync("visible");
}
}
};
handleVisibility();
return () => {
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("visible");
}
};
}, [isPlaying]);
};

View File

@@ -24,5 +24,5 @@ export const useOrientation = () => {
};
}, []);
return { orientation };
return { orientation, setOrientation };
};

View File

@@ -1,18 +1,42 @@
import { useCallback } from "react";
import { useAtom, useAtomValue } from "jotai";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as FileSystem from "expo-file-system";
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { writeToLog } from "@/utils/log";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner-native";
import { useDownload } from "@/providers/DownloadProvider";
import { useRouter } from "expo-router";
import { JobStatus } from "@/utils/optimize-server";
import useImageStorage from "./useImageStorage";
import { getItemImage } from "@/utils/getItemImage";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getItemImage } from "@/utils/getItemImage";
import {writeErrorLog, writeInfoLog, writeToLog} from "@/utils/log";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router";
import {FFmpegKit, FFmpegSession, Statistics} from "ffmpeg-kit-react-native";
import {useAtomValue} from "jotai";
import {useCallback} from "react";
import { toast } from "sonner-native";
import useImageStorage from "./useImageStorage";
import useDownloadHelper from "@/utils/download";
import {Api} from "@jellyfin/sdk";
import {useSettings} from "@/utils/atoms/settings";
import {JobStatus} from "@/utils/optimize-server";
const createFFmpegCommand = (url: string, output: string) => [
"-y", // overwrite output files without asking
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
// region ffmpeg protocol commands // https://ffmpeg.org/ffmpeg-protocols.html
"-protocol_whitelist file,http,https,tcp,tls,crypto", // whitelist
"-multiple_requests 1", // http
"-tcp_nodelay 1", // http
// endregion ffmpeg protocol commands
"-fflags +genpts", // format flags
`-i ${url}`, // infile
"-map 0:v -map 0:a", // select all streams for video & audio
"-c copy", // streamcopy, preventing transcoding
"-bufsize 25M", // amount of data processed before calculating current bitrate
"-max_muxing_queue_size 4096", // sets the size of stream buffer in packets for output
output
]
/**
* Custom hook for remuxing HLS to MP4 using FFmpeg.
@@ -21,35 +45,116 @@ import { apiAtom } from "@/providers/JellyfinProvider";
* @param item - The BaseItemDto object representing the media item
* @returns An object with remuxing-related functions
*/
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
export const useRemuxHlsToMp4 = () => {
const api = useAtomValue(apiAtom);
const queryClient = useQueryClient();
const { saveDownloadedItemInfo, setProcesses } = useDownload();
const router = useRouter();
const { loadImage, saveImage, image2Base64, saveBase64Image } =
useImageStorage();
const queryClient = useQueryClient();
if (!item.Id || !item.Name) {
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
throw new Error("Item must have an Id and Name");
const [settings] = useSettings();
const {saveImage} = useImageStorage();
const {saveSeriesPrimaryImage} = useDownloadHelper();
const {saveDownloadedItemInfo, setProcesses, processes} = useDownload();
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
await saveSeriesPrimaryImage(item);
const itemImage = getItemImage({
item,
api,
variant: "Primary",
quality: 90,
width: 500,
});
await saveImage(item.Id, itemImage?.uri);
}
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
const completeCallback = useCallback(async (session: FFmpegSession, item: BaseItemDto) => {
try {
let endTime;
const returnCode = await session.getReturnCode();
const startTime = new Date();
if (returnCode.isValueSuccess()) {
endTime = new Date();
const stat = await session.getLastReceivedStatistics();
await queryClient.invalidateQueries({queryKey: ["downloadedItems"]});
saveDownloadedItemInfo(item, stat.getSize());
writeInfoLog(
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name},
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s`
.replace(/^ +/g, '')
)
toast.success("Download completed");
} else if (returnCode.isValueError()) {
endTime = new Date();
const allLogs = session.getAllLogsAsString();
writeErrorLog(
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s. All logs: ${allLogs}`
.replace(/^ +/g, '')
)
} else if (returnCode.isValueCancel()) {
endTime = new Date();
writeInfoLog(
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name},
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s`
.replace(/^ +/g, '')
)
}
setProcesses((prev) => {
return prev.filter((process) => process.itemId !== item.Id);
});
} catch (e) {
const error = e as Error;
writeErrorLog(
`useRemuxHlsToMp4 ~ Exception during remuxing for item: ${item.Name},
Error: ${error.message}, Stack: ${error.stack}`
.replace(/^ +/g, '')
);
}
}, [processes, setProcesses]);
const statisticsCallback = useCallback((statistics: Statistics, item: BaseItemDto) => {
const videoLength = (item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
const totalFrames = videoLength * fps;
const processedFrames = statistics.getVideoFrameNumber();
const speed = statistics.getSpeed();
const percentage =
totalFrames > 0
? Math.floor((processedFrames / totalFrames) * 100)
: 0;
if (!item.Id) throw new Error("Item is undefined");
setProcesses((prev) => {
return prev.map((process) => {
if (process.itemId === item.Id) {
return {
...process,
id: statistics.getSessionId().toString(),
progress: percentage,
speed: Math.max(speed, 0),
};
}
return process;
});
});
}, [setProcesses, completeCallback]);
const startRemuxing = useCallback(
async (url: string) => {
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
if (!api) throw new Error("API is not defined");
if (!item.Id) throw new Error("Item must have an Id");
const itemImage = getItemImage({
item,
api,
variant: "Primary",
quality: 90,
width: 500,
});
await saveImage(item.Id, itemImage?.uri);
// First lets save any important assets we want to present to the user offline
await onSaveAssets(api, item);
toast.success(`Download started for ${item.Name}`, {
action: {
@@ -61,102 +166,34 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
},
});
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`
);
try {
setProcesses((prev) => [
...prev,
{
id: "",
deviceId: "",
inputUrl: "",
item,
itemId: item.Id,
outputPath: "",
progress: 0,
status: "downloading",
timestamp: new Date(),
} as JobStatus,
]);
const job: JobStatus = {
id: "",
deviceId: "",
inputUrl: url,
item: item,
itemId: item.Id!,
outputPath: output,
progress: 0,
status: "downloading",
timestamp: new Date(),
}
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
const videoLength =
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
const totalFrames = videoLength * fps;
const processedFrames = statistics.getVideoFrameNumber();
const speed = statistics.getSpeed();
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
setProcesses((prev) => [...prev, job]);
const percentage =
totalFrames > 0
? Math.floor((processedFrames / totalFrames) * 100)
: 0;
if (!item.Id) throw new Error("Item is undefined");
setProcesses((prev) => {
return prev.map((process) => {
if (process.itemId === item.Id) {
return {
...process,
progress: percentage,
speed: Math.max(speed, 0),
};
}
return process;
});
});
});
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
await new Promise<void>((resolve, reject) => {
FFmpegKit.executeAsync(command, async (session) => {
try {
const returnCode = await session.getReturnCode();
if (returnCode.isValueSuccess()) {
if (!item) throw new Error("Item is undefined");
await saveDownloadedItemInfo(item);
toast.success("Download completed");
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`
);
await queryClient.invalidateQueries({
queryKey: ["downloadedItems"],
});
resolve();
} else if (returnCode.isValueError()) {
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
);
reject(new Error("Remuxing failed")); // Reject the promise on error
} else if (returnCode.isValueCancel()) {
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`
);
resolve();
}
setProcesses((prev) => {
return prev.filter((process) => process.itemId !== item.Id);
});
} catch (error) {
reject(error);
}
});
});
} catch (error) {
await FFmpegKit.executeAsync(
createFFmpegCommand(url, output).join(" "),
session => completeCallback(session, item),
undefined,
s => statisticsCallback(s, item)
)
} catch (e) {
const error = e as Error;
console.error("Failed to remux:", error);
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
writeErrorLog(
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
Error: ${error.message}, Stack: ${error.stack}`
);
setProcesses((prev) => {
return prev.filter((process) => process.itemId !== item.Id);
@@ -164,15 +201,13 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
throw error; // Re-throw the error to propagate it to the caller
}
},
[output, item]
[settings, processes, setProcesses, completeCallback, statisticsCallback]
);
const cancelRemuxing = useCallback(() => {
FFmpegKit.cancel();
setProcesses((prev) => {
return prev.filter((process) => process.itemId !== item.Id);
});
}, [item.Name]);
setProcesses([]);
}, []);
return { startRemuxing, cancelRemuxing };
};

View File

@@ -0,0 +1,29 @@
import { useQueryClient } from "@tanstack/react-query";
/**
* useRevalidatePlaybackProgressCache invalidates queries related to playback progress.
*/
export function useInvalidatePlaybackProgressCache() {
const queryClient = useQueryClient();
const revalidate = async () => {
// List of all the queries to invalidate
const queriesToInvalidate = [
["item"],
["resumeItems"],
["continueWatching"],
["nextUp-all"],
["nextUp"],
["episodes"],
["seasons"],
["home"],
];
// Invalidate each query
for (const queryKey of queriesToInvalidate) {
await queryClient.invalidateQueries({ queryKey });
}
};
return revalidate;
}

View File

@@ -1,7 +1,7 @@
// hooks/useTrickplay.ts
import { apiAtom } from "@/providers/JellyfinProvider";
import { ticksToMs } from "@/utils/time";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react";
@@ -57,6 +57,7 @@ export const useTrickplay = (item: BaseItemDto, enabled = true) => {
: null;
}, [item, enabled]);
// Takes in ticks.
const calculateTrickplayUrl = useCallback(
(progress: number) => {
if (!enabled) {
@@ -74,28 +75,33 @@ export const useTrickplay = (item: BaseItemDto, enabled = true) => {
}
const { data, resolution } = trickplayInfo;
const { Interval, TileWidth, TileHeight } = data;
const { Interval, TileWidth, TileHeight, Width, Height } = data;
if (!Interval || !TileWidth || !TileHeight || !resolution) {
if (
!Interval ||
!TileWidth ||
!TileHeight ||
!resolution ||
!Width ||
!Height
) {
throw new Error("Invalid trickplay data");
}
const currentSecond = Math.max(0, Math.floor(progress / 10000000));
const currentTimeMs = Math.max(0, ticksToMs(progress));
const currentTile = Math.floor(currentTimeMs / Interval);
const cols = TileWidth;
const rows = TileHeight;
const imagesPerTile = cols * rows;
const imageIndex = Math.floor(currentSecond / (Interval / 1000));
const tileIndex = Math.floor(imageIndex / imagesPerTile);
const tileSize = TileWidth * TileHeight;
const tileOffset = currentTile % tileSize;
const index = Math.floor(currentTile / tileSize);
const positionInTile = imageIndex % imagesPerTile;
const rowInTile = Math.floor(positionInTile / cols);
const colInTile = positionInTile % cols;
const tileOffsetX = tileOffset % TileWidth;
const tileOffsetY = Math.floor(tileOffset / TileWidth);
const newTrickPlayUrl = {
x: rowInTile,
y: colInTile,
url: `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${tileIndex}.jpg?api_key=${api.accessToken}`,
x: tileOffsetX,
y: tileOffsetY,
url: `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${index}.jpg?api_key=${api.accessToken}`,
};
setTrickPlayUrl(newTrickPlayUrl);
@@ -104,9 +110,45 @@ export const useTrickplay = (item: BaseItemDto, enabled = true) => {
[trickplayInfo, item, api, enabled]
);
const prefetchAllTrickplayImages = useCallback(() => {
if (!api || !enabled || !trickplayInfo || !item.Id || !item.RunTimeTicks) {
return;
}
const { data, resolution } = trickplayInfo;
const { Interval, TileWidth, TileHeight, Width, Height } = data;
if (
!Interval ||
!TileWidth ||
!TileHeight ||
!resolution ||
!Width ||
!Height
) {
throw new Error("Invalid trickplay data");
}
// Calculate tiles per sheet
const tilesPerRow = TileWidth;
const tilesPerColumn = TileHeight;
const tilesPerSheet = tilesPerRow * tilesPerColumn;
const totalTiles = Math.ceil(ticksToMs(item.RunTimeTicks) / Interval);
const totalIndexes = Math.ceil(totalTiles / tilesPerSheet);
// Prefetch all trickplay images
for (let index = 0; index < totalIndexes; index++) {
const url = `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${index}.jpg?api_key=${api.accessToken}`;
Image.prefetch(url);
}
}, [trickplayInfo, item, api, enabled]);
return {
trickPlayUrl: enabled ? trickPlayUrl : null,
calculateTrickplayUrl: enabled ? calculateTrickplayUrl : () => null,
prefetchAllTrickplayImages: enabled
? prefetchAllTrickplayImages
: () => null,
trickplayInfo: enabled ? trickplayInfo : null,
};
};

View File

@@ -1,89 +1,27 @@
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { Alert } from "react-native";
import { Router, useRouter } from "expo-router";
import { Api } from "@jellyfin/sdk";
import { useAtomValue } from "jotai";
import {
apiAtom,
getOrSetDeviceId,
userAtom,
} from "@/providers/JellyfinProvider";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useWebSocketContext } from "@/providers/WebSocketProvider";
interface UseWebSocketProps {
isPlaying: boolean;
pauseVideo: () => void;
playVideo: () => void;
togglePlay: () => void;
stopPlayback: () => void;
offline: boolean;
}
export const useWebSocket = ({
isPlaying,
pauseVideo,
playVideo,
togglePlay,
stopPlayback,
offline,
}: UseWebSocketProps) => {
const router = useRouter();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const { data: deviceId } = useQuery({
queryKey: ["deviceId"],
queryFn: async () => {
return await getOrSetDeviceId();
},
staleTime: Infinity,
});
useEffect(() => {
if (!deviceId || !api?.accessToken) return;
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
const url = `${protocol}://${api?.basePath
.replace("https://", "")
.replace("http://", "")}/socket?api_key=${
api?.accessToken
}&deviceId=${deviceId}`;
const newWebSocket = new WebSocket(url);
let keepAliveInterval: NodeJS.Timeout | null = null;
newWebSocket.onopen = () => {
setIsConnected(true);
keepAliveInterval = setInterval(() => {
if (newWebSocket.readyState === WebSocket.OPEN) {
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
}
}, 30000);
};
newWebSocket.onerror = (e) => {
console.error("WebSocket error:", e);
setIsConnected(false);
};
newWebSocket.onclose = (e) => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
};
setWs(newWebSocket);
return () => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
newWebSocket.close();
};
}, [api, deviceId, user]);
const { ws } = useWebSocketContext();
useEffect(() => {
if (!ws) return;
if (offline) return;
ws.onmessage = (e) => {
const json = JSON.parse(e.data);
@@ -93,8 +31,7 @@ export const useWebSocket = ({
if (command === "PlayPause") {
console.log("Command ~ PlayPause");
if (isPlaying) pauseVideo();
else playVideo();
togglePlay();
} else if (command === "Stop") {
console.log("Command ~ Stop");
stopPlayback();
@@ -106,7 +43,9 @@ export const useWebSocket = ({
Alert.alert("Message from server: " + title, body);
}
};
}, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router]);
return { isConnected };
return () => {
ws.onmessage = null;
};
}, [ws, stopPlayback, togglePlay, isPlaying, router]);
};

View File

@@ -0,0 +1,2 @@
#Sun Nov 17 18:25:45 AEDT 2024
gradle.version=8.9

View File

@@ -0,0 +1,70 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
group = 'expo.modules.vlcplayer'
version = '0.6.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()
// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
// Most of the time, you may like to manage the Android SDK versions yourself.
def useManagedAndroidSdkVersions = false
if (useManagedAndroidSdkVersions) {
useDefaultAndroidSdkVersions()
} else {
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:7.1.3"
}
}
project.android {
compileSdkVersion safeExtGet("compileSdkVersion", 34)
defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 21)
targetSdkVersion safeExtGet("targetSdkVersion", 34)
}
}
}
dependencies {
implementation 'org.videolan.android:libvlc-all:3.6.0-eap12'
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.5.31"
}
android {
namespace "expo.modules.vlcplayer"
compileSdkVersion 34
defaultConfig {
minSdkVersion 21
targetSdkVersion 34
versionCode 1
versionName "0.6.0"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
lintOptions {
abortOnError false
}
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {
freeCompilerArgs += ["-Xshow-kotlin-compiler-errors"]
jvmTarget = "17"
}
}

View File

@@ -0,0 +1,2 @@
<manifest>
</manifest>

View File

@@ -0,0 +1,69 @@
package expo.modules.vlcplayer
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class VlcPlayerModule : Module() {
override fun definition() = ModuleDefinition {
Name("VlcPlayer")
View(VlcPlayerView::class) {
Prop("source") { view: VlcPlayerView, source: Map<String, Any> ->
view.setSource(source)
}
Prop("paused") { view: VlcPlayerView, paused: Boolean ->
if (paused) {
view.pause()
} else {
view.play()
}
}
Events(
"onPlaybackStateChanged",
"onVideoStateChange",
"onVideoLoadStart",
"onVideoLoadEnd",
"onVideoProgress",
"onVideoError"
)
AsyncFunction("play") { view: VlcPlayerView ->
view.play()
}
AsyncFunction("pause") { view: VlcPlayerView ->
view.pause()
}
AsyncFunction("stop") { view: VlcPlayerView ->
view.stop()
}
AsyncFunction("seekTo") { view: VlcPlayerView, time: Int ->
view.seekTo(time)
}
AsyncFunction("setAudioTrack") { view: VlcPlayerView, trackIndex: Int ->
view.setAudioTrack(trackIndex)
}
AsyncFunction("getAudioTracks") { view: VlcPlayerView ->
view.getAudioTracks()
}
AsyncFunction("setSubtitleTrack") { view: VlcPlayerView, trackIndex: Int ->
view.setSubtitleTrack(trackIndex)
}
AsyncFunction("getSubtitleTracks") { view: VlcPlayerView ->
view.getSubtitleTracks()
}
AsyncFunction("setSubtitleURL") { view: VlcPlayerView, url: String, name: String ->
view.setSubtitleURL(url, name)
}
}
}
}

View File

@@ -0,0 +1,286 @@
package expo.modules.vlcplayer
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.lifecycle.LifecycleObserver
import android.net.Uri
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.views.ExpoView
import expo.modules.kotlin.viewevent.EventDispatcher
import org.videolan.libvlc.LibVLC
import org.videolan.libvlc.Media
import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.util.VLCVideoLayout
class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener {
private var libVLC: LibVLC? = null
private var mediaPlayer: MediaPlayer? = null
private lateinit var videoLayout: VLCVideoLayout
private var isPaused: Boolean = false
private var lastReportedState: Int? = null
private var lastReportedIsPlaying: Boolean? = null
private var media : Media? = null
private val onVideoProgress by EventDispatcher()
private val onVideoStateChange by EventDispatcher()
private val onVideoLoadEnd by EventDispatcher()
private var startPosition: Int? = 0
private var isMediaReady: Boolean = false
private var externalTrack: Map<String, String>? = null
var hasSource: Boolean = false
private val handler = Handler(Looper.getMainLooper())
private val updateInterval = 1000L // 1 second
private val updateProgressRunnable = object : Runnable {
override fun run() {
updateVideoProgress()
handler.postDelayed(this, updateInterval)
}
}
init {
setupView()
}
private fun setupView() {
Log.d("VlcPlayerView", "Setting up view")
setBackgroundColor(android.graphics.Color.WHITE)
videoLayout = VLCVideoLayout(context).apply {
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
}
addView(videoLayout)
Log.d("VlcPlayerView", "View setup complete")
}
fun setSource(source: Map<String, Any>) {
if (hasSource) {
mediaPlayer?.attachViews(videoLayout, null, false, false)
play()
return
}
val mediaOptions = source["mediaOptions"] as? Map<String, Any> ?: emptyMap()
val autoplay = source["autoplay"] as? Boolean ?: false
val isNetwork = source["isNetwork"] as? Boolean ?: false
externalTrack = source["externalTrack"] as? Map<String, String>
startPosition = (source["startPosition"] as? Double)?.toInt() ?: 0
val initOptions = source["initOptions"] as? MutableList<String> ?: mutableListOf()
initOptions.add("--start-time=$startPosition")
val uri = source["uri"] as? String
// Handle video load start event
// onVideoLoadStart?.invoke(mapOf("target" to reactTag ?: "null"))
libVLC = LibVLC(context, initOptions)
mediaPlayer = MediaPlayer(libVLC)
mediaPlayer?.attachViews(videoLayout, null, false, false)
mediaPlayer?.setEventListener(this)
Log.d("VlcPlayerView", "Loading network file: $uri")
media = Media(libVLC, Uri.parse(uri))
mediaPlayer?.media = media
Log.d("VlcPlayerView", "Debug: Media options: $mediaOptions")
// media.addOptions(mediaOptions)
// Apply subtitle options
// val subtitleTrackIndex = source["subtitleTrackIndex"] as? Int ?: -1
// Log.d("VlcPlayerView", "Debug: Subtitle track index from source: $subtitleTrackIndex")
// if (subtitleTrackIndex >= -1) {
// setSubtitleTrack(subtitleTrackIndex)
// Log.d("VlcPlayerView", "Debug: Set subtitle track to index: $subtitleTrackIndex")
// } else {
// Log.d("VlcPlayerView", "Debug: Subtitle track index is less than -1, not setting")
// }
hasSource = true
if (autoplay) {
Log.d("VlcPlayerView", "Playing...")
play()
}
}
fun play() {
mediaPlayer?.play()
isPaused = false
handler.post(updateProgressRunnable) // Start updating progress
}
fun pause() {
mediaPlayer?.pause()
isPaused = true
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
}
fun stop() {
mediaPlayer?.stop()
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
}
fun seekTo(time: Int) {
mediaPlayer?.let { player ->
val wasPlaying = player.isPlaying
if (wasPlaying) {
player.pause()
}
val duration = player.length.toInt()
val seekTime = if (time > duration) duration - 1000 else time
player.time = seekTime.toLong()
if (wasPlaying) {
player.play()
}
}
}
fun setAudioTrack(trackIndex: Int) {
mediaPlayer?.setAudioTrack(trackIndex)
}
fun getAudioTracks(): List<Map<String, Any>>? {
println("getAudioTracks")
println(mediaPlayer?.getAudioTracks())
val trackDescriptions = mediaPlayer?.audioTracks ?: return null
return trackDescriptions.map { trackDescription ->
mapOf("name" to trackDescription.name, "index" to trackDescription.id)
}
}
fun setSubtitleTrack(trackIndex: Int) {
mediaPlayer?.setSpuTrack(trackIndex)
}
// fun getSubtitleTracks(): List<Map<String, Any>>? {
// return mediaPlayer?.getSpuTracks()?.map { trackDescription ->
// mapOf("name" to trackDescription.name, "index" to trackDescription.id)
// }
// }
fun getSubtitleTracks(): List<Map<String, Any>>? {
val subtitleTracks = mediaPlayer?.spuTracks?.map { trackDescription ->
mapOf("name" to trackDescription.name, "index" to trackDescription.id)
}
// Debug statement to print the result
Log.d("VlcPlayerView", "Subtitle Tracks: $subtitleTracks")
return subtitleTracks
}
fun setSubtitleURL(subtitleURL: String, name: String) {
println("Setting subtitle URL: $subtitleURL, name: $name")
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
}
override fun onDetachedFromWindow() {
println("onDetachedFromWindow")
super.onDetachedFromWindow()
mediaPlayer?.stop()
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
media?.release()
mediaPlayer?.release()
libVLC?.release()
mediaPlayer = null
media = null
libVLC = null
}
override fun onEvent(event: MediaPlayer.Event) {
when (event.type) {
MediaPlayer.Event.Playing,
MediaPlayer.Event.Paused,
MediaPlayer.Event.Stopped,
MediaPlayer.Event.Buffering,
MediaPlayer.Event.EndReached,
MediaPlayer.Event.EncounteredError -> updatePlayerState(event)
MediaPlayer.Event.TimeChanged -> {
// Do nothing here, as we are updating progress every 1 second
}
}
}
private fun updatePlayerState(event: MediaPlayer.Event) {
val player = mediaPlayer ?: return
val currentState = event.type
val stateInfo = mutableMapOf<String, Any>(
"target" to "null", // Replace with actual target if needed
"currentTime" to player.time.toInt(),
"duration" to (player.media?.duration?.toInt() ?: 0),
"error" to false
)
when (currentState) {
MediaPlayer.Event.Playing -> {
stateInfo["isPlaying"] = true
stateInfo["isBuffering"] = false
stateInfo["state"] = "Playing"
}
MediaPlayer.Event.Paused -> {
stateInfo["isPlaying"] = false
stateInfo["state"] = "Paused"
}
MediaPlayer.Event.Buffering -> {
stateInfo["isBuffering"] = true
stateInfo["state"] = "Buffering"
}
MediaPlayer.Event.EncounteredError -> {
Log.e("VlcPlayerView", "player.state ~ error")
stateInfo["state"] = "Error"
onVideoLoadEnd(stateInfo);
}
MediaPlayer.Event.Opening -> {
Log.d("VlcPlayerView", "player.state ~ opening")
stateInfo["state"] = "Opening"
}
}
if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) {
lastReportedState = currentState
lastReportedIsPlaying = player.isPlaying
onVideoStateChange(stateInfo)
}
}
private fun updateVideoProgress() {
val player = mediaPlayer ?: return
val currentTimeMs = player.time.toInt()
val durationMs = player.media?.duration?.toInt() ?: 0
if (currentTimeMs >= 0 && currentTimeMs < durationMs) {
// Set subtitle URL if available
if (player.isPlaying && !isMediaReady) {
isMediaReady = true
externalTrack?.let {
val name = it["name"]
val deliveryUrl = it["DeliveryUrl"] ?: ""
if (!name.isNullOrEmpty() && !deliveryUrl.isNullOrEmpty()) {
setSubtitleURL(deliveryUrl, name)
}
}
}
onVideoProgress(mapOf(
"currentTime" to currentTimeMs,
"duration" to durationMs
));
}
}
}

View File

@@ -0,0 +1,9 @@
{
"platforms": ["ios", "tvos", "android", "web"],
"ios": {
"modules": ["VlcPlayerModule"]
},
"android": {
"modules": ["expo.modules.vlcplayer.VlcPlayerModule"]
}
}

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