Compare commits

...

418 Commits

Author SHA1 Message Date
Fredrik Burmester
90930d478c fix: call with correct args 2024-12-23 11:25:24 +01:00
Fredrik Burmester
8608ad02f7 feat: context menu actions for items 2024-12-22 11:57:44 +01:00
Fredrik Burmester
030947fc38 fix: login design 2024-12-22 11:33:15 +01:00
Fredrik Burmester
9b18188b32 fix: live tv design 2024-12-22 11:28:28 +01:00
Fredrik Burmester
d86853dec9 fix: login design 2024-12-22 11:28:21 +01:00
Fredrik Burmester
0750acdc13 Merge pull request #302 from Alexk2309/fix/remove-episode-list-and-next-up-in-offline-playback
Removed episodelist button and previous/next buttons for offline playback
2024-12-22 10:40:08 +01:00
Alex Kim
d8231f5b80 Fixed compile error 2024-12-22 17:46:56 +11:00
Alex Kim
41d17499bb Removed episodelist button and previous/next buttons for offline playback 2024-12-22 17:43:58 +11:00
Fredrik Burmester
60f1217cae Merge pull request #300 from herrrta/fix/storage-read-crash
dont rely on cache for downloadedItems
2024-12-21 22:02:49 +01:00
herrrta
834de10e34 dont rely on cache for downloadedItems 2024-12-21 14:10:55 -05:00
Fredrik Burmester
51f17f983d fix: add safe areas back to controls 2024-12-21 13:18:56 +01:00
Fredrik Burmester
ba4a2c0b79 fix: haptics 2024-12-21 13:03:32 +01:00
Fredrik Burmester
a32eb710ec feat: haptics 2024-12-21 12:56:04 +01:00
Fredrik Burmester
cb05da782a feat: optimistic update 2024-12-21 12:55:58 +01:00
Fredrik Burmester
5a680a4392 fix: smoother item page loading 2024-12-21 12:45:32 +01:00
Fredrik Burmester
8a44d2ff15 fix: correct types 2024-12-21 12:31:00 +01:00
Fredrik Burmester
f3f260625f fix: refactor buttons 2024-12-21 12:22:53 +01:00
Fredrik Burmester
6908620f4e fix: downloaded movie title overflowing 2024-12-19 15:10:21 +01:00
Fredrik Burmester
9932266203 fix: key error 2024-12-19 15:06:26 +01:00
Fredrik Burmester
cb2268e39c fix: don't show empty folders in movie/tvshow libraries 2024-12-19 15:05:31 +01:00
Fredrik Burmester
bf9be278d3 fix: texts and icons 2024-12-19 14:48:51 +01:00
Fredrik Burmester
584fcc09d6 fix: use ionicons 2024-12-19 14:18:53 +01:00
Fredrik Burmester
7a26b5004b fix: text reorder 2024-12-19 14:18:47 +01:00
Fredrik Burmester
ae92692ea0 fix: title props 2024-12-19 14:18:37 +01:00
Fredrik Burmester
92e4b3b8cf fix: use ionicons 2024-12-19 14:18:26 +01:00
Fredrik Burmester
127ec1391b fix: text 2024-12-19 14:12:29 +01:00
Fredrik Burmester
0ac4f826bc Merge pull request #297 from fredrikburmester/feat/technical-details
feat: add technical details to item
2024-12-19 12:06:12 +01:00
Fredrik Burmester
6190f2e602 feat: add technical details to item 2024-12-19 12:05:57 +01:00
Fredrik Burmester
24fdd071af Merge pull request #294 from jakequade/master
fix: restore streaming codecs
2024-12-19 10:11:04 +01:00
jakequade
be3122caac add in proper codecs for chromecast 2024-12-17 20:41:07 +11:00
Fredrik Burmester
39a220bbed Merge pull request #289 from herrrta/fix/delete-type
Move downloads to a cache directory
2024-12-14 22:49:15 +01:00
herrrta
e3bdbb5cbd Move downloads to a cache directory
- cleanup cache during apps first cold start
- Downloads now saved in cacheDirectory and moved to documents when verified complete
- Bring back download size to episode card
- Improve reading a files size if its a known downloaded file
- Added decimal to divisor in bytesToReadable for more accurate file size conversions
2024-12-14 12:19:44 -05:00
Fredrik Burmester
b6ad05d980 Merge pull request #284 from Alexk2309/hotfix/transcoded-streams
Hotfix/transcoded streams
2024-12-13 07:48:54 +01:00
Alex Kim
0360b5cbd5 Merged changes from main 2024-12-13 16:39:58 +11:00
Alex Kim
a9b1d9fb0a Added bandaid fix 2024-12-13 05:03:16 +11:00
Alex Kim
4291ef55b9 Added tmp fix 2024-12-13 01:04:55 +11:00
Fredrik Burmester
655060fb40 Merge pull request #282 from Alexk2309/hotfix/for-settings
Hotfix/for settings
2024-12-12 12:20:35 +01:00
Alex Kim
0e29b8b671 Added temporary fix 2024-12-12 21:41:22 +11:00
Alex Kim
72f64c71dd Added .vscode to git ignore 2024-12-12 16:37:06 +11:00
Alex Kim
ddfd9f6ce3 Added vscode styling for pretier extension 2024-12-12 16:36:17 +11:00
Alex Kim
67fb339d40 Added fix that fully stops the UseEffect hook from been calling indefinetly 2024-12-12 16:33:30 +11:00
Alex Kim
9e0a7f047c Added new pulled state, to stop infinite callback for useEffect hookt in MediaContext 2024-12-12 15:44:53 +11:00
Fredrik Burmester
aab806bbf4 Merge pull request #281 from Alexk2309/hotfix/fix-options-after-subtitle-audio-revamp
Added fix
2024-12-11 19:39:37 +01:00
Alex Kim
4a53b20618 Added fix 2024-12-12 05:38:14 +11:00
Fredrik Burmester
45299a5c5d Merge pull request #279 from Alexk2309/revamp/audio-subtitle-selection
Revamp/audio subtitle selection
2024-12-11 18:59:24 +01:00
Alex Kim
65ad4effca Merged main into branch 2024-12-12 04:25:37 +11:00
Alex Kim
35fcb5ca0c Completed subtitle feature 2024-12-12 04:23:09 +11:00
Fredrik Burmester
5dc0066370 fix: remove auto http/s and allow for more flexible urls 2024-12-11 18:02:27 +01:00
Alex Kim
3fb20a8ca2 Revamped transcoding subtitles 2024-12-12 02:41:30 +11:00
Fredrik Burmester
180ed54fed fix: initial support playlists 2024-12-11 15:49:20 +01:00
Fredrik Burmester
72859b4ae3 fix: make the next episode button work with skip credits button 2024-12-11 08:28:11 +01:00
Fredrik Burmester
bfe96edb29 fix: don't show if no next episode 2024-12-10 21:52:46 +01:00
Fredrik Burmester
46f4acdad0 Merge pull request #276 from fredrikburmester/feat/go-to-next-episode-countdown
feat: go to next episode countdown
2024-12-10 21:11:51 +01:00
Fredrik Burmester
da1aa9f48c feat: go to next episode countdown 2024-12-10 20:37:58 +01:00
Alex Kim
1d0d99c79b Added stream ranker class 2024-12-11 06:02:13 +11:00
Alex Kim
33a6295b20 Added more selection options 2024-12-11 05:22:56 +11:00
Alex Kim
72cc381087 Added use default audio 2024-12-11 04:59:33 +11:00
Alex Kim
c4bfaf2d56 Made subtitle mode fetch from server 2024-12-11 04:52:12 +11:00
Alex Kim
487ac398e5 Added subtitle mode in options 2024-12-11 04:48:53 +11:00
Alex Kim
84fd0edc49 WIP 2024-12-11 04:01:30 +11:00
Fredrik Burmester
0e1583c440 Merge pull request #275 from Alexk2309/fix/select-same-bitrate-when-changing-episode-in-player
Fix for the selecting the same bitrate when changing the same episode…
2024-12-10 10:51:30 +01:00
Alex Kim
6459e5f323 Fix for the selecting the same bitrate when changing the same episode in the player 2024-12-10 20:48:15 +11:00
Fredrik Burmester
319e1fd53f Merge pull request #274 from Alexk2309/fix/trick-play-invalid-for-transcoded-player
Fix/trick play invalid for transcoded player
2024-12-10 10:24:39 +01:00
Alex Kim
93bd817eaf Removed old function argumement 2024-12-10 20:22:41 +11:00
Alex Kim
d9f21e6824 Removed Unused imports for controls 2024-12-10 20:21:53 +11:00
Alex Kim
d287f5d082 Added fix for invalid trickplay, for transcoded player 2024-12-10 20:21:00 +11:00
Fredrik Burmester
ecd2fa386e chore 2024-12-09 21:33:10 +01:00
Fredrik Burmester
7c022bbaff Merge pull request #273 from Alexk2309/hotfix/show-audio-slider-when-changing-audio-through-device
Added feature to show audio slider when changing audio through device
2024-12-09 21:32:42 +01:00
Alex Kim
5d79ee34cf Added feature to show audio slider when changing audio through device 2024-12-10 05:20:49 +11:00
Fredrik Burmester
b0adad8dc4 Merge pull request #272 from Alexk2309/feature/audio-slider-in-controls
Feature/audio slider in controls
2024-12-09 17:22:14 +01:00
Alex Kim
c3d3f538d7 Finished changes for audio selection 2024-12-10 03:21:02 +11:00
Alex Kim
6b6dedf303 WIP 2024-12-10 02:50:03 +11:00
Alex Kim
8d22e4c075 Added audioSlider.tsx 2024-12-10 02:20:52 +11:00
Fredrik Burmester
4dff26e8c3 Merge pull request #270 from Alexk2309/change/default-subtitle-size-for-different-platforms
Changed default subtitle size depending platform
2024-12-09 15:04:30 +01:00
Alex Kim
ee2edda507 Changed default subtitle size depending platform 2024-12-10 01:03:09 +11:00
Fredrik Burmester
9e6a8424db Merge pull request #269 from Alexk2309/fix/next-up-episodes-not-showing-for-some-series
Fix for next-up not showing up for some episodes of a series.
2024-12-09 15:02:11 +01:00
Alex Kim
d37ecc1bef Added fix for change 2024-12-10 00:51:47 +11:00
Fredrik Burmester
e70fd3ee45 Merge pull request #268 from Alexk2309/fix/default-subtitles-not-showing
Fix default subtitles not working on app.
2024-12-09 08:55:56 +01:00
Alex Kim
16e93513e2 Fixed issue 2024-12-09 05:51:04 +11:00
Fredrik Burmester
b0c506f85d Merge pull request #267 from Alexk2309/hotfix/small-ui-changes
Changed trickplay debounce to 10ms and added padding for EpisodeList
2024-12-08 19:22:15 +01:00
Alex Kim
b762aff6e2 Changed trickplay debounce to 10ms and added padding for EpisodeList 2024-12-09 04:46:09 +11:00
Fredrik Burmester
75639c4424 Merge pull request #266 from Alexk2309/hotfix/bug-fixes-for-player
Hotfix/bug fixes for player
2024-12-08 18:14:17 +01:00
Fredrik Burmester
4606ce1834 chore: update deps 2024-12-08 18:13:57 +01:00
Alex Kim
44bde8f41e Fixed more bugs 2024-12-09 04:12:13 +11:00
Alex Kim
828edad749 Added padding on right side only 2024-12-09 04:07:56 +11:00
Alex Kim
f842c8a41f Episode list fix rendering 2024-12-09 04:01:59 +11:00
Alex Kim
4d38573973 Fixed rubber banding issue 2024-12-09 03:38:22 +11:00
Alex Kim
785e3b6859 Stop websocket on page exit for transcoded player 2024-12-09 02:56:27 +11:00
Alex Kim
40b3304f9b Fixed socket not closing on exit 2024-12-09 02:48:36 +11:00
Fredrik Burmester
abf1b343cd Merge pull request #265 from herrrta/fix/delete-type
Fix delete by show file type
2024-12-08 16:37:21 +01:00
herrrta
e427802aae Fix delete by show file type 2024-12-08 10:34:03 -05:00
Fredrik Burmester
684e671750 fix: design issues regarding downloads 2024-12-08 16:29:17 +01:00
Fredrik Burmester
5e9b28f2eb fix: type errors and design 2024-12-08 15:59:03 +01:00
Alex Kim
1d4c56265f Made sure changes are saved when changing episode list 2024-12-09 01:54:30 +11:00
Alex Kim
1102df8384 Added fixes for opacity style 2024-12-09 01:06:32 +11:00
Fredrik Burmester
15073f47db Merge pull request #264 from Alexk2309/feature/episode-list-in-player
Feature/episode list in player
2024-12-08 14:16:58 +01:00
Alex Kim
15f32bca6c Removed useless file 2024-12-09 00:13:41 +11:00
Alex Kim
108c5f9bab Merged websocket PR 2024-12-09 00:11:19 +11:00
Fredrik Burmester
24d781050f Merge pull request #263 from fredrikburmester/fix/global-websockets-with-vlc
fix: websockets now work globally with vlc and transcoded player
2024-12-08 14:02:13 +01:00
Alex Kim
353ebf3b0c Removed opacity for unselected items 2024-12-09 00:00:03 +11:00
Fredrik Burmester
c8b16f947d fix: increase max streaming bitrate for HUGE files 2024-12-08 13:59:36 +01:00
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
Alex Kim
a6b49c42cf Added style changes 2024-12-08 23:50:59 +11:00
Fredrik Burmester
5afb677b3a chore 2024-12-08 11:57:47 +01:00
Alex Kim
65d3da155f Fixed style issue for devices with bottom safe area 2024-12-08 18:34:20 +11:00
Alex Kim
d616574232 Added scroll to episode when going in player mode 2024-12-08 18:25:10 +11:00
Alex Kim
b8b083abe2 Added correct starting season index 2024-12-08 18:14:41 +11:00
Alex Kim
49a1bffcf5 Added style changes for episode list 2024-12-08 18:03:06 +11:00
Alex Kim
cb6c716830 Fixed playbutton showing up on current Episode 2024-12-08 17:26:48 +11:00
Alex Kim
a725af114c Fixed playbutton showing up on current Episode 2024-12-08 17:26:17 +11:00
Alex Kim
5b290fd667 Got season dropdown to start working 2024-12-08 17:18:44 +11:00
Alex Kim
de4f60f564 WIP 2024-12-08 07:44:35 +11:00
Alex Kim
a4cd3ea600 WIP 2024-12-08 07:15:34 +11:00
Fredrik Burmester
3db12bd76a Merge pull request #261 from Alexk2309/fix/refactor-vlc-media-player
Refactored perfomance change for IOS
2024-12-07 16:53:02 +01:00
Alex Kim
26305c2983 Used debouncing for trick play and stop rendering trickplay, while not sliding 2024-12-08 02:48:23 +11:00
Alex Kim
9c02fa2e72 Refactored perfomance change for IOS 2024-12-07 23:04:21 +11:00
Fredrik Burmester
b08ec474a4 Merge pull request #259 from herrrta/fix/remux-download
Fix queue being downloaded all at once
2024-12-07 09:28:49 +01:00
Fredrik Burmester
416fb24ac0 Merge pull request #257 from Alexk2309/fix/pause-video-when-exiting-ios
Made pause video on app exit and added some performance changes for android
2024-12-07 09:28:08 +01:00
Alex Kim
0d2b15e5af Removed unneccessary print statement 2024-12-07 18:17:28 +11:00
Alex Kim
ef036cb362 Fixed for android and added some peformance changes for android 2024-12-07 18:11:41 +11:00
herrrta
006e457d23 # Fix queue being downloaded all at once 2024-12-06 19:54:43 -05:00
Alex Kim
832a717585 Improve performance of android version 2024-12-07 06:35:25 +11:00
Alex Kim
39f86a9eb1 Added android fix 2024-12-07 06:01:04 +11:00
Alex Kim
38445c6959 Fixed issue for IOS and android 2024-12-07 05:41:46 +11:00
Alex Kim
24320541c7 Made pause video on app exit on IOS 2024-12-07 04:15:16 +11:00
Fredrik Burmester
ee4e9fe347 Merge pull request #256 from Alexk2309/fix/optimize-direct-video-player-ios
Optimized direct player for IOS
2024-12-06 17:28:43 +01:00
Alex Kim
6d43b34f66 Optimized direct player for IOS 2024-12-07 03:14:25 +11:00
retardgerman
63cf7eb622 specified versions in dropdown menu 2024-12-06 14:16:53 +01:00
Fredrik Burmester
32130f1a9c Merge pull request #255 from Alexk2309/feature/prefetch-trick-play-on-video-start
Prefetch trick-play images
2024-12-06 08:39:01 +01:00
Fredrik Burmester
7f458f2f0b Merge pull request #254 from Alexk2309/hotfix/fixed-bugs-for-new-controls-ui
Bug fixes for controls
2024-12-06 08:38:48 +01:00
Alex Kim
6ec6c6daa0 Added feature to prefetch trick-play images on video start rather than downloading it while scrubbing 2024-12-06 17:41:42 +11:00
Alex Kim
02a48fd958 Refactored code, so that way the skip intro button is not using absolute positioning 2024-12-06 16:42:58 +11:00
Alex Kim
04c4dfd13a Fixed bugs for skip intro button still being able to be clicked once it is gone past the time frame 2024-12-06 15:08:33 +11:00
retardgerman
40bdb10653 add dropdown menu for App version 2024-12-05 22:59:30 +01:00
Fredrik Burmester
f16c486bfb Merge pull request #252 from Alexk2309/hotfix/control-ui-fix-skip-intro-button-overlapping-with-safe-area
Fixed skip intro button overlapping with safe area
2024-12-05 20:35:27 +01:00
Alex Kim
19fc00e314 Fixed skip intro button overlapping with safe area 2024-12-06 06:14:53 +11:00
Fredrik Burmester
c51965016c Merge pull request #251 from Alexk2309/feature/intergration-for-ios-file-provider
File System Support
2024-12-05 19:28:27 +01:00
Alex Kim
3bcf73f0dd Changed app ios settings 2024-12-06 05:11:26 +11:00
retardgerman
1ecef4be67 Update bug_report.yml 2024-12-05 18:29:59 +01:00
Fredrik Burmester
387525f9c3 Merge pull request #249 from Alexk2309/feature/subtitle-size-change
Added the ability to change subtitle size
2024-12-05 18:10:46 +01:00
Fredrik Burmester
cf182d8473 Merge branch 'master' into feature/subtitle-size-change 2024-12-05 18:10:38 +01:00
Fredrik Burmester
f0e3321a16 Merge pull request #248 from Alexk2309/feature/control-ui-change
Feature/control UI change
2024-12-05 18:09:38 +01:00
Fredrik Burmester
96c76e2b08 Merge pull request #247 from herrrta/feat/multiple-remux-downloads
Multiple Remux downloads
2024-12-05 18:09:27 +01:00
retardgerman
aaa07d93cf better bug_report.yml 2024-12-05 17:52:20 +01:00
Alex Kim
0716bba6ec Updated setting description 2024-12-06 03:47:58 +11:00
retardgerman
15476f3686 correct bug_report.yml 2024-12-05 17:44:15 +01:00
Alex Kim
97cf9185d3 Added the ability to change subtitle size 2024-12-06 03:40:48 +11:00
retardgerman
c11ad17ca5 fixed bug_report.yml 2024-12-05 17:34:20 +01:00
retardgerman
b0d563bc48 new bug_report.yml 2024-12-05 17:25:55 +01:00
retardgerman
909fc84ec0 Update bug_report.yml 2024-12-05 17:15:36 +01:00
retardgerman
0400597061 finally fixed bug_report.yml 2024-12-05 17:13:01 +01:00
retardgerman
b44a5fbbba fixed bug_report.yml 2024-12-05 17:09:13 +01:00
retardgerman
a5f6ba27b1 reworked bug submit handling 2024-12-05 17:08:32 +01:00
Alex Kim
ece1b8f2b9 Removed consoled log for changing brightness 2024-12-06 03:02:11 +11:00
Alex Kim
beb6702112 merge with master 2024-12-06 02:53:28 +11:00
Alex Kim
98c0ed4ad5 Removed pink background from slider 2024-12-06 02:50:16 +11:00
Alex Kim
b3f471bfa6 Added brightness slider 2024-12-06 01:17:24 +11:00
herrrta
1a10f0debf # Multiple Remux downloads
- Added stepper component
- Disabled more download settings based on download method
- refactored useRemuxHlsToMp4.ts to allow for multiple remux downloads
2024-12-05 01:27:55 -05:00
Fredrik Burmester
ac266c6956 Merge pull request #243 from herrrta/feat/show-overall-download-size
Show app usage
2024-12-03 16:23:24 +01:00
herrrta
b23a50914c Show app usage
- Added app usage to settings
- add more readable size formats
2024-12-03 09:56:07 -05:00
Fredrik Burmester
5c4a419d22 Merge branch 'pr/242' 2024-12-03 14:53:27 +01:00
Fredrik Burmester
3d034864f9 chore 2024-12-03 11:59:25 +01:00
Fredrik Burmester
ea183c426b fix: play new item when pressing play button 2024-12-03 11:59:22 +01:00
Fredrik Burmester
92be991cf7 fix: subtitles burn in for chromecast 2024-12-03 11:59:13 +01:00
herrrta
b73c29221a New delete options & storage visibility
- Added react-native-progress dependency
- Added bottom sheet to downloads page to handle actions for deleting items by type
- Added ability to long press to delete a single series
- Added ability to delete by season
- Refactored delete helpers in DownloadProvider.tsx
- Display storage usage inside downloads & settings page
- Fixed Delete all downloaded files from delting user data in mmkv
2024-12-02 22:37:59 -05:00
Alex Kim
880a739dd4 Reworked controls to have pause, and skip not in the slider anymore 2024-12-03 03:56:55 +11:00
Fredrik Burmester
69ffdc2ddf fix: try to fix #239 2024-12-02 12:04:24 +01:00
Fredrik Burmester
d686bd8c7b fix: tab bar icon not hiding 2024-12-02 11:44:52 +01:00
Fredrik Burmester
c8a60e735b Merge branch 'pr/238' 2024-12-02 11:35:22 +01:00
Fredrik Burmester
05f7574e60 fix: hide tab bar icon 2024-12-02 11:34:57 +01:00
Fredrik Burmester
11b880863c Revert "feat: cache item size"
This reverts commit aec172d8f5.
2024-12-02 11:34:49 +01:00
Fredrik Burmester
aec172d8f5 feat: cache item size 2024-12-02 11:13:05 +01:00
herrrta
7b52528d72 # Persist DownloadedItem size when downloading or when reading file for the first time 2024-12-01 18:07:34 -05:00
Fredrik Burmester
5fd1d9080e chore 2024-12-01 23:08:21 +01:00
Fredrik Burmester
5cc0f381fa chore 2024-12-01 22:26:40 +01:00
Fredrik Burmester
0f547deb39 Merge branch 'pr/210' 2024-12-01 21:18:20 +01:00
Fredrik Burmester
5aeb80348a chore 2024-12-01 21:16:53 +01:00
Fredrik Burmester
1dfc0ac762 Merge branch 'pr/233' 2024-12-01 21:01:59 +01:00
Fredrik Burmester
2b8aee442a Merge branch 'pr/232' 2024-12-01 21:01:54 +01:00
Fredrik Burmester
3e45adfeb5 Merge branch 'pr/231' 2024-12-01 21:01:45 +01:00
herrrta
b41363d347 # Allow option for viewing custom menu links
- Added new 'Other' setting to toggle new tab visibility
- Added new Tab to show custom links
- Added icon asset for list
2024-12-01 14:26:49 -05:00
herrrta
2d5a27c015 # Add Button to download whole series/Season
- Refactored DownloadItem.tsx to be compatible with multiple items
- Updated queueActions.enqueue signature to be compatible with array of jobs
- Added download button beside season dropdown to download entire season
- Added download button to series page to download entire series
2024-12-01 14:23:38 -05:00
herrrta
b5c6403e2d # Add download size to offline media downloads
- Added getDownloadSize helper function to display media size
 in MB or GB when appropriate
2024-12-01 14:23:29 -05:00
herrrta
7eb7d17fa9 # New downloads page for downloaded TV-Series
- Renamed downloads.tsx to index.tsx
- Added new downloads/series.tsx page
- Downloading now saves series primary image
- Downloads index page now shows series primary image with downloaded episode counter
- Updated EpisodeCard.tsx to display more information
- Moved season dropdown from SeasonPicker.tsx into its own component SeasonDropdown.tsx
- Updated navigation in DownloadItem.tsx to direct to series page when a downloaded episode is clicked
2024-12-01 14:23:12 -05:00
Fredrik Burmester
3d8875208f Merge branch 'pr/235' 2024-12-01 17:48:13 +01:00
Alex Kim
e4cfb52dab Added change to show actual device name rather than platform 2024-12-02 03:41:25 +11:00
Alex Kim
879e79cc47 Fixed some edge cases with dropdown 2024-12-02 03:21:26 +11:00
Fredrik Burmester
b9abe3e7f7 fix: stop playback on back button 2024-12-01 14:20:00 +01:00
Fredrik Burmester
383062ac0d fix: cache invalidation issue 2024-12-01 10:11:24 +01:00
Fredrik Burmester
3a507b6d1b fix: correct initialization of query client (based on docs) 2024-12-01 09:46:15 +01:00
Fredrik Burmester
500005afa8 fix: burn in subs for downloads 2024-11-30 10:18:09 +01:00
Fredrik Burmester
b638743497 fix: corrected ffmpeg command 2024-11-29 09:43:54 +01:00
Fredrik Burmester
73aae1d260 fix: don't return undefined 2024-11-29 09:43:39 +01:00
Fredrik Burmester
b84e95dc54 fix: add more remux debug logging 2024-11-29 09:05:40 +01:00
Fredrik Burmester
5292d89303 chore 2024-11-28 14:55:29 +01:00
Fredrik Burmester
acd14279f4 fix: make sure always max bitrate is selected 2024-11-28 10:21:30 +01:00
Fredrik Burmester
945d553cae fix: start at 0 for downloaded content 2024-11-28 10:15:07 +01:00
Fredrik Burmester
c33890fb38 fix: download all embeded audio and subtitle tracks, not just default 2024-11-28 10:14:57 +01:00
Fredrik Burmester
c718f53109 chore 2024-11-28 09:53:16 +01:00
Fredrik Burmester
18552bf622 Merge branch 'pr/226' 2024-11-27 12:54:24 +01:00
Fredrik Burmester
ec5c367438 Merge branch 'feature/vlc-support-android' of https://github.com/Alexk2309/streamyfin into pr/226 2024-11-27 12:25:46 +01:00
Fredrik Burmester
ba38fe6c03 fix: stop playback when exit 2024-11-27 12:25:39 +01:00
Alex Kim
a37da8f667 Added crash fix for subtitle disable 2024-11-27 22:07:48 +11:00
Fredrik Burmester
8b0b3d8abc fix: inf loop dep 2024-11-27 11:38:31 +01:00
Fredrik Burmester
d113729b6f fix: refetch on focus 2024-11-27 09:57:03 +01:00
Fredrik Burmester
e6ea5d13d4 fix: progress data not updating due to enabled being set
https://stackoverflow.com/a/72230424
2024-11-27 09:36:52 +01:00
Fredrik Burmester
c911a3c38a chore 2024-11-27 09:35:53 +01:00
Fredrik Burmester
a1a895815a fix: playback stopped dep 2024-11-27 09:35:26 +01:00
Fredrik Burmester
ea06efb82e fix: playback stopped dep 2024-11-27 09:35:17 +01:00
Fredrik Burmester
8a655c04b2 fix: key error 2024-11-27 08:59:41 +01:00
Fredrik Burmester
2db4effef5 fix: loader position 2024-11-27 08:59:31 +01:00
Fredrik Burmester
88a3bdd891 chore 2024-11-26 22:27:08 +01:00
Fredrik Burmester
6df20f516c fix: pressin to show controls 2024-11-26 22:27:05 +01:00
Fredrik Burmester
1fdf45daa7 fix: try to fix invalidate cache in prod 2024-11-26 21:54:35 +01:00
Fredrik Burmester
e8f4ee2264 chore 2024-11-26 21:24:42 +01:00
Fredrik Burmester
81d4e778e3 fix: lower opacity for video when show controls 2024-11-26 21:24:39 +01:00
Fredrik Burmester
025ce45e33 fix: routing 2024-11-25 21:59:48 +01:00
Fredrik Burmester
4f72cacbc0 fix: remove items 2024-11-25 21:59:38 +01:00
Fredrik Burmester
8c909e17bd Merge branch 'feature/vlc-support-android' of https://github.com/Alexk2309/streamyfin into pr/226 2024-11-25 17:11:13 +01:00
Fredrik Burmester
98fbf71ff8 feat: closes pr/181 2024-11-25 17:11:08 +01:00
Alex Kim
bf0c8a8007 Fixed refresh not refetching next-up and continue watching 2024-11-26 02:55:42 +11:00
Fredrik Burmester
44e5436c3b fix: video not quitting when leaving player route 2024-11-25 16:54:49 +01:00
Fredrik Burmester
d22f047f2b fix: controls safe areas 2024-11-25 15:58:08 +01:00
Fredrik Burmester
7f9dd4e14e fix: header padding 2024-11-25 08:22:04 +01:00
Alex Kim
e82890d7ff Fixed reloading issue 2024-11-25 15:53:15 +11:00
Alex Kim
0054095b20 Fixed bug with dupe subtitle names for transcoded content 2024-11-25 15:35:05 +11:00
Fredrik Burmester
d218d0b1c2 Merge branch 'feature/vlc-support-android' of https://github.com/Alexk2309/streamyfin into pr/226 2024-11-24 19:35:40 +01:00
Fredrik Burmester
93d117640a fix: change to mmkv and fix downloads with VLC 2024-11-24 19:34:49 +01:00
Alex Kim
d4009040d8 Fixed bug in code 2024-11-25 04:51:20 +11:00
Alex Kim
3d8e4a07ce Subtitle and audio now show which one is selected 2024-11-25 04:49:10 +11:00
Alex Kim
726301aca8 Merge branch 'feature/vlc-support-android' of github.com:Alexk2309/streamyfin into feature/vlc-support-android 2024-11-25 03:50:11 +11:00
Alex Kim
887ef10265 Implemented sorting subtitles in the correct order 2024-11-25 03:49:37 +11:00
Alex Kim
d47dd633c7 Updated IOS to work with new react-video transcoded player 2024-11-25 03:32:28 +11:00
Alex Kim
835484b367 Made transcoding content use react-native-video insted 2024-11-25 03:22:04 +11:00
Fredrik Burmester
335765993d fix: header issue 2024-11-24 10:51:01 +01:00
Fredrik Burmester
734772fb92 chore 2024-11-24 10:50:19 +01:00
Fredrik Burmester
56b37a1ec1 Merge branch 'pr/226' into feat/downloads-with-vlc 2024-11-24 10:38:10 +01:00
Fredrik Burmester
6a50eb9044 fix: offline playback using player component 2024-11-24 10:36:06 +01:00
Fredrik Burmester
3dee8ba2e3 chore: remove unused files 2024-11-24 09:40:26 +01:00
Fredrik Burmester
dc73677876 fix: chosenAudioTrack should not be undefined ? 2024-11-24 09:39:14 +01:00
Fredrik Burmester
0633d60186 chore 2024-11-24 00:12:14 +01:00
Fredrik Burmester
55f8af7069 wip 2024-11-23 22:42:04 +01:00
Alex Kim
02f4e4a16b Added disable option when on image based subg on transcoding 2024-11-24 01:59:22 +11:00
Alex Kim
c56b80889f Fixed up style 2024-11-24 01:15:01 +11:00
Alex Kim
ad2bfd8f28 Got preselect audio selection for IOS and android for direct play working 2024-11-24 01:02:00 +11:00
Alex Kim
0418cffba1 Added subtitle preselection for IOS 2024-11-24 00:43:07 +11:00
Alex Kim
6a29a10d82 Finished 2024-11-24 00:00:14 +11:00
Alex Kim
c5077953a8 Got preselect subtitles working for Android 2024-11-23 22:37:16 +11:00
Alex Kim
0e720aa8cf In progress of handling subtitles for transcoded streams 2024-11-23 06:17:38 +11:00
Fredrik Burmester
4699ee9c18 fix: updated device profile 2024-11-21 21:59:59 +01:00
Alex Kim
a7dd74e7ab Updated comments 2024-11-22 04:52:10 +11:00
Alex Kim
2a52499a75 Fixed HLS starting from the earliest segment for android 2024-11-22 04:51:11 +11:00
Alex Kim
a3f8087ccc Fixed HLS starting from the earliest segment 2024-11-22 01:58:59 +11:00
Fredrik Burmester
73acca6c21 fix: correct order of methods 2024-11-21 11:41:04 +01:00
Fredrik Burmester
f2367d3f68 fix: show correct list of subs 2024-11-21 10:24:26 +01:00
Fredrik Burmester
868c046cd2 fix: external subs not showing up due to isExternal not showing true when delivery url is present 2024-11-21 10:04:03 +01:00
Alex Kim
52b5b2875c WIP 2024-11-21 16:09:56 +11:00
Alex Kim
1aed133a67 Fixed not direct playing 2024-11-19 13:35:33 +11:00
Alex Kim
f127ee2976 Fixed destuctor bug 2024-11-19 02:55:01 +11:00
Alex Kim
72410d2729 Fixed next up not working 2024-11-18 20:21:34 +11:00
Fredrik Burmester
dcf59ac18e fix: ios header right padding 2024-11-18 09:24:43 +01:00
Fredrik Burmester
6b7bbf716c chore: remove unused code 2024-11-18 09:13:18 +01:00
Fredrik Burmester
6224f8b92d fix: use correct device profile for android 2024-11-18 09:09:25 +01:00
Fredrik Burmester
3843bf1fcd chore: remove unused code 2024-11-18 09:09:10 +01:00
Fredrik Burmester
5c44db183a chore 2024-11-18 08:55:34 +01:00
Fredrik Burmester
2350f4e294 fix: bitrate value not set on play start 2024-11-18 08:54:21 +01:00
Alex Kim
7ce3bc6e92 Fixed plugin not resolved issue 2024-11-18 18:07:59 +11:00
Alex Kim
21fbe1adae Try fix android issue for player seelct 2024-11-18 18:00:30 +11:00
Fredrik Burmester
cef1327fcb fix: hide/show now works + added back dropdown menu 2024-11-17 21:36:52 +01:00
Fredrik Burmester
a5677aae86 fix: move logic back into page
no need for separate ios and android components not that the player is combined
2024-11-17 21:36:40 +01:00
Fredrik Burmester
44a7ec238f fix: show distinct people 2024-11-17 21:36:13 +01:00
Alex Kim
34d7ab5f1e Fixed not starting at the correct posistion when playing video 2024-11-18 04:24:58 +11:00
Alex Kim
991f58cf73 SetSubtitle URL works now 2024-11-18 04:03:11 +11:00
Alex Kim
558480ea9d Got working subtitles/audio 2024-11-18 03:06:29 +11:00
Alex Kim
6b751cf154 Attempt to get events working 2024-11-17 20:52:07 +11:00
Alex Kim
e010c8229c Downgraded version for better compabilitiy 2024-11-17 18:35:20 +11:00
Alex Kim
128c369e55 Attempt to remove weird stretching 2024-11-17 14:18:45 +11:00
Alex Kim
0b0afb448d WIP 2024-11-17 05:48:29 +11:00
Fredrik Burmester
3d20b7956f wip 2024-11-11 09:18:49 +01:00
Fredrik Burmester
1fdf7ca42f wip 2024-11-10 23:29:21 +01:00
Fredrik Burmester
865fbdf834 wip 2024-11-10 22:36:03 +01:00
Fredrik Burmester
8ed81fbe23 wip 2024-11-10 17:03:15 +01:00
Fredrik Burmester
817e2b3d85 wip 2024-11-10 15:21:30 +01:00
Fredrik Burmester
fff880e708 fix: offline vlc playback not working 2024-11-10 11:54:22 +01:00
Fredrik Burmester
f2bcd2c675 fix: report playback stopped 2024-11-10 10:58:45 +01:00
Fredrik Burmester
00a296cee6 fix: not setting start position on video start 2024-11-10 10:55:22 +01:00
Fredrik Burmester
33b94105c2 chore 2024-11-10 10:16:31 +01:00
Fredrik Burmester
a23e370deb chore 2024-11-10 10:13:34 +01:00
Fredrik Burmester
d95833335e chore: merge things 2024-11-10 10:11:00 +01:00
Fredrik Burmester
5e91f45e3d chore 2024-11-10 09:32:48 +01:00
Fredrik Burmester
b8111babd2 Merge branch 'feat/native-tabbar' into pr/178 2024-11-10 09:31:53 +01:00
Fredrik Burmester
87092fed48 fix: working material3 theme 2024-11-10 09:21:51 +01:00
Fredrik Burmester
65a6ab9972 wip 2024-11-04 22:08:01 +00:00
Fredrik Burmester
8943708ff5 fix: update versions and use native-edge-to-edge 2024-11-04 21:35:26 +00:00
Alex Kim
c0b2579fdd Removed debug print statement 2024-11-04 00:19:58 +11:00
Alex Kim
272b8b914f Fixed incorrect time shown when downloading 2024-11-04 00:15:57 +11:00
Alex Kim
4eb7d0f151 Fixed skip intro skipping more than the video length 2024-11-03 23:18:02 +11:00
Fredrik Burmester
229670e829 fix(android): buffer state and video not loading 2024-11-01 16:54:20 +01:00
retardgerman
341a0f21d7 fix: change Icon Patch (#211) 2024-10-30 10:51:07 +01:00
Mateusz Kukieła
91b4e403e6 feat: add MacOS fullscreen support 2024-10-29 11:26:15 +01:00
Alex Kim
152d3a9c1c Fixed next up and previous episodes 2024-10-29 02:26:07 +11:00
Alex Kim
ad43ee7585 Fixed subtitle sizes 2024-10-29 00:49:22 +11:00
Alex Kim
a1fe226d22 Added hours for trickplay time 2024-10-28 23:28:59 +11:00
Alex Kim
0bc7bbed5a Fixed inproper conversion of secondsToMs 2024-10-28 21:46:46 +11:00
Alex Kim
0f178a502b Fixed intro skipper for VLC 2024-10-28 21:40:09 +11:00
Alex Kim
db20fffeb5 Fixed trick play for VLC 2024-10-28 21:12:42 +11:00
Fredrik Burmester
9ca71dc7fc Merge branch 'feature/vlc-support' of https://github.com/Alexk2309/streamyfin into pr/178 2024-10-27 15:47:07 +01:00
Fredrik Burmester
0117c87a55 fix: wrong time conversion in report playback progress 2024-10-27 15:46:44 +01:00
Fredrik Burmester
120fd4a32b wip 2024-10-27 15:21:59 +01:00
Fredrik Burmester
06e657dc4d wip 2024-10-27 14:14:27 +01:00
Fredrik Burmester
f5857e2162 fix: native pill look on android 2024-10-27 12:37:46 +01:00
Alex Kim
30280db810 WIP bug fixing 2024-10-27 22:30:30 +11:00
Fredrik Burmester
d1221dae83 chore 2024-10-27 11:24:49 +01:00
Fredrik Burmester
b99c18c5ac chore 2024-10-27 11:23:24 +01:00
Fredrik Burmester
25544eb157 chore 2024-10-27 11:22:58 +01:00
Fredrik Burmester
0f1ee174a0 chore 2024-10-27 11:21:59 +01:00
Fredrik Burmester
786f91ab4d Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-10-27 09:04:50 +01:00
Fredrik Burmester
5baa2a3697 chore: update deps 2024-10-27 09:04:37 +01:00
Alex Kim
b9375c1d7b Fixed race condition issue when playing media 2024-10-27 00:34:51 +11:00
retardgerman
d393bc0ac5 Typo fixed #206 2024-10-24 18:02:37 +02:00
Fredrik Burmester
ef9ed647c9 fix: don't include android build 2024-10-21 16:06:34 +02:00
Fredrik Burmester
68d32bd0de wip 2024-10-21 16:05:36 +02:00
Fredrik Burmester
ba76f2444d wip 2024-10-19 21:20:50 +02:00
Fredrik Burmester
d9fde3ba79 wip 2024-10-19 21:20:11 +02:00
Fredrik Burmester
f5b05bf32d wip 2024-10-19 13:20:38 +02:00
Fredrik Burmester
f71eb0be5a wip 2024-10-19 09:18:03 +02:00
Fredrik Burmester
3989d5e525 wip 2024-10-18 23:16:32 +02:00
Fredrik Burmester
4ad67f7f77 wip 2024-10-18 22:36:02 +02:00
Fredrik Burmester
39c49d4cdb wip 2024-10-18 22:27:26 +02:00
Fredrik Burmester
6e669b2aa9 fix: pause playback while seeking 2024-10-17 09:10:09 +02:00
Fredrik Burmester
ac4ce2934c fix: start position and errors 2024-10-16 18:58:45 +02:00
Fredrik Burmester
6a4fe83fbb chore 2024-10-16 08:50:22 +02:00
Fredrik Burmester
04e31e8628 fix: ignore android build of vlc player
since we don't play to have vlc for android right now
2024-10-16 08:23:45 +02:00
Fredrik Burmester
0fb2a6d32b chore: renaming refactoring 2024-10-16 08:21:57 +02:00
Fredrik Burmester
fcffee1981 feat: support start time 2024-10-16 08:21:42 +02:00
Fredrik Burmester
951a9d08ba fix: platform sepcific playing 2024-10-15 22:41:19 +02:00
Fredrik Burmester
3916c94f36 wip 2024-10-15 20:00:17 +02:00
Fredrik Burmester
c7901c759a chore 2024-10-15 13:12:25 +02:00
Fredrik Burmester
e852e40503 Merge branch 'master' into pr/178 2024-10-15 13:12:21 +02:00
Fredrik Burmester
ac9bcbcb9f fix: always use native device profile 2024-10-15 13:08:03 +02:00
Fredrik Burmester
9e5aa16a7d feat: select audio 2024-10-15 08:31:36 +02:00
Fredrik Burmester
ae963751cf wip 2024-10-15 08:01:12 +02:00
Fredrik Burmester
13d4117cc1 wip: external subs and cleanup 2024-10-15 07:32:25 +02:00
Fredrik Burmester
3807f847fd wip 2024-10-14 18:30:01 +02:00
Fredrik Burmester
67be97d857 wip: subtitles and onVideoLoad stuff 2024-10-14 11:14:34 +02:00
Alex Kim
af9f722b53 Removed duplicate Invalidate queries for next-up 2024-10-14 19:24:13 +11:00
Fredrik Burmester
092f5e73d7 Merge branch 'feature/vlc-support' of https://github.com/Alexk2309/streamyfin into pr/178 2024-10-13 20:18:38 +02:00
Alex Kim
7fe7e4e321 Fixed buffering issue 2024-10-14 05:17:52 +11:00
Fredrik Burmester
d41040e6d3 chore: keep up to date with master 2024-10-13 19:25:32 +02:00
Fredrik Burmester
a71832c6e5 feat: initial subtitle support 2024-10-13 17:59:47 +02:00
Fredrik Burmester
eefd1d9d13 fix: local playback 2024-10-13 16:07:03 +02:00
Fredrik Burmester
bbd12c540a fix: dark overlay not disapearing 2024-10-13 14:56:50 +02:00
Fredrik Burmester
0c436408e7 wip 2024-10-13 13:59:27 +02:00
Fredrik Burmester
ebc77f4ee2 Merge branch 'master' into feat/native-tabbar 2024-10-13 11:54:22 +02:00
Fredrik Burmester
d6ee1807f3 Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-10-13 11:39:54 +02:00
Fredrik Burmester
0d7c3cb9da fix: again remove animations because it causes no-tap-register bug 2024-10-13 11:39:51 +02:00
Fredrik Burmester
fd252247aa fix: screen rotation issues 2024-10-13 11:39:25 +02:00
Fredrik Burmester
c12af2efe9 Merge pull request #183 from simoncaron/feat/hide-zero-hour
feat: Hide 0h on play button when media length is lower than an hour
2024-10-13 08:51:27 +02:00
Simon Caron
04b24ee86b feat: Hide 0h on play button when media length is lower than an hour 2024-10-12 14:27:51 -04:00
Alex Kim
43d64bc3d0 Solved buffering issue when paused and scrubbing 2024-10-13 01:54:41 +11:00
Fredrik Burmester
f7401bd60c fix: app crashing on video exit 2024-10-12 16:09:59 +02:00
Fredrik Burmester
6a3d0ae296 fix: play states working 2024-10-12 15:53:25 +02:00
Fredrik Burmester
5c3da8b01a fix: don't clip bottom - allow blur 2024-10-12 15:14:52 +02:00
Fredrik Burmester
46c4b3e1d8 Merge branch 'master' into feat/native-tabbar 2024-10-12 15:03:31 +02:00
Fredrik Burmester
43d251fcda chore 2024-10-12 15:02:46 +02:00
Fredrik Burmester
fed3725733 fix: use better screen dimensions 2024-10-12 15:02:23 +02:00
Fredrik Burmester
f5be204ac8 fix: cache item count 2024-10-12 15:02:10 +02:00
Fredrik Burmester
ba6322bb1f wip 2024-10-12 13:41:09 +02:00
Fredrik Burmester
bf8687a473 wip 2024-10-12 12:55:45 +02:00
Alex Kim
09c6ad47d5 Update visual playback when exiting video 2024-10-12 19:07:16 +11:00
Fredrik Burmester
091a8ff6c3 fix: show more info in debug component 2024-10-11 22:24:23 +02:00
Fredrik Burmester
cab5693ced fix: debounce updates when seeking 2024-10-11 22:20:17 +02:00
Fredrik Burmester
be867a3b10 working 2024-10-11 22:10:47 +02:00
Fredrik Burmester
093fdcda45 Merge pull request #177 from fredrikburmester/fix/video-rotation-bug
fix: video rotation bug by updating screen dimensions dynamically using an event listener
2024-10-11 19:03:35 +02:00
Fredrik Burmester
eeaa027579 fix: turn into hook 2024-10-11 19:03:15 +02:00
Fredrik Burmester
a4c20981cf Merge pull request #174 from jakequade/fix/issue-159-chromecast-button-android
fix: casting broken on 0.17.0
2024-10-11 19:01:41 +02:00
Alex Kim
57354e6b06 WIP 2024-10-12 03:00:26 +11:00
Fredrik Burmester
63965c9e64 fix: video rotation bug 2024-10-11 16:41:44 +02:00
Fredrik Burmester
c5f39f6f8a chore: no need for these props any more 2024-10-11 12:04:15 +02:00
Fredrik Burmester
eb841601f6 fix: use correct url for chromecast streaming 2024-10-11 12:04:15 +02:00
jakequade
3f5ce6dc43 use cast button rather than feather icon for casting 2024-10-11 20:34:01 +11:00
Fredrik Burmester
2ed29e5a18 wip 2024-10-10 23:11:40 +02:00
Fredrik Burmester
380172c5ac Merge branch 'master' into feat/native-tabbar 2024-10-10 23:11:35 +02:00
Fredrik Burmester
b73a33b05b chore 2024-10-10 17:27:21 +02:00
Fredrik Burmester
e3baa2f58b fix: rotation issues 2024-10-10 17:27:17 +02:00
Alex Kim
8be1e2df0c Push to remote repo 2024-10-10 23:40:01 +11:00
Fredrik Burmester
ef7fbc985f chore 2024-10-10 10:10:24 +02:00
Fredrik Burmester
381c6701f2 chore: version bump 2024-10-10 07:56:23 +02:00
Fredrik Burmester
71da79ee6a chore 2024-10-09 20:23:53 +02:00
Fredrik Burmester
5cff323871 feat: go to next episode on end 2024-10-09 20:23:50 +02:00
Fredrik Burmester
39b7c66d34 fix: don't crash app when no media source found for unmatched items 2024-10-09 20:23:40 +02:00
Fredrik Burmester
57201f8606 wip 2024-10-09 20:06:30 +02:00
Fredrik Burmester
0a098bf26e chore 2024-10-09 07:28:53 +02:00
Fredrik Burmester
f6cb90e5dc feat: add logo to login 2024-10-09 07:28:49 +02:00
Fredrik Burmester
eba9163ce8 wip 2024-10-08 22:24:52 +02:00
Fredrik Burmester
4b166cf1d8 first commit 2024-10-08 21:21:12 +02:00
Fredrik Burmester
b878e93dec Merge pull request #163 from Alexk2309/master
Removed resumable items from next up
2024-10-08 19:55:28 +02:00
Fredrik Burmester
66cd36a899 feat: native selectable text for titles 2024-10-08 19:53:48 +02:00
Fredrik Burmester
91b926e6c2 fix: larger tap area 2024-10-08 19:53:38 +02:00
Fredrik Burmester
d4cc7499c0 Merge pull request #165 from fredrikburmester/refactor/player
Refactor: Update the player logic (video, music, live-tv)
2024-10-08 18:54:43 +02:00
Fredrik Burmester
317e719460 wip 2024-10-08 18:43:25 +02:00
Alex Kim
6012f8c8d2 Removed resumable items from next up 2024-10-09 02:40:11 +11:00
Fredrik Burmester
ec0843d737 wip 2024-10-08 15:39:44 +02:00
Fredrik Burmester
a5b4f6cc78 wip 2024-10-07 10:00:16 +02:00
Fredrik Burmester
4b60de4d43 fix: padding 2024-10-07 08:18:24 +02:00
Fredrik Burmester
aa56749402 wip 2024-10-06 22:48:31 +02:00
Fredrik Burmester
d6f02bd970 wip 2024-10-06 16:33:29 +02:00
Fredrik Burmester
862e783de1 wip 2024-10-06 15:11:06 +02:00
Fredrik Burmester
0233862fc1 wip 2024-10-06 13:03:16 +02:00
Fredrik Burmester
cc242a971f Merge branch 'master' into refactor/player 2024-10-06 09:48:42 +02:00
Fredrik Burmester
4fc3044838 Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-10-05 21:28:11 +02:00
Fredrik Burmester
df6cd17099 chore 2024-10-05 21:28:07 +02:00
Fredrik Burmester
5e8a0a9fa9 Merge pull request #156 from fredrikburmester/feat/live-tv
feat: live-tv support
2024-10-05 20:01:49 +02:00
Fredrik Burmester
aa89c66e6e Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-10-05 13:26:46 +02:00
Fredrik Burmester
b1e2020b43 feat: new icons 2024-10-05 13:26:44 +02:00
retardgerman
5ab53738d5 fix: international App Store link 2024-10-05 13:18:49 +02:00
Fredrik Burmester
bf1efd7ca2 chore 2024-10-05 09:29:42 +02:00
Fredrik Burmester
b25cdce702 first commit 2024-10-04 12:08:45 +02:00
184 changed files with 11327 additions and 5017 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.

5
.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,12 @@ package-lock.json
/ios
/android
modules/player/android
pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json
*.apk
*.ipa
.continuerc.json
.vscode/

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,8 @@
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[swift]": {
"editor.defaultFormatter": "sswg.swift-lang"
}
}

View File

@@ -62,7 +62,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to
## Get it now
<div style="display: flex; gap: 5px;">
<a href="https://apps.apple.com/se/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
</div>

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.17.0",
"version": "0.22.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -10,7 +10,7 @@
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#29164B"
"backgroundColor": "#2E2E2E"
},
"jsEngine": "hermes",
"assetBundlePatterns": ["**/*"],
@@ -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": 42,
"versionCode": 47,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png"
"foregroundImage": "./assets/images/adaptive_icon.png"
},
"package": "com.fredrikburmester.streamyfin",
"permissions": [
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.WRITE_SETTINGS"
]
},
"plugins": [
@@ -66,18 +70,12 @@
}
}
],
[
"./plugins/withAndroidMainActivityAttributes",
{
"com.reactnative.googlecast.RNGCExpandedControllerActivity": true
}
],
["./plugins/withExpandedController.js"],
[
"expo-build-properties",
{
"ios": {
"deploymentTarget": "15.6"
"deploymentTarget": "15.6",
"useFrameworks": "static"
},
"android": {
"android": {
@@ -106,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,132 @@
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
SeasonDropdown,
SeasonIndexState,
} 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(() => {
Alert.alert(
"Delete season",
"Are you sure you want to delete the entire season?",
[
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
onPress: () => deleteItems(groupBySeason),
style: "destructive",
},
]
);
}, [groupBySeason]);
return (
<View className="flex-1">
{series.length > 0 && (
<View className="flex flex-row items-center justify-start my-2 px-4">
<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="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2">
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
</View>
<View className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto">
<TouchableOpacity onPress={deleteSeries}>
<Ionicons name="trash" size={20} color="white" />
</TouchableOpacity>
</View>
</View>
)}
<ScrollView key={seasonIndex} className="px-4">
{groupBySeason.map((episode, index) => (
<EpisodeCard key={index} item={episode} />
))}
</ScrollView>
</View>
);
}

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("Episode")
.then(() => toast.success("Deleted all TV-Series successfully!"))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error("Failed to delete all TV-Series");
});
const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()])
return (
<>
<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";
@@ -25,11 +25,10 @@ import {
import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Platform,
RefreshControl,
ScrollView,
TouchableOpacity,
@@ -54,11 +53,10 @@ type MediaListSection = {
type Section = ScrollingCollectionListSection | MediaListSection;
export default function index() {
const queryClient = useQueryClient();
const router = useRouter();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const [settings, _] = useSettings();
@@ -66,9 +64,11 @@ export default function index() {
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
const { downloadedFiles } = useDownload();
const { downloadedFiles, cleanCacheDirectory } = useDownload();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
useEffect(() => {
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
navigation.setOptions({
@@ -107,6 +107,9 @@ export default function index() {
setIsConnected(state.isConnected);
});
cleanCacheDirectory()
.then(r => console.log("Cache directory cleaned"))
.catch(e => console.error("Something went wrong cleaning cache directory"))
return () => {
unsubscribe();
};
@@ -117,7 +120,7 @@ export default function index() {
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["userViews", user?.Id],
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
@@ -138,7 +141,7 @@ export default function index() {
isError: e2,
isLoading: l2,
} = useQuery({
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
queryKey: ["home", "sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
@@ -165,9 +168,11 @@ export default function index() {
);
}, [userViews]);
const invalidateCache = useInvalidatePlaybackProgressCache();
const refetch = useCallback(async () => {
setLoading(true);
await queryClient.invalidateQueries();
await invalidateCache();
setLoading(false);
}, []);
@@ -186,7 +191,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"],
@@ -208,7 +213,12 @@ export default function index() {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = "Recently Added in " + c.Name;
const queryKey = ["recentlyAddedIn" + c.CollectionType, user?.Id!, c.Id!];
const queryKey = [
"home",
"recentlyAddedIn" + c.CollectionType,
user?.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
@@ -220,12 +230,13 @@ export default function index() {
const ss: Section[] = [
{
title: "Continue Watching",
queryKey: ["resumeItems", user.Id],
queryKey: ["home", "resumeItems"],
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
})
).data.Items || [],
type: "ScrollingCollectionList",
@@ -233,7 +244,7 @@ export default function index() {
},
{
title: "Next Up",
queryKey: ["nextUp-all", user?.Id],
queryKey: ["home", "nextUp-all"],
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
@@ -241,6 +252,7 @@ export default function index() {
fields: ["MediaSourceCount"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
})
).data.Items || [],
type: "ScrollingCollectionList",
@@ -251,7 +263,7 @@ export default function index() {
(ml) =>
({
title: ml.Name,
queryKey: ["mediaList", ml.Id!],
queryKey: ["home", "mediaList", ml.Id!],
queryFn: async () => ml,
type: "MediaListSection",
orientation: "vertical",
@@ -259,7 +271,7 @@ export default function index() {
) || []),
{
title: "Suggested Movies",
queryKey: ["suggestedMovies", user?.Id],
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async () =>
(
await getSuggestionsApi(api).getSuggestions({
@@ -274,7 +286,7 @@ export default function index() {
},
{
title: "Suggested Episodes",
queryKey: ["suggestedEpisodes", user?.Id],
queryKey: ["home", "suggestedEpisodes", user?.Id],
queryFn: async () => {
try {
const suggestions = await getSuggestions(api, user.Id);
@@ -338,9 +350,7 @@ export default function index() {
);
}
const insets = useSafeAreaInsets();
if (e1 || e2 || !api)
if (e1 || e2)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">Oops!</Text>
@@ -364,15 +374,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={{
@@ -74,13 +104,16 @@ export default function settings() {
registerBackgroundFetchAsync
</Button> */}
<View>
<Text className="font-bold text-lg mb-2">Information</Text>
<Text className="font-bold text-lg mb-2">User Info</Text>
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
<ListItem title="User" subTitle={user?.Name} />
<ListItem title="Server" subTitle={api?.basePath} />
<ListItem title="Token" subTitle={api?.accessToken} />
</View>
<Button className="my-2.5" color="black" onPress={logout}>
Log out
</Button>
</View>
<View>
@@ -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>
@@ -143,7 +170,9 @@ export default function settings() {
>
{log.level}
</Text>
<Text className="text-xs">{log.message}</Text>
<Text uiTextView selectable className="text-xs">
{log.message}
</Text>
</View>
))}
{logs?.length === 0 && (

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { Text } from "@/components/common/Text";
import { DownloadItems } from "@/components/DownloadItem";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { NextUp } from "@/components/series/NextUp";
import { SeasonPicker } from "@/components/series/SeasonPicker";
@@ -6,15 +7,17 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Ionicons } from "@expo/vector-icons";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } 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, useMemo } from "react";
import { View } from "react-native";
const page: React.FC = () => {
const navigation = useNavigation();
const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as {
id: string;
@@ -56,6 +59,46 @@ const page: React.FC = () => {
[item]
);
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
title="Download Series"
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name="download" size={22} color="white" />
)}
DownloadedIconComponent={() => (
<Ionicons
name="checkmark-done-outline"
size={24}
color="#9333ea"
/>
)}
/>
</View>
),
});
}, [allEpisodes, isLoading]);
if (!item || !backdropUrl) return null;
return (

View File

@@ -1,12 +1,8 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import {
useFocusEffect,
useLocalSearchParams,
useNavigation,
} from "expo-router";
import { useLocalSearchParams, useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
import React, { useCallback, useEffect, useMemo } from "react";
import { FlatList, useWindowDimensions, View } from "react-native";
import { Text } from "@/components/common/Text";
@@ -16,6 +12,7 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
genreFilterAtom,
@@ -32,10 +29,10 @@ import {
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import { orientationAtom } from "@/utils/atoms/orientation";
import {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getFilterApi,
@@ -44,8 +41,7 @@ import {
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
import { colletionTypeToItemType } from "@/utils/collectionTypeToItemType";
const Page = () => {
const searchParams = useLocalSearchParams();
@@ -60,12 +56,13 @@ const Page = () => {
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [sortBy, _setSortBy] = useAtom(sortByAtom);
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
const [orientation] = useAtom(orientationAtom);
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
const [sortOrderPreference, setOderByPreference] = useAtom(
sortOrderPreferenceAtom
);
const { orientation } = useOrientation();
useEffect(() => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sop) {
@@ -106,11 +103,12 @@ const Page = () => {
[libraryId, sortOrderPreference]
);
const getNumberOfColumns = useCallback(() => {
if (orientation === ScreenOrientation.Orientation.PORTRAIT_UP) return 3;
if (screenWidth < 600) return 5;
if (screenWidth < 960) return 6;
if (screenWidth < 1280) return 7;
const nrOfCols = useMemo(() => {
if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3;
if (screenWidth < 800) return 5;
if (screenWidth < 1000) return 6;
if (screenWidth < 1500) return 7;
return 6;
}, [screenWidth, orientation]);
@@ -143,6 +141,18 @@ const Page = () => {
}): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !library) return null;
console.log("[libraryId] ~", library);
let itemType: BaseItemKind | undefined;
// This fix makes sure to only return 1 type of items, if defined.
// This is because the underlying directory some times contains other types, and we don't want to show them.
if (library.CollectionType === "movies") {
itemType = "Movie";
} else if (library.CollectionType === "tvshows") {
itemType = "Series";
}
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: libraryId,
@@ -157,6 +167,7 @@ const Page = () => {
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => parseInt(year)),
includeItemTypes: itemType ? [itemType] : undefined,
});
return response.data || null;
@@ -219,7 +230,7 @@ const Page = () => {
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<MemoizedTouchableItemRouter
<TouchableItemRouter
key={item.Id}
style={{
width: "100%",
@@ -230,10 +241,10 @@ const Page = () => {
<View
style={{
alignSelf:
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
? index % 3 === 0
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
? index % nrOfCols === 0
? "flex-end"
: (index + 1) % 3 === 0
: (index + 1) % nrOfCols === 0
? "flex-start"
: "center"
: "center",
@@ -244,7 +255,7 @@ const Page = () => {
<ItemPoster item={item} />
<ItemCardText item={item} />
</View>
</MemoizedTouchableItemRouter>
</TouchableItemRouter>
),
[orientation]
);
@@ -429,6 +440,7 @@ const Page = () => {
return (
<FlashList
key={orientation}
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No results</Text>
@@ -437,10 +449,10 @@ const Page = () => {
contentInsetAdjustmentBehavior="automatic"
data={flatData}
renderItem={renderItem}
extraData={orientation}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
estimatedItemSize={244}
numColumns={getNumberOfColumns()}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();

View File

@@ -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">
@@ -278,7 +278,7 @@ export default function search() {
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28"
className="flex flex-col w-28 mr-2"
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
@@ -297,7 +297,7 @@ export default function search() {
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-44"
className="flex flex-col w-44 mr-2"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
@@ -311,7 +311,7 @@ export default function search() {
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28"
className="flex flex-col w-28 mr-2"
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
@@ -327,7 +327,7 @@ export default function search() {
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
className="flex flex-col w-28 mr-2"
>
<MoviePoster item={item} />
<ItemCardText item={item} />
@@ -341,7 +341,7 @@ export default function search() {
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
@@ -355,7 +355,7 @@ export default function search() {
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
@@ -369,7 +369,7 @@ export default function search() {
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.AlbumId} />
<ItemCardText item={item} />

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,14 +0,0 @@
import { FullScreenMusicPlayer } from "@/components/FullScreenMusicPlayer";
import { StatusBar } from "expo-status-bar";
import { View, ViewProps } from "react-native";
interface Props extends ViewProps {}
export default function page() {
return (
<View className="">
<StatusBar hidden={false} />
<FullScreenMusicPlayer />
</View>
);
}

View File

@@ -1,48 +0,0 @@
import { FullScreenVideoPlayer } from "@/components/FullScreenVideoPlayer";
import { useSettings } from "@/utils/atoms/settings";
import * as NavigationBar from "expo-navigation-bar";
import * as ScreenOrientation from "expo-screen-orientation";
import { StatusBar } from "expo-status-bar";
import { useEffect } from "react";
import { Platform, View, ViewProps } from "react-native";
interface Props extends ViewProps {}
export default function page() {
const [settings] = useSettings();
useEffect(() => {
if (settings?.autoRotate) {
// Don't need to do anything
} else if (settings?.defaultVideoOrientation) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
}
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("hidden");
NavigationBar.setBehaviorAsync("overlay-swipe");
}
return () => {
if (settings?.autoRotate) {
ScreenOrientation.unlockAsync();
} else {
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
}
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("visible");
NavigationBar.setBehaviorAsync("inset-swipe");
}
};
}, [settings]);
return (
<View className="">
<StatusBar hidden />
<FullScreenVideoPlayer />
</View>
);
}

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

View File

@@ -1,14 +1,17 @@
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
getOrSetDeviceId,
getTokenFromStoraage,
getTokenFromStorage,
JellyfinProvider,
} from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaybackProvider } from "@/providers/PlaybackProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { orientationAtom } from "@/utils/atoms/orientation";
import { Settings, useSettings } from "@/utils/atoms/settings";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
import { LogProvider, writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
@@ -18,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";
@@ -30,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";
@@ -85,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;
@@ -95,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,
@@ -119,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)) {
@@ -136,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,
@@ -190,7 +178,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
const checkAndRequestPermissions = async () => {
try {
const hasAskedBefore = await AsyncStorage.getItem(
const hasAskedBefore = storage.getString(
"hasAskedForNotificationPermission"
);
@@ -198,16 +186,23 @@ const checkAndRequestPermissions = async () => {
const { status } = await Notifications.requestPermissionsAsync();
if (status === "granted") {
writeToLog("INFO", "Notification permissions granted.");
console.log("Notification permissions granted.");
} else {
writeToLog("ERROR", "Notification permissions denied.");
console.log("Notification permissions denied.");
}
await AsyncStorage.setItem("hasAskedForNotificationPermission", "true");
storage.set("hasAskedForNotificationPermission", "true");
} else {
console.log("Already asked for notification permissions before.");
}
} catch (error) {
writeToLog(
"ERROR",
"Error checking/requesting notification permissions:",
error
);
console.error("Error checking/requesting notification permissions:", error);
}
};
@@ -223,6 +218,8 @@ export default function RootLayout() {
}
}, [loaded]);
Appearance.setColorScheme("dark");
if (!loaded) {
return null;
}
@@ -234,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);
@@ -241,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();
}, []);
@@ -311,81 +306,75 @@ function Layout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClientRef.current}>
<JobQueueProvider>
<DownloadProvider>
<ActionSheetProvider>
<BottomSheetModalProvider>
<JellyfinProvider>
<PlaybackProvider>
<StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}>
<Stack
initialRouteName="/home"
screenOptions={{
autoHideHomeIndicator: true,
}}
>
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
}}
/>
<Stack.Screen
name="(auth)/play"
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>
</PlaybackProvider>
</JellyfinProvider>
</BottomSheetModalProvider>
</ActionSheetProvider>
</DownloadProvider>
</JobQueueProvider>
<QueryClientProvider client={queryClient}>
<ActionSheetProvider>
<JobQueueProvider>
<JellyfinProvider>
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="(auth)/player"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="login"
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</JobQueueProvider>
</ActionSheetProvider>
</QueryClientProvider>
</GestureHandlerRootView>
);
}
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)
: [];
@@ -397,8 +386,9 @@ 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

@@ -5,7 +5,8 @@ import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { useLocalSearchParams } from "expo-router";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect, useState } from "react";
import {
@@ -13,6 +14,7 @@ import {
KeyboardAvoidingView,
Platform,
SafeAreaView,
TouchableOpacity,
View,
} from "react-native";
@@ -64,6 +66,23 @@ const Login: React.FC = () => {
})();
}, [_apiUrl, _username, _password]);
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerTitle: serverName,
headerLeft: () =>
api?.basePath ? (
<TouchableOpacity
onPress={() => {
removeServer();
}}
>
<Ionicons name="chevron-back" size={24} color="white" />
</TouchableOpacity>
) : null,
});
}, [serverName, navigation, api?.basePath]);
const [loading, setLoading] = useState<boolean>(false);
const handleLogin = async () => {
@@ -102,37 +121,19 @@ const Login: React.FC = () => {
* - Logs errors and timeout information to the console.
*/
async function checkUrl(url: string) {
url = url.endsWith("/") ? url.slice(0, -1) : url;
setLoadingServerCheck(true);
const protocols = ["https://", "http://"];
const timeout = 2000; // 2 seconds timeout for long 404 responses
try {
for (const protocol of protocols) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const response = await fetch(`${url}/System/Info/Public`, {
mode: "cors",
});
try {
const response = await fetch(`${protocol}${url}/System/Info/Public`, {
mode: "cors",
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok) {
const data = (await response.json()) as PublicSystemInfo;
setServerName(data.ServerName || "");
return `${protocol}${url}`;
}
} catch (e) {
const error = e as Error;
if (error.name === "AbortError") {
console.log(`Request to ${protocol}${url} timed out`);
} else {
console.error(`Error checking ${protocol}${url}:`, error);
}
}
if (response.ok) {
const data = (await response.json()) as PublicSystemInfo;
setServerName(data.ServerName || "");
return url;
}
return undefined;
} finally {
setLoadingServerCheck(false);
@@ -158,9 +159,7 @@ const Login: React.FC = () => {
const handleConnect = async (url: string) => {
url = url.trim();
const result = await checkUrl(
url.startsWith("http") ? new URL(url).host : url
);
const result = await checkUrl(url);
if (result === undefined) {
Alert.alert(
@@ -170,7 +169,7 @@ const Login: React.FC = () => {
return;
}
setServer({ address: result });
setServer({ address: url });
};
const handleQuickConnect = async () => {
@@ -195,36 +194,21 @@ const Login: React.FC = () => {
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1, height: "100%" }}
>
<View className="flex flex-col justify-between px-4 h-full gap-y-2">
<View></View>
<View>
<View className="mb-4">
<Text className="text-3xl font-bold mb-1">
{serverName || "Streamyfin"}
</Text>
<Text className="text-neutral-500 mb-2">
Server: {api.basePath}
</Text>
<Button
color="black"
onPress={() => {
removeServer();
}}
justify="between"
iconLeft={
<Ionicons
name="arrow-back-outline"
size={18}
color={"white"}
/>
}
>
Change server
</Button>
</View>
<View className="flex flex-col h-full relative items-center justify-center">
<View className="px-4 -mt-20 w-full">
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold">Log in</Text>
<Text className="text-2xl font-bold -mb-2">
Log in
<>
{serverName ? (
<>
{" to "}
<Text className="text-purple-600">{serverName}</Text>
</>
) : null}
</>
</Text>
<Text className="text-xs text-neutral-400">{serverURL}</Text>
<Input
placeholder="Username"
onChangeText={(text) =>
@@ -261,11 +245,11 @@ const Login: React.FC = () => {
<Text className="text-red-600 mb-2">{error}</Text>
</View>
<View className="mt-auto mb-2">
<View className="absolute bottom-0 left-0 w-full px-4 mb-2">
<Button
color="black"
onPress={handleQuickConnect}
className="mb-2"
className="w-full mb-2"
>
Use Quick Connect
</Button>
@@ -283,14 +267,22 @@ const Login: React.FC = () => {
<SafeAreaView style={{ flex: 1 }}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
style={{ flex: 1, height: "100%" }}
>
<View className="flex flex-col px-4 justify-between h-full">
<View></View>
<View className="flex flex-col gap-y-2">
<View className="flex flex-col h-full relative items-center justify-center w-full">
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
}}
source={require("@/assets/images/StreamyFinFinal.png")}
/>
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="text-neutral-500">
Connect to your Jellyfin server
Enter the URL to your Jellyfin server
</Text>
<Input
placeholder="Server URL"
@@ -302,15 +294,20 @@ const Login: React.FC = () => {
textContentType="URL"
maxLength={500}
/>
<Text className="text-xs text-neutral-500">
Make sure to include http or https
</Text>
</View>
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)}
className="w-full grow"
>
Connect
</Button>
</View>
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)}
className="mb-2"
>
Connect
</Button>
</View>
</KeyboardAvoidingView>
</SafeAreaView>

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
assets/icons/list.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 49 KiB

BIN
bun.lockb

Binary file not shown.

View File

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

View File

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

View File

@@ -3,7 +3,8 @@ import React, { PropsWithChildren, ReactNode, useMemo } from "react";
import { Text, TouchableOpacity, View } from "react-native";
import { Loader } from "./Loader";
interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
export interface ButtonProps
extends React.ComponentProps<typeof TouchableOpacity> {
onPress?: () => void;
className?: string;
textClassName?: string;

View File

@@ -1,14 +1,16 @@
import { Feather } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import React, { useEffect } from "react";
import React, { useCallback, useEffect } from "react";
import { Platform, TouchableOpacity, ViewProps } from "react-native";
import GoogleCast, {
CastButton,
CastContext,
useCastDevice,
useDevices,
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
import { RoundButton } from "./RoundButton";
interface Props extends ViewProps {
width?: number;
@@ -39,49 +41,43 @@ export const Chromecast: React.FC<Props> = ({
})();
}, [client, devices, castDevice, sessionManager, discoveryManager]);
// Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback(
() =>
Platform.OS === "android" ? (
<CastButton tintColor="transparent" />
) : (
<></>
),
[Platform.OS]
);
if (background === "transparent")
return (
<TouchableOpacity
<RoundButton
size="large"
className="mr-2"
background={false}
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
className="rounded-full h-10 w-10 flex items-center justify-center b"
{...props}
>
<Feather name="cast" size={22} color={"white"} />
</TouchableOpacity>
);
if (Platform.OS === "android")
return (
<TouchableOpacity
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
className="rounded-full h-10 w-10 flex items-center justify-center bg-neutral-800/80"
{...props}
>
<Feather name="cast" size={22} color={"white"} />
</TouchableOpacity>
</RoundButton>
);
return (
<TouchableOpacity
<RoundButton
size="large"
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
{...props}
>
<BlurView
intensity={100}
className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center"
{...props}
>
<Feather name="cast" size={22} color={"white"} />
</BlurView>
</TouchableOpacity>
<Feather name="cast" size={22} color={"white"} />
</RoundButton>
);
};

View File

@@ -1,27 +1,30 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { useAtomValue } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator";
import React from "react";
import { Ionicons } from "@expo/vector-icons";
type ContinueWatchingPosterProps = {
item: BaseItemDto;
useEpisodePoster?: boolean;
size?: "small" | "normal";
showPlayButton?: boolean;
};
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
item,
useEpisodePoster = false,
size = "normal",
showPlayButton = false,
}) => {
const [api] = useAtom(apiAtom);
const api = useAtomValue(apiAtom);
/**
* Get horrizontal poster for movie and episode, with failover to primary.
* Get horizontal poster for movie and episode, with failover to primary.
*/
const url = useMemo(() => {
if (!api) return;
@@ -59,7 +62,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
} else {
return item.UserData?.PlayedPercentage || 0;
}
}, []);
}, [item]);
if (!url)
return (
@@ -73,16 +76,23 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
${size === "small" ? "w-32" : "w-44"}
`}
>
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
/>
<View className="w-full h-full flex items-center justify-center">
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
/>
{showPlayButton && (
<View className="absolute inset-0 flex items-center justify-center">
<Ionicons name="play-circle" size={40} color="white" />
</View>
)}
</View>
{!progress && <WatchedIndicator item={item} />}
{progress > 0 && (
<>

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,10 +18,11 @@ import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { Href, router, useFocusEffect } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Alert, View, ViewProps } from "react-native";
import { toast } from "sonner-native";
import { AudioTrackSelector } from "./AudioTrackSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button";
@@ -28,24 +30,37 @@ import { Text } from "./common/Text";
import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle";
import { RoundButton } from "./RoundButton";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
import { toast } from "sonner-native";
import iosFmp4 from "@/utils/profiles/iosFmp4";
interface DownloadProps extends ViewProps {
item: BaseItemDto;
items: BaseItemDto[];
MissingDownloadIconComponent: () => React.ReactElement;
DownloadedIconComponent: () => React.ReactElement;
title?: string;
subtitle?: string;
size?: "default" | "large";
}
export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
export const DownloadItems: React.FC<DownloadProps> = ({
items,
MissingDownloadIconComponent,
DownloadedIconComponent,
title = "Download",
subtitle = "",
size = "default",
...props
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
const { processes, startBackgroundDownload } = useDownload();
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(item);
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
const { startRemuxing } = useRemuxHlsToMp4();
const [selectedMediaSource, setSelectedMediaSource] =
useState<MediaSourceInfo | null>(null);
const [selectedMediaSource, setSelectedMediaSource] = useState<
MediaSourceInfo | undefined | null
>(undefined);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
@@ -54,13 +69,15 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
value: undefined,
});
const userCanDownload = useMemo(() => {
return user?.Policy?.EnableContentDownloading;
}, [user]);
const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading,
[user]
);
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === "optimized",
[settings]
);
/**
* Bottom sheet
*/
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const handlePresentModalPress = useCallback(() => {
@@ -73,114 +90,161 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
bottomSheetModalRef.current?.dismiss();
}, []);
/**
* 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 itemIds = useMemo(() => items.map((i) => i.Id), [items]);
let deviceProfile: any = iosFmp4;
const itemsNotDownloaded = useMemo(
() =>
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
[items, downloadedFiles]
);
if (settings?.deviceProfile === "Native") {
deviceProfile = native;
} else if (settings?.deviceProfile === "Old") {
deviceProfile = old;
}
const allItemsDownloaded = useMemo(() => {
if (items.length === 0) return false;
return itemsNotDownloaded.length === 0;
}, [items, itemsNotDownloaded]);
const itemsProcesses = useMemo(
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
[processes, itemIds]
);
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}"`,
},
}
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]);
let url: string | undefined = undefined;
let fileExtension: string | undefined | null = "mp4";
const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
(source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id
const itemsQueued = useMemo(() => {
return (
itemsNotDownloaded.length > 0 &&
itemsNotDownloaded.every((p) => queue.some((q) => p.Id == q.item.Id))
);
}, [queue, itemsNotDownloaded]);
const navigateToDownloads = () => router.push("/downloads");
if (!mediaSource) {
throw new Error("No media source");
}
const onDownloadedPress = () => {
const firstItem = items?.[0];
router.push(
firstItem.Type !== "Episode"
? "/downloads"
: ({
pathname: `/downloads/${firstItem.SeriesId}`,
params: {
episodeSeasonIndex: firstItem.ParentIndexNumber,
},
} as Href)
);
};
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()}`;
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (itemsNotDownloaded.some((i) => !i.Id)) {
throw new Error("No item id");
}
} else if (mediaSource.TranscodingUrl) {
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
fileExtension = mediaSource.TranscodingContainer;
}
closeModal();
if (!url) throw new Error("No url");
if (!fileExtension) throw new Error("No file extension");
if (settings?.downloadMethod === "optimized") {
return await startBackgroundDownload(url, item, fileExtension);
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
else {
queueActions.enqueue(
queue,
setQueue,
...itemsNotDownloaded.map((item) => ({
id: item.Id!,
execute: async () => await initiateDownload(item),
item,
}))
);
}
} else {
return await startRemuxing(url);
toast.error("You are not allowed to download files.");
}
}, [
api,
item,
startBackgroundDownload,
user?.Id,
queue,
setQueue,
itemsNotDownloaded,
usingOptimizedServer,
userCanDownload,
maxBitrate,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
maxBitrate,
settings?.downloadMethod,
]);
/**
* Check if item is downloaded
*/
const { downloadedFiles } = useDownload();
const initiateDownload = useCallback(
async (...items: BaseItemDto[]) => {
if (
!api ||
!user?.Id ||
items.some((p) => !p.Id) ||
(itemsNotDownloaded.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;
const isDownloaded = useMemo(() => {
if (!downloadedFiles) return false;
for (const item of items) {
if (itemsNotDownloaded.length > 1) {
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
item,
settings!
));
}
return downloadedFiles.some((file) => file.Id === item.Id);
}, [downloadedFiles, item.Id]);
const res = await getStreamUrl({
api,
item,
startTimeTicks: 0,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: maxBitrate.value,
mediaSourceId: mediaSource?.Id,
subtitleStreamIndex: subtitleIndex,
deviceProfile: download,
});
if (!res) {
Alert.alert(
"Something went wrong",
"Could not get stream url from Jellyfin"
);
continue;
}
const { mediaSource: source, url } = res;
if (!url || !source) throw new Error("No url");
saveDownloadItemInfoToDiskTmp(item, source, url);
if (usingOptimizedServer) {
await startBackgroundDownload(url, item, source);
} else {
await startRemuxing(item, url, source);
}
}
},
[
api,
user?.Id,
itemsNotDownloaded,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
settings,
maxBitrate,
usingOptimizedServer,
startBackgroundDownload,
startRemuxing,
]
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
@@ -192,59 +256,61 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
),
[]
);
useFocusEffect(
useCallback(() => {
if (!settings) return;
if (itemsNotDownloaded.length !== 1) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(items[0], settings);
const process = useMemo(() => {
if (!processes) return null;
setSelectedMediaSource(mediaSource ?? undefined);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [items, itemsNotDownloaded, settings])
);
return processes.find((process) => process?.item?.Id === item.Id);
}, [processes, item.Id]);
const renderButtonContent = () => {
if (processes && itemsProcesses.length > 0) {
return progress === 0 ? (
<Loader />
) : (
<View className="-rotate-45">
<ProgressCircle
size={24}
fill={progress}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
/>
</View>
);
} else if (itemsQueued) {
return <Ionicons name="hourglass" size={24} color="white" />;
} else if (allItemsDownloaded) {
return <DownloadedIconComponent />;
} else {
return <MissingDownloadIconComponent />;
}
};
const onButtonPress = () => {
if (processes && itemsProcesses.length > 0) {
navigateToDownloads();
} else if (itemsQueued) {
navigateToDownloads();
} else if (allItemsDownloaded) {
onDownloadedPress();
} else {
handlePresentModalPress();
}
};
return (
<View
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 ? (
<Loader />
) : (
<View className="-rotate-45">
<ProgressCircle
size={24}
fill={process.progress}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
/>
</View>
)}
</TouchableOpacity>
) : queue.some((i) => i.id === item.Id) ? (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<Ionicons name="hourglass" size={24} color="white" />
</TouchableOpacity>
) : isDownloaded ? (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<Ionicons name="cloud-download" size={26} color="#9333ea" />
</TouchableOpacity>
) : (
<TouchableOpacity onPress={handlePresentModalPress}>
<Ionicons name="cloud-download-outline" size={24} color="white" />
</TouchableOpacity>
)}
<View {...props}>
<RoundButton size={size} onPress={onButtonPress}>
{renderButtonContent()}
</RoundButton>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
@@ -259,68 +325,57 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
>
<BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<Text className="font-bold text-2xl text-neutral-10">
Download options
</Text>
<View>
<Text className="font-bold text-2xl text-neutral-100">
{title}
</Text>
<Text className="text-neutral-300">
{subtitle || `Download ${itemsNotDownloaded.length} items`}
</Text>
</View>
<View className="flex flex-col space-y-2 w-full items-start">
<BitrateSelector
inverted
onChange={(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}
{itemsNotDownloaded.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>
@@ -328,3 +383,23 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
</View>
);
};
export const DownloadSingleItem: React.FC<{
size?: "default" | "large";
item: BaseItemDto;
}> = ({ item, size = "default" }) => {
return (
<DownloadItems
size={size}
title="Download Episode"
subtitle={item.Name!}
items={[item]}
MissingDownloadIconComponent={() => (
<Ionicons name="cloud-download-outline" size={24} color="white" />
)}
DownloadedIconComponent={() => (
<Ionicons name="cloud-download" size={26} color="#9333ea" />
)}
/>
);
};

View File

@@ -1,544 +0,0 @@
import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import { apiAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { formatTimeString, ticksToSeconds } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { useRouter, useSegments } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
BackHandler,
Dimensions,
Pressable,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Video, { OnProgressData } from "react-native-video";
import { Text } from "./common/Text";
import { itemRouter } from "./common/TouchableItemRouter";
import { Loader } from "./Loader";
const windowDimensions = Dimensions.get("window");
const screenDimensions = Dimensions.get("screen");
export const FullScreenMusicPlayer: React.FC = () => {
const {
currentlyPlaying,
pauseVideo,
playVideo,
stopPlayback,
setIsPlaying,
isPlaying,
videoRef,
onProgress,
setIsBuffering,
} = usePlayback();
const [settings] = useSettings();
const [api] = useAtom(apiAtom);
const router = useRouter();
const segments = useSegments();
const insets = useSafeAreaInsets();
const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying });
const [showControls, setShowControls] = useState(true);
const [isBuffering, setIsBufferingState] = useState(true);
// Seconds
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const progress = useSharedValue(0);
const min = useSharedValue(0);
const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0);
const [dimensions, setDimensions] = useState({
window: windowDimensions,
screen: screenDimensions,
});
useEffect(() => {
const subscription = Dimensions.addEventListener(
"change",
({ window, screen }) => {
setDimensions({ window, screen });
}
);
return () => subscription?.remove();
});
const from = useMemo(() => segments[2], [segments]);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = ticksToSeconds(currentProgress);
const remaining = ticksToSeconds(maxValue - current);
setCurrentTime(current);
setRemainingTime(remaining);
},
[]
);
const { showSkipButton, skipIntro } = useIntroSkipper(
currentlyPlaying?.item.Id,
currentTime,
videoRef
);
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
currentlyPlaying?.item.Id,
currentTime,
videoRef
);
useAnimatedReaction(
() => ({
progress: progress.value,
max: max.value,
isSeeking: isSeeking.value,
}),
(result) => {
if (result.isSeeking === false) {
runOnJS(updateTimes)(result.progress, result.max);
}
},
[updateTimes]
);
useEffect(() => {
const backAction = () => {
if (currentlyPlaying) {
Alert.alert("Hold on!", "Are you sure you want to exit?", [
{
text: "Cancel",
onPress: () => null,
style: "cancel",
},
{
text: "Yes",
onPress: () => {
stopPlayback();
router.back();
},
},
]);
return true;
}
return false;
};
const backHandler = BackHandler.addEventListener(
"hardwareBackPress",
backAction
);
return () => backHandler.remove();
}, [currentlyPlaying, stopPlayback, router]);
const poster = useMemo(() => {
if (!currentlyPlaying?.item || !api) return "";
return currentlyPlaying.item.Type === "Audio"
? `${api.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: currentlyPlaying.item,
quality: 70,
width: 200,
});
}, [currentlyPlaying?.item, api]);
const videoSource = useMemo(() => {
if (!api || !currentlyPlaying || !poster) return null;
const startPosition = currentlyPlaying.item?.UserData?.PlaybackPositionTicks
? Math.round(currentlyPlaying.item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: currentlyPlaying.url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: currentlyPlaying.item?.AlbumArtist ?? undefined,
title: currentlyPlaying.item?.Name || "Unknown",
description: currentlyPlaying.item?.Overview ?? undefined,
imageUri: poster,
subtitle: currentlyPlaying.item?.Album ?? undefined,
},
};
}, [currentlyPlaying, api, poster]);
useEffect(() => {
if (currentlyPlaying) {
progress.value =
currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0;
max.value = currentlyPlaying.item.RunTimeTicks || 0;
setShowControls(true);
playVideo();
}
}, [currentlyPlaying]);
const toggleControls = () => setShowControls(!showControls);
const handleVideoProgress = useCallback(
(data: OnProgressData) => {
if (isSeeking.value === true) return;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBufferingState(data.playableDuration === 0);
setIsBuffering(data.playableDuration === 0);
onProgress(data);
},
[onProgress, setIsBuffering, isSeeking]
);
const handleVideoError = useCallback(
(e: any) => {
console.log(e);
writeToLog("ERROR", "Video playback error: " + JSON.stringify(e));
Alert.alert("Error", "Cannot play this video file.");
setIsPlaying(false);
},
[setIsPlaying]
);
const handlePlayPause = useCallback(() => {
if (isPlaying) pauseVideo();
else playVideo();
}, [isPlaying, pauseVideo, playVideo]);
const handleSliderComplete = (value: number) => {
progress.value = value;
isSeeking.value = false;
videoRef.current?.seek(value / 10000000);
};
const handleSliderChange = (value: number) => {};
const handleSliderStart = useCallback(() => {
if (showControls === false) return;
isSeeking.value = true;
}, []);
const handleSkipBackward = useCallback(async () => {
if (!settings) return;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings]);
const handleSkipForward = useCallback(async () => {
if (!settings) return;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings]);
const handleGoToPreviousItem = useCallback(() => {
if (!previousItem || !from) return;
const url = itemRouter(previousItem, from);
stopPlayback();
// @ts-ignore
router.push(url);
}, [previousItem, from, stopPlayback, router]);
const handleGoToNextItem = useCallback(() => {
if (!nextItem || !from) return;
const url = itemRouter(nextItem, from);
stopPlayback();
// @ts-ignore
router.push(url);
}, [nextItem, from, stopPlayback, router]);
if (!currentlyPlaying) return null;
return (
<View
style={{
width: dimensions.window.width,
height: dimensions.window.height,
backgroundColor: "black",
}}
>
<Pressable
onPress={toggleControls}
style={[
{
position: "absolute",
top: 0,
bottom: 0,
left: insets.left,
right: insets.right,
width: dimensions.window.width - (insets.left + insets.right),
},
]}
>
{videoSource && (
<>
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={"contain"}
onProgress={handleVideoProgress}
onLoad={(data) => (max.value = secondsToTicks(data.duration))}
onError={handleVideoError}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
/>
</>
)}
</Pressable>
<View pointerEvents="none" className="p-4">
<Image
source={poster ? { uri: poster } : undefined}
style={{
width: "100%",
height: "100%",
resizeMode: "contain",
}}
/>
</View>
{(showControls || isBuffering) && (
<View
pointerEvents="none"
style={[
{
top: 0,
left: 0,
position: "absolute",
width: dimensions.window.width,
height: dimensions.window.height,
},
]}
className=" bg-black/50 z-0"
></View>
)}
{isBuffering && (
<View
pointerEvents="none"
className="fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center"
>
<Loader />
</View>
)}
{showSkipButton && (
<View
style={[
{
position: "absolute",
bottom: insets.bottom + 70,
right: insets.right + 16,
height: 70,
},
]}
className="z-10"
>
<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>
)}
{showSkipCreditButton && (
<View
style={[
{
position: "absolute",
bottom: insets.bottom + 70,
right: insets.right + 16,
height: 70,
},
]}
className="z-10"
>
<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>
)}
{showControls && (
<>
<View
style={[
{
position: "absolute",
top: insets.top,
right: insets.right + 16,
height: 70,
zIndex: 10,
},
]}
className="flex flex-row items-center space-x-2 z-10"
>
<TouchableOpacity
onPress={() => {
stopPlayback();
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<View
style={[
{
position: "absolute",
bottom: insets.bottom + 8,
left: insets.left + 16,
width:
dimensions.window.width - insets.left - insets.right - 32,
},
]}
>
<View className="shrink flex flex-col justify-center h-full mb-2">
<Text className="font-bold">{currentlyPlaying.item?.Name}</Text>
{currentlyPlaying.item?.Type === "Episode" && (
<Text className="opacity-50">
{currentlyPlaying.item.SeriesName}
</Text>
)}
{currentlyPlaying.item?.Type === "Movie" && (
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.ProductionYear}
</Text>
)}
{currentlyPlaying.item?.Type === "Audio" && (
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.Album}
</Text>
)}
</View>
<View
className={`flex ${"flex-col-reverse py-4 px-4 rounded-2xl"}
items-center bg-neutral-800`}
>
<View className="flex flex-row items-center space-x-4">
<TouchableOpacity
style={{
opacity: !previousItem ? 0.5 : 1,
}}
onPress={handleGoToPreviousItem}
>
<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={handlePlayPause}>
<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={handleGoToNextItem}
>
<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)",
}}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
sliderHeight={10}
progress={progress}
thumbWidth={0}
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

@@ -1,626 +0,0 @@
import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import { apiAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { formatTimeString, ticksToSeconds } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { useRouter, useSegments } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
BackHandler,
Dimensions,
Pressable,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Video, { OnProgressData, ReactVideoProps } from "react-native-video";
import { Text } from "./common/Text";
import { itemRouter } from "./common/TouchableItemRouter";
import { Loader } from "./Loader";
const windowDimensions = Dimensions.get("window");
const screenDimensions = Dimensions.get("screen");
export const FullScreenVideoPlayer: React.FC = () => {
const {
currentlyPlaying,
pauseVideo,
playVideo,
stopPlayback,
setIsPlaying,
isPlaying,
videoRef,
onProgress,
setIsBuffering,
} = usePlayback();
const [settings] = useSettings();
const [api] = useAtom(apiAtom);
const router = useRouter();
const segments = useSegments();
const insets = useSafeAreaInsets();
const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying });
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } =
useTrickplay(currentlyPlaying);
const [showControls, setShowControls] = useState(true);
const [isBuffering, setIsBufferingState] = useState(true);
const [ignoreSafeArea, setIgnoreSafeArea] = useState(false);
const [orientation, setOrientation] = useState(
ScreenOrientation.OrientationLock.UNKNOWN
);
// Seconds
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const progress = useSharedValue(0);
const min = useSharedValue(0);
const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0);
const [dimensions, setDimensions] = useState({
window: windowDimensions,
screen: screenDimensions,
});
useEffect(() => {
const dimensionsSubscription = Dimensions.addEventListener(
"change",
({ window, screen }) => {
setDimensions({ window, screen });
}
);
const orientationSubscription =
ScreenOrientation.addOrientationChangeListener((event) => {
setOrientation(
orientationToOrientationLock(event.orientationInfo.orientation)
);
});
ScreenOrientation.getOrientationAsync().then((orientation) => {
setOrientation(orientationToOrientationLock(orientation));
});
return () => {
dimensionsSubscription.remove();
orientationSubscription.remove();
};
}, []);
const from = useMemo(() => segments[2], [segments]);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = ticksToSeconds(currentProgress);
const remaining = ticksToSeconds(maxValue - current);
setCurrentTime(current);
setRemainingTime(remaining);
},
[]
);
const { showSkipButton, skipIntro } = useIntroSkipper(
currentlyPlaying?.item.Id,
currentTime,
videoRef
);
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
currentlyPlaying?.item.Id,
currentTime,
videoRef
);
useAnimatedReaction(
() => ({
progress: progress.value,
max: max.value,
isSeeking: isSeeking.value,
}),
(result) => {
if (result.isSeeking === false) {
runOnJS(updateTimes)(result.progress, result.max);
}
},
[updateTimes]
);
useEffect(() => {
const backAction = () => {
if (currentlyPlaying) {
Alert.alert("Hold on!", "Are you sure you want to exit?", [
{
text: "Cancel",
onPress: () => null,
style: "cancel",
},
{
text: "Yes",
onPress: () => {
stopPlayback();
router.back();
},
},
]);
return true;
}
return false;
};
const backHandler = BackHandler.addEventListener(
"hardwareBackPress",
backAction
);
return () => backHandler.remove();
}, [currentlyPlaying, stopPlayback, router]);
const isLandscape = useMemo(() => {
return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT ||
orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
? true
: false;
}, [orientation]);
const poster = useMemo(() => {
if (!currentlyPlaying?.item || !api) return "";
return currentlyPlaying.item.Type === "Audio"
? `${api.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: currentlyPlaying.item,
quality: 70,
width: 200,
});
}, [currentlyPlaying?.item, api]);
const videoSource: ReactVideoProps["source"] = useMemo(() => {
if (!api || !currentlyPlaying || !poster) return undefined;
const startPosition = currentlyPlaying.item?.UserData?.PlaybackPositionTicks
? Math.round(currentlyPlaying.item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: currentlyPlaying.url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: currentlyPlaying.item?.AlbumArtist ?? undefined,
title: currentlyPlaying.item?.Name || "Unknown",
description: currentlyPlaying.item?.Overview ?? undefined,
imageUri: poster,
subtitle: currentlyPlaying.item?.Album ?? undefined,
},
};
}, [currentlyPlaying, api, poster]);
useEffect(() => {
if (currentlyPlaying) {
progress.value =
currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0;
max.value = currentlyPlaying.item.RunTimeTicks || 0;
setShowControls(true);
playVideo();
}
}, [currentlyPlaying]);
const toggleControls = () => setShowControls(!showControls);
const handleVideoProgress = useCallback(
(data: OnProgressData) => {
if (isSeeking.value === true) return;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBufferingState(data.playableDuration === 0);
setIsBuffering(data.playableDuration === 0);
onProgress(data);
},
[onProgress, setIsBuffering, isSeeking]
);
const handleVideoError = useCallback(
(e: any) => {
console.log(e);
writeToLog("ERROR", "Video playback error: " + JSON.stringify(e));
Alert.alert("Error", "Cannot play this video file.");
setIsPlaying(false);
},
[setIsPlaying]
);
const handlePlayPause = useCallback(() => {
if (isPlaying) pauseVideo();
else playVideo();
}, [isPlaying, pauseVideo, playVideo]);
const handleSliderComplete = (value: number) => {
progress.value = value;
isSeeking.value = false;
videoRef.current?.seek(value / 10000000);
};
const handleSliderChange = (value: number) => {
calculateTrickplayUrl(value);
};
const handleSliderStart = useCallback(() => {
if (showControls === false) return;
isSeeking.value = true;
}, []);
const handleSkipBackward = useCallback(async () => {
if (!settings) return;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings]);
const handleSkipForward = useCallback(async () => {
if (!settings) return;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings]);
const handleGoToPreviousItem = useCallback(() => {
if (!previousItem || !from) return;
const url = itemRouter(previousItem, from);
stopPlayback();
// @ts-ignore
router.push(url);
}, [previousItem, from, stopPlayback, router]);
const handleGoToNextItem = useCallback(() => {
if (!nextItem || !from) return;
const url = itemRouter(nextItem, from);
stopPlayback();
// @ts-ignore
router.push(url);
}, [nextItem, from, stopPlayback, router]);
const toggleIgnoreSafeArea = useCallback(() => {
setIgnoreSafeArea((prev) => !prev);
}, []);
if (!currentlyPlaying) return null;
return (
<View
style={{
width: dimensions.window.width,
height: dimensions.window.height,
backgroundColor: "black",
}}
>
<Pressable
onPress={toggleControls}
style={[
{
position: "absolute",
top: 0,
bottom: 0,
left: ignoreSafeArea ? 0 : insets.left,
right: ignoreSafeArea ? 0 : insets.right,
width: ignoreSafeArea
? dimensions.window.width
: dimensions.window.width - (insets.left + insets.right),
},
]}
>
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeArea ? "cover" : "contain"}
onProgress={handleVideoProgress}
onLoad={(data) => (max.value = secondsToTicks(data.duration))}
onError={handleVideoError}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onVideoTracks={(d) => {
console.log("onVideoTracks ~", d);
}}
/>
</Pressable>
{(showControls || isBuffering) && (
<View
pointerEvents="none"
style={[
{
top: 0,
left: 0,
position: "absolute",
width: dimensions.window.width,
height: dimensions.window.height,
},
]}
className=" bg-black/50 z-0"
></View>
)}
{isBuffering && (
<View
pointerEvents="none"
className="fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center"
>
<Loader />
</View>
)}
{showSkipButton && (
<View
style={[
{
position: "absolute",
bottom: isLandscape ? insets.bottom + 26 : insets.bottom + 70,
right: isLandscape ? insets.right + 32 : insets.right + 16,
height: 70,
},
]}
className="z-10"
>
<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>
)}
{showSkipCreditButton && (
<View
style={[
{
position: "absolute",
bottom: isLandscape ? insets.bottom + 26 : insets.bottom + 70,
right: isLandscape ? insets.right + 32 : insets.right + 16,
height: 70,
},
]}
className="z-10"
>
<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>
)}
{showControls && (
<>
<View
style={[
{
position: "absolute",
top: insets.top,
right: isLandscape ? insets.right + 32 : insets.right + 16,
height: 70,
zIndex: 10,
},
]}
className="flex flex-row items-center space-x-2 z-10"
>
<TouchableOpacity
onPress={toggleIgnoreSafeArea}
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeArea ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
stopPlayback();
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<View
style={[
{
position: "absolute",
bottom: insets.bottom + 8,
left: isLandscape ? insets.left + 32 : insets.left + 16,
width: isLandscape
? dimensions.window.width - insets.left - insets.right - 64
: dimensions.window.width - insets.left - insets.right - 32,
},
]}
>
<View className="shrink flex flex-col justify-center h-full mb-2">
<Text className="font-bold">{currentlyPlaying.item?.Name}</Text>
{currentlyPlaying.item?.Type === "Episode" && (
<Text className="opacity-50">
{currentlyPlaying.item.SeriesName}
</Text>
)}
{currentlyPlaying.item?.Type === "Movie" && (
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.ProductionYear}
</Text>
)}
{currentlyPlaying.item?.Type === "Audio" && (
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.Album}
</Text>
)}
</View>
<View
className={`flex ${
isLandscape
? "flex-row space-x-6 py-2 px-4 rounded-full"
: "flex-col-reverse py-4 px-4 rounded-2xl"
}
items-center bg-neutral-800`}
>
<View className="flex flex-row items-center space-x-4">
<TouchableOpacity
style={{
opacity: !previousItem ? 0.5 : 1,
}}
onPress={handleGoToPreviousItem}
>
<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={handlePlayPause}>
<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={handleGoToNextItem}
>
<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

@@ -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

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

View File

@@ -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,78 +11,76 @@ import { ItemImage } from "@/components/common/ItemImage";
import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import iosFmp4 from "@/utils/profiles/iosFmp4";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import {
BaseItemDto,
MediaSourceInfo,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { View } from "react-native";
import { useCastDevice } from "react-native-google-cast";
import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
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 [user] = useAtom(userAtom);
const castDevice = useCastDevice();
const navigation = useNavigation();
const [settings] = useSettings();
const [selectedMediaSource, setSelectedMediaSource] =
useState<MediaSourceInfo | null>(null);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(-1);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
key: "Max",
value: undefined,
});
const { orientation } = useOrientation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
useImageColors({ item });
const [loadingLogo, setLoadingLogo] = useState(true);
const [headerHeight, setHeaderHeight] = useState(350);
const [orientation, setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP
);
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
>(undefined);
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(item, settings);
// Needs to automatically change the selected to the default values for default indexes.
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
setOrientation(event.orientationInfo.orientation);
}
);
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
setOrientation(initialOrientation);
});
return () => {
ScreenOrientation.removeOrientationChangeListener(subscription);
};
}, []);
const headerHeightRef = useRef(400);
useImageColors({ item });
console.log(defaultAudioIndex, defaultSubtitleIndex);
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
}, [
defaultAudioIndex,
defaultBitrate,
defaultSubtitleIndex,
defaultMediaSource,
]);
useEffect(() => {
navigation.setOptions({
@@ -91,10 +89,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<View className="flex flex-row items-center space-x-2">
<Chromecast background="blur" width={22} height={22} />
{item.Type !== "Program" && (
<>
<DownloadItem item={item} />
<View className="flex flex-row items-center space-x-2">
<DownloadSingleItem item={item} size="large" />
<PlayedStatus item={item} />
</>
</View>
)}
</View>
),
@@ -102,95 +100,11 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
}, [item]);
useEffect(() => {
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
headerHeightRef.current = 230;
return;
}
if (item.Type === "Episode") headerHeightRef.current = 400;
else if (item.Type === "Movie") headerHeightRef.current = 500;
else headerHeightRef.current = 400;
}, [item, orientation]);
const { data: sessionData } = useQuery({
queryKey: ["sessionData", item.Id],
queryFn: async () => {
if (!api || !user?.Id || !item.Id) {
return null;
}
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo(
{
itemId: item.Id,
userId: user?.Id,
},
{
method: "POST",
}
);
return playbackData.data;
},
enabled: !!item.Id && !!api && !!user?.Id,
staleTime: 0,
});
const { data: playbackUrl } = useQuery({
queryKey: [
"playbackUrl",
item.Id,
maxBitrate,
castDevice?.deviceId,
selectedMediaSource?.Id,
selectedAudioStream,
selectedSubtitleStream,
settings,
sessionData?.PlaySessionId,
],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
if (
item.Type !== "Program" &&
(!sessionData || !selectedMediaSource?.Id)
) {
return null;
}
let deviceProfile: any = iosFmp4;
if (castDevice?.deviceId) {
deviceProfile = chromecastProfile;
} else if (settings?.deviceProfile === "Native") {
deviceProfile = native;
} else if (settings?.deviceProfile === "Old") {
deviceProfile = old;
}
console.log("playbackUrl...");
const url = await getStreamUrl({
api,
userId: user.Id,
item,
startTimeTicks: item.UserData?.PlaybackPositionTicks || 0,
maxStreamingBitrate: maxBitrate.value,
sessionData,
deviceProfile,
audioStreamIndex: selectedAudioStream,
subtitleStreamIndex: selectedSubtitleStream,
forceDirectPlay: settings?.forceDirectPlay,
height: maxBitrate.height,
mediaSourceId: selectedMediaSource?.Id,
});
console.info("Stream URL:", url);
return url;
},
enabled: !!api && !!user?.Id && !!item.Id,
staleTime: 0,
});
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
setHeaderHeight(230);
else if (item.Type === "Movie") setHeaderHeight(500);
else setHeaderHeight(350);
}, [item.Type, orientation]);
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
@@ -198,7 +112,37 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
const insets = useSafeAreaInsets();
const [isTranscoding, setIsTranscoding] = useState(false);
const [previouslyChosenSubtitleIndex, setPreviouslyChosenSubtitleIndex] =
useState<number | undefined>(selectedOptions?.subtitleIndex);
useEffect(() => {
const isTranscoding = Boolean(selectedOptions?.bitrate.value);
if (isTranscoding) {
setPreviouslyChosenSubtitleIndex(selectedOptions?.subtitleIndex);
const subHelper = new SubtitleHelper(
selectedOptions?.mediaSource?.MediaStreams ?? []
);
const newSubtitleIndex = subHelper.getMostCommonSubtitleByName(
selectedOptions?.subtitleIndex
);
setSelectedOptions((prev) => ({
...prev!,
subtitleIndex: newSubtitleIndex ?? -1,
}));
}
if (!isTranscoding && previouslyChosenSubtitleIndex !== undefined) {
setSelectedOptions((prev) => ({
...prev!,
subtitleIndex: previouslyChosenSubtitleIndex,
}));
}
setIsTranscoding(isTranscoding);
}, [selectedOptions?.bitrate]);
if (!selectedOptions) return null;
return (
<View
@@ -210,22 +154,20 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
>
<ParallaxScrollView
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
headerHeight={headerHeightRef.current}
headerHeight={headerHeight}
headerImage={
<>
<Animated.View style={[{ flex: 1 }]}>
<ItemImage
variant={
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
}
item={item}
style={{
width: "100%",
height: "100%",
}}
/>
</Animated.View>
</>
<View style={[{ flex: 1 }]}>
<ItemImage
variant={
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
}
item={item}
style={{
width: "100%",
height: "100%",
}}
/>
</View>
}
logo={
<>
@@ -253,51 +195,87 @@ 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
isTranscoding={isTranscoding}
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 item={item} url={playbackUrl} className="grow" />
<PlayButton
className="grow"
selectedOptions={selectedOptions}
item={item}
/>
</View>
{item.Type === "Episode" && (
<SeasonEpisodesCarousel item={item} loading={loading} />
)}
<OverviewText text={item.Overview} className="px-4 my-4" />
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
<OverviewText text={item.Overview} className="px-4 mb-4" />
{item.Type !== "Program" && (
<>
{item.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" />
)}
<CastAndCrew item={item} className="mb-4" loading={loading} />
{item.People && item.People.length > 0 && (
<View className="mb-4">
{item.People.slice(0, 3).map((person) => (
{item.People.slice(0, 3).map((person, idx) => (
<MoreMoviesWithActor
currentItem={item}
key={person.Id}
key={idx}
actorId={person.Id!}
className="mb-4"
/>
@@ -305,15 +283,9 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View>
)}
{item.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" />
)}
<SimilarItems itemId={item.Id} />
</>
)}
<View className="h-16"></View>
</View>
</ParallaxScrollView>
</View>

View File

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

View File

@@ -21,10 +21,10 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
{...props}
>
<View className="flex flex-col">
<View className="flex flex-col overflow-visible">
<Text className="font-bold ">{title}</Text>
{subTitle && (
<Text className="text-xs" selectable>
<Text uiTextView selectable className="text-xs">
{subTitle}
</Text>
)}

View File

@@ -12,7 +12,7 @@ import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
onChange: (value: MediaSourceInfo) => void;
selected: MediaSourceInfo | null;
selected?: MediaSourceInfo | null;
}
export const MediaSourceSelector: React.FC<Props> = ({
@@ -21,30 +21,14 @@ export const MediaSourceSelector: React.FC<Props> = ({
selected,
...props
}) => {
const mediaSources = useMemo(() => {
return item.MediaSources;
}, [item]);
const selectedMediaSource = useMemo(
const selectedName = useMemo(
() =>
mediaSources
?.find((x) => x.Id === selected?.Id)
?.MediaStreams?.find((x) => x.Type === "Video")?.DisplayTitle || "",
[mediaSources, selected]
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
(x) => x.Type === "Video"
)?.DisplayTitle || "",
[item, selected]
);
useEffect(() => {
if (mediaSources?.length) onChange(mediaSources[0]);
}, [mediaSources]);
const name = (name?: string | null) => {
if (name && name.length > 40)
return (
name.substring(0, 20) + " [...] " + name.substring(name.length - 20)
);
return name;
};
return (
<View
className="flex shrink"
@@ -56,8 +40,8 @@ export const MediaSourceSelector: React.FC<Props> = ({
<DropdownMenu.Trigger>
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">Video</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center ">
<Text numberOfLines={1}>{selectedMediaSource}</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
<Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
@@ -71,7 +55,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
sideOffset={8}
>
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
{mediaSources?.map((source, idx: number) => (
{item.MediaSources?.map((source, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
@@ -90,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,37 +0,0 @@
import React, { useEffect, useRef } from "react";
import Video, { VideoRef } from "react-native-video";
type VideoPlayerProps = {
url: string;
};
export const OfflineVideoPlayer: React.FC<VideoPlayerProps> = ({ url }) => {
const videoRef = useRef<VideoRef | null>(null);
const onError = (error: any) => {
console.error("Video Error: ", error);
};
useEffect(() => {
if (videoRef.current) {
videoRef.current.resume();
}
setTimeout(() => {
if (videoRef.current) {
videoRef.current.presentFullscreenPlayer();
}
}, 500);
}, []);
return (
<Video
source={{
uri: url,
isNetwork: false,
}}
ref={videoRef}
onError={onError}
ignoreSilentSwitch="ignore"
/>
);
};

View File

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

View File

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

114
components/RoundButton.tsx Normal file
View File

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

View File

@@ -1,57 +1,40 @@
import { TouchableOpacity, View } from "react-native";
import { tc } from "@/utils/textTools";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
import { useSettings } from "@/utils/atoms/settings";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
interface Props extends React.ComponentProps<typeof View> {
source: MediaSourceInfo;
source?: MediaSourceInfo;
onChange: (value: number) => void;
selected: number;
selected?: number | undefined;
isTranscoding?: boolean;
}
export const SubtitleTrackSelector: React.FC<Props> = ({
source,
onChange,
selected,
isTranscoding,
...props
}) => {
const [settings] = useSettings();
const subtitleStreams = useMemo(() => {
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
const subtitleStreams = useMemo(
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
[source]
);
if (isTranscoding && Platform.OS === "ios") {
return subtitleHelper.getUniqueSubtitles();
}
return subtitleHelper.getSubtitles();
}, [source, isTranscoding]);
const selectedSubtitleSteam = useMemo(
() => subtitleStreams.find((x) => x.Index === selected),
[subtitleStreams, selected]
);
useEffect(() => {
// const index = source.DefaultAudioStreamIndex;
// if (index !== undefined && index !== null) {
// onChange(index);
// return;
// }
const defaultSubIndex = subtitleStreams?.find(
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
)?.Index;
if (defaultSubIndex !== undefined && defaultSubIndex !== null) {
onChange(defaultSubIndex);
return;
}
onChange(-1);
}, [subtitleStreams, settings]);
if (subtitleStreams.length === 0) return null;
return (

View File

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

View File

@@ -24,14 +24,14 @@ export const HeaderBackButton: React.FC<Props> = ({
if (background === "transparent" && Platform.OS !== "android")
return (
<BlurView
{...props}
intensity={100}
className="overflow-hidden rounded-full p-2"
<TouchableOpacity
onPress={() => router.back()}
{...touchableOpacityProps}
>
<TouchableOpacity
onPress={() => router.back()}
{...touchableOpacityProps}
<BlurView
{...props}
intensity={100}
className="overflow-hidden rounded-full p-2"
>
<Ionicons
className="drop-shadow-2xl"
@@ -39,8 +39,8 @@ export const HeaderBackButton: React.FC<Props> = ({
size={24}
color="white"
/>
</TouchableOpacity>
</BlurView>
</BlurView>
</TouchableOpacity>
);
return (

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

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

View File

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

View File

@@ -1,8 +1,10 @@
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as Haptics from "expo-haptics";
import { useRouter, useSegments } from "expo-router";
import { PropsWithChildren } from "react";
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
import * as ContextMenu from "zeego/context-menu";
interface Props extends TouchableOpacityProps {
item: BaseItemDto;
@@ -45,6 +47,10 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
}
if (item.Type === "Playlist") {
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
}
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
};
@@ -58,18 +64,82 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
const from = segments[2];
const markAsPlayedStatus = useMarkAsPlayed(item);
if (from === "(home)" || from === "(search)" || from === "(libraries)")
return (
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const url = itemRouter(item, from);
// @ts-ignore
router.push(url);
}}
{...props}
>
{children}
</TouchableOpacity>
<ContextMenu.Root>
<ContextMenu.Trigger>
<TouchableOpacity
onPress={() => {
const url = itemRouter(item, from);
// @ts-ignore
router.push(url);
}}
{...props}
>
{children}
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
avoidCollisions
alignOffset={0}
collisionPadding={0}
loop={false}
key={"content"}
>
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
<ContextMenu.Item
key="item-1"
onSelect={() => {
markAsPlayedStatus(true);
}}
shouldDismissMenuOnSelect
>
<ContextMenu.ItemTitle key="item-1-title">
Mark as watched
</ContextMenu.ItemTitle>
<ContextMenu.ItemIcon
ios={{
name: "checkmark.circle", // Changed to "checkmark.circle" which represents "watched"
pointSize: 18,
weight: "semibold",
scale: "medium",
hierarchicalColor: {
dark: "green", // Changed to green for "watched"
light: "green",
},
}}
androidIconName="checkmark-circle"
></ContextMenu.ItemIcon>
</ContextMenu.Item>
<ContextMenu.Item
key="item-2"
onSelect={() => {
markAsPlayedStatus(false);
}}
shouldDismissMenuOnSelect
destructive
>
<ContextMenu.ItemTitle key="item-2-title">
Mark as not watched
</ContextMenu.ItemTitle>
<ContextMenu.ItemIcon
ios={{
name: "eye.slash", // Changed to "eye.slash" which represents "not watched"
pointSize: 18, // Adjusted for better visibility
weight: "semibold",
scale: "medium",
hierarchicalColor: {
dark: "red", // Changed to red for "not watched"
light: "red",
},
// Removed paletteColors as it's not necessary in this case
}}
androidIconName="eye-slash"
></ContextMenu.ItemIcon>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
);
};

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,47 @@
import { Text } from "@/components/common/Text";
import { bytesToReadable, useDownload } from "@/providers/DownloadProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useEffect, useMemo, useState } from "react";
import { TextProps } from "react-native";
interface DownloadSizeProps extends TextProps {
items: BaseItemDto[];
}
export const DownloadSize: React.FC<DownloadSizeProps> = ({
items,
...props
}) => {
const { downloadedFiles, getDownloadedItemSize } = useDownload();
const [size, setSize] = useState<string | undefined>();
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
useEffect(() => {
if (!downloadedFiles) return;
let s = 0;
for (const item of items) {
if (!item.Id) continue;
const size = getDownloadedItemSize(item.Id);
if (size) {
s += size;
}
}
setSize(bytesToReadable(s));
}, [itemIds]);
const sizeText = useMemo(() => {
if (!size) return "...";
return size;
}, [size]);
return (
<>
<Text className="text-xs text-neutral-500" {...props}>
{sizeText}
</Text>
</>
);
};

View File

@@ -1,37 +1,35 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as Haptics from "expo-haptics";
import React, { useCallback, useMemo, useRef } from "react";
import { TouchableOpacity, View } from "react-native";
import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import {
ActionSheetProvider,
useActionSheet,
} from "@expo/react-native-action-sheet";
import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
import { Text } from "../common/Text";
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";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
interface EpisodeCardProps {
interface EpisodeCardProps extends TouchableOpacityProps {
item: BaseItemDto;
}
/**
* EpisodeCard component displays an episode with action sheet options.
* @param {EpisodeCardProps} props - The component props.
* @returns {React.ReactElement} The rendered EpisodeCard component.
*/
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
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 +74,30 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
<TouchableOpacity
onPress={handleOpenFile}
onLongPress={showActionSheet}
className="flex flex-col"
key={item.Id}
className="flex flex-col mb-4"
>
{base64Image ? (
<View className="w-44 aspect-video rounded-lg overflow-hidden mr-2">
<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">
<ContinueWatchingPoster size="small" item={item} useEpisodePoster />
</View>
) : (
<View className="w-44 aspect-video 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 className="shrink">
<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(() => {
@@ -99,7 +97,10 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
/>
</View>
)}
<ItemCardText item={item} />
<View className="w-28">
<ItemCardText item={item} />
</View>
<DownloadSize items={[item]} />
</TouchableOpacity>
);
};

View File

@@ -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

@@ -9,10 +9,8 @@ import {
import { ScrollView, View, ViewProps } from "react-native";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import SeriesPoster from "../posters/SeriesPoster";
import { FlashList } from "@shopify/flash-list";
interface Props extends ViewProps {
title?: string | null;
@@ -30,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: 60 * 1000,
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

@@ -11,12 +11,9 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
import { TouchableOpacityProps, View } from "react-native";
import { getColors } from "react-native-image-colors";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { useImageColors } from "@/hooks/useImageColors";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
interface Props extends TouchableOpacityProps {
library: BaseItemDto;
@@ -53,10 +50,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
[library]
);
// If we want to use image colors for library cards
// const [color] = useAtom(itemThemeColorAtom)
// useImageColors({ url });
const { data: itemsCount } = useQuery({
queryKey: ["library-count", library.Id],
queryFn: async () => {
@@ -68,6 +61,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
});
return response.data.TotalRecordCount;
},
staleTime: 1000 * 60 * 60,
});
if (!url) return null;

View File

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

View File

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

View File

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

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

@@ -12,7 +12,7 @@ export const EpisodeTitleHeader: React.FC<Props> = ({ item, ...props }) => {
return (
<View {...props}>
<Text className="font-bold text-2xl" selectable>
<Text uiTextView className="font-bold text-2xl" selectable>
{item?.Name}
</Text>
<View className="flex flex-row items-center mb-1">

View File

@@ -13,7 +13,7 @@ interface Props extends React.ComponentProps<typeof Button> {
type?: "next" | "previous";
}
export const NextEpisodeButton: React.FC<Props> = ({
export const NextItemButton: React.FC<Props> = ({
item,
type = "next",
...props
@@ -23,8 +23,8 @@ export const NextEpisodeButton: React.FC<Props> = ({
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const { data: nextEpisode } = useQuery({
queryKey: ["nextEpisode", item.Id, item.ParentId, type],
const { data: nextItem } = useQuery({
queryKey: ["nextItem", item.Id, item.ParentId, type],
queryFn: async () => {
if (
!api ||
@@ -47,16 +47,16 @@ export const NextEpisodeButton: React.FC<Props> = ({
});
const disabled = useMemo(() => {
if (!nextEpisode) return true;
if (nextEpisode.Id === item.Id) return true;
if (!nextItem) return true;
if (nextItem.Id === item.Id) return true;
return false;
}, [nextEpisode, type]);
}, [nextItem, type]);
if (item.Type !== "Episode") return null;
return (
<Button
onPress={() => router.setParams({ id: nextEpisode?.Id })}
onPress={() => router.setParams({ id: nextItem?.Id })}
className={`h-12 aspect-square`}
disabled={disabled}
{...props}

View File

@@ -0,0 +1,121 @@
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] as string) ?? ""],
[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) =>
Number(a[keys.index]) - Number(b[keys.index]);
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-row">
<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,27 @@ 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 +32,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 +56,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 +112,30 @@ 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 px-4">
<SeasonDropdown
item={item}
seasons={seasons}
state={seasonIndexState}
onSelect={(season) => {
setSeasonIndexState((prev) => ({
...prev,
[item.Id ?? ""]: season.IndexNumber,
}));
}}
/>
<DownloadItems
title="Download Season"
className="ml-2"
items={episodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name="download" size={20} color="white" />
)}
DownloadedIconComponent={() => (
<Ionicons name="download" size={20} color="#9333ea" />
)}
/>
</View>
<View className="px-4 flex flex-col my-4">
{isFetching ? (
<View
@@ -218,7 +173,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

@@ -0,0 +1,114 @@
import { TouchableOpacity, View, ViewProps } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { useMedia } from "./MediaContext";
import { Switch } from "react-native-gesture-handler";
interface Props extends ViewProps {}
export const AudioToggles: React.FC<Props> = ({ ...props }) => {
const media = useMedia();
const { settings, updateSettings } = media;
const cultures = media.cultures;
if (!settings) return null;
return (
<View>
<Text className="text-lg font-bold mb-2">Audio</Text>
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Audio language</Text>
<Text className="text-xs opacity-50">
Choose a default audio language.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{settings?.defaultAudioLanguage?.DisplayName || "None"}
</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Languages</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-audio"}
onSelect={() => {
updateSettings({
defaultAudioLanguage: null,
});
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{cultures?.map((l) => (
<DropdownMenu.Item
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
onSelect={() => {
updateSettings({
defaultAudioLanguage: l,
});
}}
>
<DropdownMenu.ItemTitle>
{l.DisplayName}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-col">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col">
<Text className="font-semibold">Use Default Audio</Text>
<Text className="text-xs opacity-50">
Play default audio track regardless of language.
</Text>
</View>
<Switch
value={settings.playDefaultAudioTrack}
onValueChange={(value) =>
updateSettings({ playDefaultAudioTrack: value })
}
/>
</View>
</View>
<View className="flex flex-col">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col">
<Text className="font-semibold">
Set Audio Track From Previous Item
</Text>
<Text className="text-xs opacity-50 min max-w-[85%]">
Try to set the audio track to the closest match to the last
video.
</Text>
</View>
<Switch
value={settings.rememberAudioSelections}
onValueChange={(value) =>
updateSettings({ rememberAudioSelections: value })
}
/>
</View>
</View>
</View>
</View>
);
};

View File

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

View File

@@ -1,8 +1,6 @@
import { useSettings } from "@/utils/atoms/settings";
import { TouchableOpacity, View, ViewProps } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { LANGUAGES } from "@/constants/Languages";
interface Props extends ViewProps {}
@@ -15,113 +13,6 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
<View>
<Text className="text-lg font-bold mb-2">Media</Text>
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Audio language</Text>
<Text className="text-xs opacity-50">
Choose a default audio language.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>{settings?.defaultAudioLanguage?.label || "None"}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Languages</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-audio"}
onSelect={() => {
updateSettings({
defaultAudioLanguage: null,
});
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{LANGUAGES.map((l) => (
<DropdownMenu.Item
key={l.value}
onSelect={() => {
updateSettings({
defaultAudioLanguage: l,
});
}}
>
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Subtitle language</Text>
<Text className="text-xs opacity-50">
Choose a default subtitle language.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{settings?.defaultSubtitleLanguage?.label || "None"}
</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Languages</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-subs"}
onSelect={() => {
updateSettings({
defaultSubtitleLanguage: null,
});
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{LANGUAGES.map((l) => (
<DropdownMenu.Item
key={l.value}
onSelect={() => {
updateSettings({
defaultSubtitleLanguage: l,
});
}}
>
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4

View File

@@ -4,12 +4,17 @@ 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";
@@ -18,7 +23,6 @@ import * as TaskManager from "expo-task-manager";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import {
ActivityIndicator,
Linking,
Switch,
TouchableOpacity,
@@ -32,8 +36,10 @@ 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";
import { MediaProvider } from "./MediaContext";
import { SubtitleToggles } from "./SubtitleToggles";
import { AudioToggles } from "./AudioToggles";
interface Props extends ViewProps {}
@@ -64,7 +70,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");
@@ -121,7 +127,11 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
</View>
</View> */}
<MediaToggles />
<MediaProvider>
<MediaToggles />
<AudioToggles />
<SubtitleToggles />
</MediaProvider>
<View>
<Text className="text-lg font-bold mb-2">Other</Text>
@@ -248,22 +258,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 +328,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 +415,31 @@ 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 +500,50 @@ 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

@@ -0,0 +1,191 @@
import { TouchableOpacity, View, ViewProps } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { useMedia } from "./MediaContext";
import { Switch } from "react-native-gesture-handler";
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
interface Props extends ViewProps {}
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
const media = useMedia();
const { settings, updateSettings } = media;
const cultures = media.cultures;
if (!settings) return null;
const subtitleModes = [
SubtitlePlaybackMode.Default,
SubtitlePlaybackMode.Smart,
SubtitlePlaybackMode.OnlyForced,
SubtitlePlaybackMode.Always,
SubtitlePlaybackMode.None,
];
return (
<View>
<Text className="text-lg font-bold mb-2">Subtitle</Text>
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Subtitle language</Text>
<Text className="text-xs opacity-50">
Choose a default subtitle language.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{settings?.defaultSubtitleLanguage?.DisplayName || "None"}
</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Languages</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-subs"}
onSelect={() => {
updateSettings({
defaultSubtitleLanguage: null,
});
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{cultures?.map((l) => (
<DropdownMenu.Item
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
onSelect={() => {
updateSettings({
defaultSubtitleLanguage: l,
});
}}
>
<DropdownMenu.ItemTitle>
{l.DisplayName}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Subtitle Mode</Text>
<Text className="text-xs opacity-50 mr-2">
Subtitles are loaded based on the default and forced flags in the
embedded metadata. Language preferences are considered when
multiple options are available.
</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?.subtitleMode || "Loading"}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Subtitle Mode</DropdownMenu.Label>
{subtitleModes?.map((l) => (
<DropdownMenu.Item
key={l}
onSelect={() => {
updateSettings({
subtitleMode: l,
});
}}
>
<DropdownMenu.ItemTitle>{l}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-col">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col">
<Text className="font-semibold">
Set Subtitle Track From Previous Item
</Text>
<Text className="text-xs opacity-50 min max-w-[85%]">
Try to set the subtitle track to the closest match to the last
video.
</Text>
</View>
<Switch
value={settings.rememberSubtitleSelections}
onValueChange={(value) =>
updateSettings({ rememberSubtitleSelections: value })
}
/>
</View>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">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>
</View>
);
};

View File

@@ -1,6 +1,15 @@
import { NativeStackNavigationOptions } from "@react-navigation/native-stack";
import { HeaderBackButton } from "../common/HeaderBackButton";
import { ParamListBase, RouteProp } from "@react-navigation/native";
const commonScreenOptions = {
type ICommonScreenOptions =
| NativeStackNavigationOptions
| ((prop: {
route: RouteProp<ParamListBase, string>;
navigation: any;
}) => NativeStackNavigationOptions);
const commonScreenOptions: ICommonScreenOptions = {
title: "",
headerShown: true,
headerTransparent: true,
@@ -17,5 +26,5 @@ const routes = [
"series/[id]",
];
export const nestedTabPageScreenOptions: { [key: string]: any } =
export const nestedTabPageScreenOptions: Record<string, ICommonScreenOptions> =
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));

View File

@@ -0,0 +1,114 @@
import React, { useEffect, useRef } from "react";
import { View, StyleSheet } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import { Slider } from "react-native-awesome-slider";
import { VolumeManager } from "react-native-volume-manager";
import { Ionicons } from "@expo/vector-icons";
interface AudioSliderProps {
setVisibility: (show: boolean) => void;
}
const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
const volume = useSharedValue<number>(50); // Explicitly type as number
const min = useSharedValue<number>(0); // Explicitly type as number
const max = useSharedValue<number>(100); // Explicitly type as number
const timeoutRef = useRef<NodeJS.Timeout | null>(null); // Use a ref to store the timeout ID
useEffect(() => {
const fetchInitialVolume = async () => {
try {
const { volume: initialVolume } = await VolumeManager.getVolume();
console.log("initialVolume", initialVolume);
volume.value = initialVolume * 100;
} catch (error) {
console.error("Error fetching initial volume:", error);
}
};
fetchInitialVolume();
// Disable the native volume UI when the component mounts
VolumeManager.showNativeVolumeUI({ enabled: false });
return () => {
// Re-enable the native volume UI when the component unmounts
VolumeManager.showNativeVolumeUI({ enabled: true });
};
}, []);
const handleValueChange = async (value: number) => {
volume.value = value;
console.log("volume through slider", value);
await VolumeManager.setVolume(value / 100);
// Re-call showNativeVolumeUI to ensure the setting is applied on iOS
VolumeManager.showNativeVolumeUI({ enabled: false });
};
useEffect(() => {
const volumeListener = VolumeManager.addVolumeListener((result) => {
console.log("Volume through device", result.volume);
volume.value = result.volume * 100;
setVisibility(true);
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set a new timeout to hide the visibility after 2 seconds
timeoutRef.current = setTimeout(() => {
setVisibility(false);
}, 1000);
});
return () => {
volumeListener.remove();
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [volume]);
return (
<View style={styles.sliderContainer}>
<Slider
progress={volume}
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="volume-high"
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 AudioSlider;

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,841 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
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 { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
getDefaultPlaySettings,
previousIndexes,
} from "@/utils/jellyfin/getDefaultPlaySettings";
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
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 * as Haptics from "expo-haptics";
import { Image } from "expo-image";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useAtom } from "jotai";
import { debounce } from "lodash";
import { Dimensions, 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 AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider";
import { ControlProvider } from "./contexts/ControlContext";
import { VideoProvider } from "./contexts/VideoContext";
import DropdownViewDirect from "./dropdown/DropdownViewDirect";
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
import { EpisodeList } from "./EpisodeList";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
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 [api] = useAtom(apiAtom);
const { previousItem, nextItem } = useAdjacentItems({ item });
const {
trickPlayUrl,
calculateTrickplayUrl,
trickplayInfo,
prefetchAllTrickplayImages,
} = useTrickplay(item, !offline && enableTrickplay);
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(Infinity);
const min = useSharedValue(0);
const max = useSharedValue(item.RunTimeTicks || 0);
const wasPlayingRef = useRef(false);
const lastProgressRef = useRef<number>(0);
const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{
bitrateValue: string;
audioIndex: string;
subtitleIndex: string;
}>();
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 previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
};
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
previousItem,
settings,
previousIndexes,
mediaSource ?? undefined
);
const queryParams = new URLSearchParams({
itemId: previousItem.Id ?? "", // Ensure itemId is a string
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue.toString(),
}).toString();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
}, [previousItem, settings, subtitleIndex, audioIndex]);
const goToNextItem = useCallback(() => {
if (!nextItem || !settings) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
};
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
nextItem,
settings,
previousIndexes,
mediaSource ?? undefined
);
const queryParams = new URLSearchParams({
itemId: nextItem.Id ?? "", // Ensure itemId is a string
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue.toString(),
}).toString();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
}, [nextItem, settings, subtitleIndex, audioIndex]);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
const remaining = isVlc
? maxValue - currentProgress
: ticksToSeconds(maxValue - currentProgress);
console.log("remaining: ", remaining);
setCurrentTime(current);
setRemainingTime(remaining);
},
[goToNextItem, isVlc]
);
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 = 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 = () => {
if (showControls) {
setShowAudioSlider(false);
setShowControls(false);
} else {
setShowControls(true);
}
};
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 = isVlc ? msToTicks(value) : 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 });
}, 3),
[]
);
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]);
const [EpisodeView, setEpisodeView] = useState(false);
const switchOnEpisodeMode = () => {
setEpisodeView(true);
if (isPlaying) togglePlay();
};
const goToItem = useCallback(
async (itemId: string) => {
try {
const gotoItem = await getItemById(api, itemId);
if (!settings || !gotoItem) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
};
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
gotoItem,
settings,
previousIndexes,
mediaSource ?? undefined
);
const queryParams = new URLSearchParams({
itemId: gotoItem.Id ?? "", // Ensure itemId is a string
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue.toString(),
}).toString();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
} catch (error) {
console.error("Error in gotoEpisode:", error);
}
},
[settings, subtitleIndex, audioIndex]
);
// Used when user changes audio through audio button on device.
const [showAudioSlider, setShowAudioSlider] = useState(false);
return (
<ControlProvider
item={item}
mediaSource={mediaSource}
isVideoLoaded={isVideoLoaded}
>
{EpisodeView ? (
<EpisodeList
item={item}
close={() => setEpisodeView(false)}
goToItem={goToItem}
/>
) : (
<>
<VideoProvider
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
setAudioTrack={setAudioTrack}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
>
<View
style={[
{
position: "absolute",
top: insets.top,
left: insets.left,
opacity: showControls ? 1 : 0,
zIndex: 1000,
},
]}
>
{!mediaSource?.TranscodingUrl ? (
<DropdownViewDirect showControls={showControls} />
) : (
<DropdownViewTranscoding showControls={showControls} />
)}
</View>
</VideoProvider>
<Pressable
onPressIn={() => {
toggleControls();
}}
style={{
position: "absolute",
width: Dimensions.get("window").width,
height: Dimensions.get("window").height,
}}
></Pressable>
<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 `}
>
{item?.Type === "Episode" && !offline && (
<TouchableOpacity
onPress={() => {
switchOnEpisodeMode();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="list" size={24} color="white" />
</TouchableOpacity>
)}
{previousItem && !offline && (
<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 && !offline && (
<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 () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
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: insets.left,
right: insets.right,
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
}}
pointerEvents={showControls ? "box-none" : "none"}
>
<View
style={{
position: "absolute",
alignItems: "center",
transform: [{ rotate: "270deg" }], // Rotate the slider to make it vertical
left: 0,
bottom: 30,
opacity: showControls ? 1 : 0,
}}
>
<BrightnessSlider />
</View>
<TouchableOpacity onPress={handleSkipBackward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
opacity: showControls ? 1 : 0,
}}
>
<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"
style={{
opacity: showControls ? 1 : 0,
}}
/>
) : (
<Loader size={"large"} />
)}
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
opacity: showControls ? 1 : 0,
}}
>
<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
style={{
position: "absolute",
alignItems: "center",
transform: [{ rotate: "270deg" }], // Rotate the slider to make it vertical
bottom: 30,
right: 0,
opacity: showAudioSlider || showControls ? 1 : 0,
}}
>
<AudioSlider setVisibility={setShowAudioSlider} />
</View>
</View>
<View
style={[
{
position: "absolute",
right: insets.right,
left: insets.left,
bottom: insets.bottom,
},
]}
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
opacity: showControls ? 1 : 0,
}}
pointerEvents={showControls ? "box-none" : "none"}
>
<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-row space-x-2">
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
buttonText="Skip Intro"
/>
<SkipButton
showButton={showSkipCreditButton}
onPress={skipCredit}
buttonText="Skip Credits"
/>
<NextEpisodeCountDownButton
show={
!nextItem
? false
: isVlc
? remainingTime < 10000
: remainingTime < 10
}
onFinish={goToNextItem}
onPress={goToNextItem}
/>
</View>
</View>
<View
className={`flex flex-col-reverse py-4 pb-1 px-4 rounded-lg items-center bg-neutral-800`}
style={{
opacity: showControls ? 1 : 0,
}}
pointerEvents={showControls ? "box-none" : "none"}
>
<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,254 @@
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 { atom, useAtom } from "jotai";
import { useEffect, useMemo, useState, useRef } from "react";
import { View, TouchableOpacity } from "react-native";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Ionicons } from "@expo/vector-icons";
import { Loader } from "@/components/Loader";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Text } from "@/components/common/Text";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
HorizontalScroll,
HorizontalScrollRef,
} from "@/components/common/HorrizontalScroll";
import {
SeasonDropdown,
SeasonIndexState,
} from "@/components/series/SeasonDropdown";
type Props = {
item: BaseItemDto;
close: () => void;
goToItem: (itemId: string) => Promise<void>;
};
export const seasonIndexAtom = atom<SeasonIndexState>({});
export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets(); // Get safe area insets
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const scrollViewRef = useRef<HorizontalScrollRef>(null); // Reference to the HorizontalScroll
const scrollToIndex = (index: number) => {
scrollViewRef.current?.scrollToIndex(index, 100);
};
// Set the initial season index
useEffect(() => {
if (item.SeriesId) {
setSeasonIndexState((prev) => ({
...prev,
[item.SeriesId ?? ""]: item.ParentIndexNumber ?? 0,
}));
}
}, []);
const seasonIndex = seasonIndexState[item.SeriesId ?? ""];
const [seriesItem, setSeriesItem] = useState<BaseItemDto | null>(null);
// This effect fetches the series item data/
useEffect(() => {
if (item.SeriesId) {
getUserItemData({ api, userId: user?.Id, itemId: item.SeriesId }).then(
(res) => {
setSeriesItem(res);
}
);
}
}, [item.SeriesId]);
const { data: seasons } = useQuery({
queryKey: ["seasons", item.SeriesId],
queryFn: async () => {
if (!api || !user?.Id || !item.SeriesId) return [];
const response = await api.axiosInstance.get(
`${api.basePath}/Shows/${item.SeriesId}/Seasons`,
{
params: {
userId: user?.Id,
itemId: item.SeriesId,
Fields:
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount",
},
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
);
return response.data.Items;
},
enabled: !!api && !!user?.Id && !!item.SeasonId,
});
const selectedSeasonId: string | null = useMemo(
() =>
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
[seasons, seasonIndex]
);
const { data: episodes, isFetching } = useQuery({
queryKey: ["episodes", item.SeriesId, selectedSeasonId],
queryFn: async () => {
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.SeriesId || "",
userId: user.Id,
seasonId: selectedSeasonId || undefined,
enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview"],
});
return res.data.Items;
},
enabled: !!api && !!user?.Id && !!selectedSeasonId,
});
useEffect(() => {
if (item?.Type === "Episode" && item.Id) {
const index = episodes?.findIndex((ep) => ep.Id === item.Id);
if (index !== undefined && index !== -1) {
setTimeout(() => {
scrollToIndex(index);
}, 400);
}
}
}, [episodes, item]);
const queryClient = useQueryClient();
useEffect(() => {
for (let e of episodes || []) {
queryClient.prefetchQuery({
queryKey: ["item", e.Id],
queryFn: async () => {
if (!e.Id) return;
const res = await getUserItemData({
api,
userId: user?.Id,
itemId: e.Id,
});
return res;
},
staleTime: 60 * 5 * 1000,
});
}
}, [episodes]);
// Scroll to the current item when episodes are fetched
useEffect(() => {
if (episodes && scrollViewRef.current) {
const currentItemIndex = episodes.findIndex((e) => e.Id === item.Id);
if (currentItemIndex !== -1) {
scrollViewRef.current.scrollToIndex(currentItemIndex, 16); // Adjust the scroll position based on item width
}
}
}, [episodes, item.Id]);
if (!episodes) {
return <Loader />;
}
return (
<View
style={{
position: "absolute",
backgroundColor: "black",
height: "100%",
width: "100%",
}}
>
<>
<View
style={{
justifyContent: "space-between",
}}
className={`flex flex-row items-center space-x-2 z-10 p-4`}
>
{seriesItem && (
<SeasonDropdown
item={seriesItem}
seasons={seasons}
state={seasonIndexState}
onSelect={(season) => {
setSeasonIndexState((prev) => ({
...prev,
[item.SeriesId ?? ""]: season.IndexNumber,
}));
}}
/>
)}
<TouchableOpacity
onPress={async () => {
close();
}}
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>
<HorizontalScroll
ref={scrollViewRef}
data={episodes}
extraData={item}
renderItem={(_item, idx) => (
<View
key={_item.Id}
style={{}}
className={`flex flex-col w-44 ${
item.Id !== _item.Id ? "opacity-75" : ""
}`}
>
<TouchableOpacity
onPress={() => {
goToItem(_item.Id);
}}
>
<ContinueWatchingPoster
item={_item}
useEpisodePoster
showPlayButton={_item.Id !== item.Id}
/>
</TouchableOpacity>
<View className="shrink">
<Text
numberOfLines={2}
style={{
lineHeight: 18, // Adjust this value based on your text size
height: 36, // lineHeight * 2 for consistent two-line space
}}
>
{_item.Name}
</Text>
<Text numberOfLines={1} className="text-xs text-neutral-475">
{`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`}
</Text>
<Text className="text-xs text-neutral-500">
{runtimeTicksToSeconds(_item.RunTimeTicks)}
</Text>
</View>
<View className="self-start mt-2">
<DownloadSingleItem item={_item} />
</View>
<Text
numberOfLines={5}
className="text-xs text-neutral-500 shrink"
>
{_item.Overview}
</Text>
</View>
)}
keyExtractor={(e: BaseItemDto) => e.Id ?? ""}
estimatedItemSize={200}
showsHorizontalScrollIndicator={false}
/>
</>
</View>
);
};

View File

@@ -0,0 +1,81 @@
import React, { useEffect } from "react";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import { Text } from "@/components/common/Text";
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
Easing,
runOnJS,
} from "react-native-reanimated";
import { Colors } from "@/constants/Colors";
interface NextEpisodeCountDownButtonProps extends TouchableOpacityProps {
onFinish?: () => void;
onPress?: () => void;
show: boolean;
}
const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
onFinish,
onPress,
show,
...props
}) => {
const progress = useSharedValue(0);
useEffect(() => {
if (show) {
progress.value = 0;
progress.value = withTiming(
1,
{
duration: 10000, // 10 seconds
easing: Easing.linear,
},
(finished) => {
if (finished && onFinish) {
console.log("finish");
runOnJS(onFinish)();
}
}
);
}
}, [show, onFinish]);
const animatedStyle = useAnimatedStyle(() => {
return {
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: `${progress.value * 100}%`,
backgroundColor: Colors.primary,
};
});
const handlePress = () => {
if (onPress) {
onPress();
}
};
if (!show) {
return null;
}
return (
<TouchableOpacity
className="w-32 overflow-hidden rounded-md bg-black/60 border border-neutral-900"
{...props}
onPress={handlePress}
>
<Animated.View style={animatedStyle} />
<View className="px-3 py-3">
<Text className="text-center font-bold">Next Episode</Text>
</View>
</TouchableOpacity>
);
};
export default NextEpisodeCountDownButton;

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