Compare commits

...

258 Commits

Author SHA1 Message Date
Kelvin 19c84475db Hotfix playback speed for non-dot locales 2025-06-10 23:27:01 +02:00
Kelvin 4164b1a3f8 Build fix 2025-06-10 19:25:43 +02:00
Kelvin a9dc038190 Build fix 2025-06-10 19:20:50 +02:00
Kelvin 2825db88a5 Minor playback tracker fix, submodules 2025-06-10 18:56:19 +02:00
Kelvin 363099b303 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-10 17:39:44 +02:00
Kelvin 5e25a5054f Increase max comment length, Fix raw dash downloads ending too early, Fix playback tracker not working for downloaded videos 2025-06-10 17:33:14 +02:00
Kelvin 2bc6127f6b Merge branch 'copy-title' into 'master'
Copy Title

See merge request videostreaming/grayjay!110
2025-06-10 14:41:49 +00:00
Kelvin 064824aedf Merge branch 'copy-playlists' into 'master'
Clone Playlist

See merge request videostreaming/grayjay!111
2025-06-10 14:41:07 +00:00
Kai DeLorenzo 52044edb2e Merge branch 'brightness-fix' into 'master'
Dim Fullscreen Fix

See merge request videostreaming/grayjay!119
2025-06-10 14:38:01 +00:00
Kai fb12073a82 Only save brightness on resume fullscreen if use system brightness is enabled
Changelog: changed
2025-06-10 09:18:28 -05:00
Kelvin de39451f67 Merge 2025-06-07 16:44:57 +02:00
Kelvin 8f28653b28 Fix edgecases for new playback speed control 2025-06-07 16:44:20 +02:00
Kai 389798457b navigate to playlist screen after copying
Changelog: changed
2025-06-06 15:57:09 -05:00
Kai dd1c04bea1 make the copied playlist name unique
Changelog: changed
2025-06-06 09:39:09 -05:00
Kelvin e6159117f6 Merge branch 'fix-scope-issue' into 'master'
fix: Scope getting removed when switching between settings 'Kelvin approved'

See merge request videostreaming/grayjay!113
2025-06-06 13:42:49 +00:00
zvonimir 0d9e1cd3c5 fix: Scope getting removed when switching between settings 'Kelvin approved' 2025-06-06 15:40:43 +02:00
Koen J 10753eb879 Sort to prefer ipv4 over ipv6. 2025-06-06 12:25:25 +02:00
Koen J 29aec21095 Merge branch 'hotfix-250606' of gitlab.futo.org:videostreaming/grayjay 2025-06-06 12:17:49 +02:00
Koen J a810f82ce2 Added boolean setting to allow link local casting over ipv4. 2025-06-06 11:21:41 +02:00
Koen J 2c454a0ec5 Added boolean setting to allow link local casting over ipv4. 2025-06-06 11:20:04 +02:00
Koen J d3dca00482 Merge branch 'hotfix-250606' of gitlab.futo.org:videostreaming/grayjay 2025-06-06 11:12:58 +02:00
Koen J d08dffd9e2 Added potential fix for having to restart app to get casting devices to show. Added persistent ordering for creators. 2025-06-06 11:12:31 +02:00
Koen J 5b50ac926e Freeze fix when clicking link in description. 2025-06-06 10:17:40 +02:00
Koen J 57a3be35d0 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-06 10:17:03 +02:00
Koen J 70f36e69e6 Freeze fix when clicking link in description. 2025-06-06 10:15:15 +02:00
Kai 8e70f1b865 add long tap to copy playing video title
Changelog: added
2025-06-05 23:14:03 -05:00
Kai f86fb0ee44 add functionality to copy playlists
fix https://github.com/futo-org/grayjay-android/issues/2306

Changelog: added
2025-06-05 23:13:05 -05:00
Kelvin fe0aac7c6e WIP playback speed additions 2025-06-05 22:47:45 +02:00
Kelvin b93447f712 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-05 19:12:10 +02:00
Kelvin 84a5103526 Use lifecycle scope instead of root scope 2025-06-05 19:11:55 +02:00
Koen c94c2721d7 Revert "prevent the user from needing to tap update on system dialog when self updating"
This reverts commit a1d460385d
2025-06-05 15:14:31 +00:00
Koen J 0428c1191a Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-05 15:19:00 +02:00
Koen J 8208f92802 Added view license in settings. 2025-06-05 15:18:45 +02:00
Koen 3d33c4b8e0 Merge branch 'github-issues-template' into 'master'
Improve android issue templates

See merge request videostreaming/grayjay!108
2025-06-05 12:22:49 +00:00
zvonimir d3210ec12a Improve android issue templates 2025-06-05 14:15:00 +02:00
Koen J c959b973fc Crashfix related to PiP #2041. 2025-06-05 13:17:15 +02:00
Koen J 40c195d4a0 Crashfix on stopping StateSync #2302 2025-06-05 13:14:57 +02:00
Koen J f4f1470153 Increased connect timeout. 2025-06-05 10:58:32 +02:00
Koen J 401999b5ea Fixed exception in sync. 2025-06-05 10:45:36 +02:00
Koen J 7b53315046 Another fix for connection robustness. 2025-06-05 10:38:49 +02:00
Koen J 4d170db5e0 Improvements to connection publishing for sync. 2025-06-05 10:31:13 +02:00
Koen J fa8d175101 Fixed issue in base64 encoding. 2025-06-05 09:58:10 +02:00
Koen J cbef605f22 Updated plugins. 2025-06-05 08:57:33 +02:00
Koen J cf95791dcc Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-05 08:53:19 +02:00
Koen J 919567dbdb Made sync dialogs more robust. 2025-06-05 08:52:59 +02:00
Kai DeLorenzo 8ca317a38a Merge branch 'playback-stutter-fix' into 'master'
background playback stutter fix

See merge request videostreaming/grayjay!103
2025-06-04 20:44:09 +00:00
Kelvin ccc686ed50 Downloads size ordering, Subsgroup removal on unsubscribe, multi-key like querying 2025-06-04 21:26:41 +02:00
Kelvin e3e7b0c345 More advanced settings 2025-06-04 20:50:10 +02:00
Kelvin 5b0f359944 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-04 20:43:59 +02:00
Kelvin 29f1bef099 Advanced settings option, Playlist id saving for exports and backups, Sync synchronization to prevent dups 2025-06-04 20:43:37 +02:00
Koen J 418f34c7e8 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-04 15:13:42 +02:00
Koen J 21c2ab21b2 Disable drag long press for search playlists. 2025-06-04 15:13:25 +02:00
Kelvin 1ace7318f3 Submodules 2025-06-04 13:17:31 +02:00
Koen J 48052b88db Made task handler and retry dialogs more robust. 2025-06-04 13:00:32 +02:00
Koen J 715c60dc6e Fixed Chromecast position not updating on Grayjay side. Fixed Chromecast not reconnecting properly. Fixed AirPlay/Chromecast position not being reflected in history. 2025-06-04 12:13:18 +02:00
Koen J 916d052688 Updated submodules. 2025-06-04 11:04:00 +02:00
Koen J 993b812c3b Removed drag handles when searching in playlist. 2025-06-04 10:44:00 +02:00
Koen J 43887586b5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-30 18:39:45 +02:00
Koen J 03d53f21a3 Tab changes no longer fill up history. 2025-05-30 18:39:28 +02:00
Kelvin 23d7e8e5b6 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-30 17:20:28 +02:00
Kelvin cce117c585 Article header support, socket handling improvements 2025-05-30 17:20:11 +02:00
Koen J 303bd1b805 Fixed spacing issue in bottom bar when button sizes vary. Potential fix for crash that happens in ShapeableImageView. 2025-05-30 14:11:05 +02:00
koen-futo c7f4a40342 Merge pull request #2288 from quonverbat/typo-fix
Fixed typo
2025-05-30 13:50:56 +02:00
quonverbat 208c6c0776 Fixed typo 2025-05-30 00:50:22 +03:00
Kelvin 7d5c8347ce Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-29 18:37:29 +02:00
Kelvin bd70131252 Wrapper fix for unsupported enum parameters 2025-05-29 18:37:12 +02:00
Kelvin 43a373eceb Merge branch 'article-web-support' into 'master'
Article/web support

See merge request videostreaming/grayjay!106
2025-05-29 16:08:04 +00:00
Kelvin 5bb3466ffe Web support, additional article support 2025-05-29 18:02:55 +02:00
Kai 75e97ed008 Merge remote-tracking branch 'origin/master' 2025-05-29 10:24:51 -05:00
Kai ee28604c11 switch audio HLS to m4a
Changelog: changed
2025-05-29 10:24:23 -05:00
Kelvin a7d89e1bfb Missing files 2025-05-29 16:51:31 +02:00
Kelvin cbfd9ea559 Article support 2025-05-29 16:51:06 +02:00
Koen J dae50c3bc3 Implemented navigateWhenReady for url handling. 2025-05-29 16:07:14 +02:00
Koen J e651e59dc4 Theoretical crashfix for the case where activity does not successfully resume before handleUrl is invoked #2245. 2025-05-29 15:39:46 +02:00
Koen J 80d78761bf Made chromecast more robust. Improvements to IPv6 handling of casting devices. 2025-05-29 15:17:26 +02:00
Kelvin fb85aa4f32 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into article-web-support 2025-05-29 13:05:09 +02:00
Kelvin 9635c95efe WIP article 2025-05-29 13:05:00 +02:00
Koen J 033a237488 Changed getLocalUrl logic. 2025-05-29 13:03:51 +02:00
Koen J ec22c58822 Made a fix for ui mode changes causing app restarts. 2025-05-29 11:58:10 +02:00
Koen J 274942b5ba Hide search for any tab that isn't videos. 2025-05-29 11:55:21 +02:00
Koen J 94ab3da0e4 Added separate error status code for transport rejection. Added unhandled exception handler for relay loop. Added additional booleans to keep track of the server/relay connections being up/down. Added additional messaging to let the user know when something is wrong. 2025-05-29 11:51:59 +02:00
Kai DeLorenzo 5d44f0f2b6 Merge branch 'download-encrypted-hls' into 'master'
Encrypted HLS Download

See merge request videostreaming/grayjay!89
2025-05-29 04:20:54 +00:00
Kai f051e6b452 Merge branch 'master' into download-encrypted-hls
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/parsers/HLS.kt
2025-05-28 23:20:22 -05:00
Kai DeLorenzo 46a4284253 Merge branch 'hls-audio-fixes' into 'master'
HLS audio download

See merge request videostreaming/grayjay!86
2025-05-29 04:17:55 +00:00
Kai 0a708c6892 Merge branch 'master' into hls-audio-fixes
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt
2025-05-28 23:12:50 -05:00
Kai DeLorenzo 0f96164dc3 Merge branch 'small-hls-fixes' into 'master'
Fix HLS downloading for nebula

See merge request videostreaming/grayjay!85
2025-05-29 04:10:06 +00:00
Kai 91c4917021 Merge branch 'master' into download-encrypted-hls
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt
2025-05-28 22:55:09 -05:00
Kai c32ebe016b fix extension
Changelog: changed
2025-05-28 17:33:35 -05:00
Kai ea26eefc2d Merge branch 'master' into hls-audio-fixes 2025-05-28 17:24:32 -05:00
Kai 418f4a6075 Merge branch 'master' into small-hls-fixes 2025-05-28 16:57:57 -05:00
Koen J 0ec921709a Fixed search visibility and channel loader when changing query. 2025-05-28 20:32:09 +02:00
Koen J e0811cfd93 Implemented new channel search. 2025-05-28 20:08:22 +02:00
Kai DeLorenzo f6b0778eb6 Merge branch 'ffmpeg-build-fix' into 'master'
add smart exception dependency for usage by local ffmpegkit dependency

See merge request videostreaming/grayjay!105
2025-05-27 19:06:50 +00:00
Kai 18aec34c0e add smart exception dependency for usage by local ffmpegkit dependency
Changelog: added
2025-05-27 14:05:51 -05:00
Kelvin bd185776e7 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into article-web-support 2025-05-27 15:49:27 +02:00
Kelvin fca5fe38bb WIP Article/WEb 2025-05-27 15:49:20 +02:00
koen-futo 1c2c7b376d Merge pull request #2152 from deenle/master
Added adaptive monochrome launcher icon
2025-05-27 11:47:33 +02:00
Koen J 670df86114 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-27 11:47:08 +02:00
Koen 55fb4d4562 Merge branch 'add-crunchyroll' into 'master'
Add Crunchyroll

See merge request videostreaming/grayjay!104
2025-05-26 12:59:15 +00:00
Koen J c703d018bd Fixed issue where loop video would not reset when opening a playlist, causing the video to loop without being able to disable it. 2025-05-26 14:22:49 +02:00
Stefan 425a27e130 Add Crunchyroll 2025-05-26 00:47:54 +01:00
Kai bd1b0e875b Merge branch 'master' into hls-audio-fixes
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt
#	app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt
#	app/src/main/java/com/futo/platformplayer/parsers/HLS.kt
2025-05-22 15:04:15 -05:00
Kai 1509c11f64 check to see if an HLS playlist is a master playlist before parsing it
Changelog: changed
2025-05-22 15:00:34 -05:00
Kai 57c1097fbc Merge branch 'master' into small-hls-fixes
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt
2025-05-22 14:02:28 -05:00
Kai 1d1728b92b switch audio hls to be an mp4 file
Changelog: changed
2025-05-22 13:44:50 -05:00
Kai 8202513993 fix stutter when switching to background
Changelog: changed
2025-05-22 12:12:34 -05:00
Kai DeLorenzo 5f9f6dbde8 Merge branch 'linked-channel-shorts-fix' into 'master'
Polycentric Channel Shorts Tab Fix

See merge request videostreaming/grayjay!100
2025-05-22 16:32:27 +00:00
Kai cc3639180b Merge branch 'master' into linked-channel-shorts-fix
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt
2025-05-22 11:31:05 -05:00
Kai 8aa4de7522 make sure the plugin supports shorts content
Changelog: added
2025-05-22 11:28:59 -05:00
Koen J ed1f7e7c72 Button incognito is now visible but translated when minimized. 2025-05-22 15:57:12 +02:00
Koen J 1ecd1f5e04 Fix for throttled networks (airplane wifi) freezing app opening downloaded content. 2025-05-22 13:45:57 +02:00
Koen J 1aa9adc899 Build fix. 2025-05-22 11:41:16 +02:00
Koen J f8b2da93b9 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-22 09:36:16 +02:00
Koen J b794ff47ef Added dependency. 2025-05-22 09:34:32 +02:00
Kai DeLorenzo 6962a0547a Merge branch 'fix-get-replies' into 'master'
Fix getReplies in source.js

See merge request videostreaming/grayjay!102
2025-05-21 21:21:38 +00:00
Kai b906c1d36b add assignment for getReplies to PlatformComment
Changelog: added
2025-05-21 16:20:35 -05:00
Koen J af337b1874 Added missing checks for polycentric enabled. 2025-05-19 15:37:39 +02:00
Koen J 542235cca0 Keep live chat open when minimizing. #2227 2025-05-19 11:45:45 +02:00
Koen J f5673425b7 Updated default price. 2025-05-17 08:54:40 +02:00
Koen J 94965cf3ba Use snapshots for sending order to improve send efficiency. 2025-05-16 12:34:48 +02:00
Koen J 120ded5274 Prepared Sync logic to be separated from the rest of the logic. 2025-05-16 12:11:41 +02:00
Koen J 705eb6a3fa Migrated service to SyncService. 2025-05-15 19:57:31 +02:00
Koen J 1eb62b31d2 Added client sided ping loops to prevent inactive channels. 2025-05-13 10:31:30 +02:00
koen-futo b145187fa8 Merge pull request #1803 from ajp-dev/patch-1
fix link typo for Script Signing
2025-05-12 09:47:51 +02:00
koen-futo 4da1e44fd1 Merge pull request #1724 from AlexandrePicavet/feat/player-seek-offset
feat(player): Add a setting to adjust player seek duration
2025-05-12 09:43:22 +02:00
koen-futo 4e70279982 Merge pull request #2136 from buzzcola3/master
Fix "previous song" button playing always from unshuffled queue, even when shuffle is used
2025-05-12 09:36:52 +02:00
koen-futo 233c8ee26e Merge pull request #2155 from realchrisolin/patch-1
Update AndroidManifest.xml
2025-05-12 09:24:28 +02:00
Koen 875adb4d79 Merge branch 'fix-other-cookie-handling' into 'master'
Pass httponly cookies to future requests

See merge request videostreaming/grayjay!88
2025-05-12 07:01:41 +00:00
Koen 456514c4d4 Merge branch 'hls-rumble-fix' into 'master'
Rumble Download HLS Fix

See merge request videostreaming/grayjay!95
2025-05-12 06:46:55 +00:00
Koen J dac1918b95 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-12 08:43:12 +02:00
Koen J 1d7429ad86 Fixed issue where private mode would not re-appear after closing the video. 2025-05-12 08:43:02 +02:00
Kai DeLorenzo 5d0e6615ab Edit VideoDownload.kt 2025-05-09 17:36:27 +00:00
Kai DeLorenzo dc415df8c0 Edit VideoDownload.kt 2025-05-09 17:35:36 +00:00
Kai DeLorenzo 45ce251c4c Merge branch 'streamline-self-update' into 'master'
Silent Self Update

See merge request videostreaming/grayjay!81
2025-05-09 17:10:47 +00:00
Koen 2bc702112f Merge branch 'fix-brightness-restore' into 'master'
Brightness Restore On App Close

See merge request videostreaming/grayjay!99
2025-05-09 16:18:41 +00:00
Kai DeLorenzo abd73bf797 Merge branch 'theme-change-restart' into 'master'
Fix Restart On System Theme Change

See merge request videostreaming/grayjay!97
2025-05-09 15:17:52 +00:00
Kai e7e67b9572 organize imports
Changelog: changed
2025-05-09 10:17:15 -05:00
Kai 1a58b693c1 Merge branch 'master' into theme-change-restart
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt
2025-05-09 10:14:33 -05:00
Kai 50ecb909b4 Remove cookie
Changelog: changed
2025-05-09 10:08:24 -05:00
Koen 5e480be8db Merge branch 'fullscreen-glitch-fix' into 'master'
Fullscreen UI Glitch Fix

See merge request videostreaming/grayjay!101
2025-05-09 06:43:40 +00:00
Koen J 48a67e51a6 Various fixes. 2025-05-07 20:57:55 +02:00
Koen J 5052bad824 Updated submodules. 2025-05-07 15:46:07 +02:00
Koen J 5be92052bb Fixed device vanishing while still being there in the case the device is blacklisted from starting a relay. 2025-05-07 15:43:28 +02:00
Koen e20945692e Merge branch 'add-plugin-curiosity-stream' into 'master'
add Curiosity Stream plugin

See merge request videostreaming/grayjay!94
2025-05-07 13:03:35 +00:00
Stefan 191a6e2460 add Curiosity Stream plugin 2025-05-07 13:03:35 +00:00
Koen J c813fb4fad Do not show history entries where it could not retrieve a plugin id. 2025-05-07 14:34:57 +02:00
Koen J bf7001b578 Implemented to background button in pip. 2025-05-07 14:17:29 +02:00
Koen J 18102a2a73 Try a more heavy handed approach to get plugin ids for history changes. 2025-05-07 13:26:31 +02:00
Koen J 780c1dbde1 Reverted platform filters on history page. 2025-05-07 13:18:22 +02:00
Koen J 879aab0d99 Added progress bar to playlist items. 2025-05-07 13:08:13 +02:00
Koen J 6f37bc2f5d Confirming sync now brings you back to the device list. 2025-05-07 12:57:44 +02:00
Koen J fc59b841d6 History now filters out videos of plugins that are not enabled. History now has a list of filters to filter specific plugins. History now shows an icon of which platform a specific history video is on. 2025-05-07 12:49:39 +02:00
Koen J c07fcdd489 Prevent going into picture in picture when clicking add sources. 2025-05-07 11:22:54 +02:00
Koen J a49db10ade Fixed issue 2163. 2025-05-07 11:01:25 +02:00
Koen J 77bae98d77 Private mode visibility no longer overlays pip/minimized video. 2025-05-07 10:57:39 +02:00
Koen J 254df7211c Fixed datetime checking related to playlists on android. 2025-05-07 10:36:32 +02:00
Koen J f9caab48c4 Added insensitivity to base64 formats. 2025-05-07 10:12:12 +02:00
Koen J e0b5e7b808 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-06 16:55:51 +02:00
Koen J ac3a8da002 Various fixes for android to android pairing. 2025-05-06 16:54:58 +02:00
Kelvin 1aa45c2156 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-06 13:23:10 +02:00
Kelvin 3cf8abd409 Fix racecondition watchlater adds 2025-05-06 13:23:00 +02:00
Koen J db8426779c Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-06 13:04:53 +02:00
Koen J b419e033f3 Casting device more clearly communicates when not ready. Implemented backoffs for SyncServer. 2025-05-06 13:04:42 +02:00
Kelvin d686fa327b Incorrect gzip compression 2025-05-06 12:40:24 +02:00
Kelvin a1ce5eda43 Fix synced ImageVariables showing black images 2025-05-06 11:53:30 +02:00
Koen J 1e790d1aa9 Added toggle to be able to disable local functionality for sync. Sync now automatically closes when pairing is successful. Pairing in progress layouts now properly show again. 2025-05-05 13:34:52 +02:00
Koen J d1d304b758 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-05 12:00:15 +02:00
Koen J e12b500144 Sync disabled by default. 2025-05-05 12:00:04 +02:00
Kelvin bd77651a1e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-05 11:18:57 +02:00
Kelvin 35dc186395 Login edgecase fix 2025-05-05 11:18:46 +02:00
Koen J 07e78e0d12 Fixed sending packets without data in sync protocol. 2025-05-05 10:41:21 +02:00
Koen J 5b8905c1d2 Made ipv6 casting URL fix. 2025-05-04 00:50:12 +02:00
Koen J 158a27cbae Casting fixes. 2025-05-03 21:20:19 +02:00
Koen J 5769b39d78 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-03 21:05:23 +02:00
Koen J 5c96262c75 Crashfix. 2025-05-03 21:05:12 +02:00
Koen J 766f57dc9d Crashfix. 2025-05-03 21:04:39 +02:00
Kelvin 9986078582 Fix sync crash and responsiveness for subs sync 2025-05-03 19:30:47 +02:00
Koen J e047ab5684 Crashfixes. 2025-05-03 17:35:17 +02:00
Koen J a100785ad7 Gzip only for data packets. 2025-05-03 17:09:34 +02:00
Koen J 156eb4d15e Implemented sync protocol gzip. 2025-05-03 15:00:17 +02:00
Koen J dabcfd965f Fixed send on wrong thread. 2025-05-03 12:11:08 +02:00
Koen J d44a71f3be Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-03 11:22:06 +02:00
Koen J f8edd6cf3d Possibel performance improvements to sync under high lat conditions. 2025-05-03 11:21:57 +02:00
Kelvin 2baf53c5a4 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-02 12:10:58 +02:00
Kelvin c26e9c281f Easier search type switching on results page, fix search result click channels 2025-05-02 12:10:47 +02:00
Koen J 9f78e9b7dd Crashfix in nsdmanager. StateSync reconnects less often. Channels are closed when sending fails in sync. 2025-05-01 21:53:48 +02:00
Kelvin fdaf41b605 BuildPlatform property 2025-05-01 16:31:51 +02:00
Koen J 89526efe7a Updated submodules. 2025-05-01 14:11:22 +02:00
Koen J 5e3a25c18f Added dialog with loader before QR code scanner shows. 2025-05-01 11:00:33 +02:00
Koen J cf11c4283e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-05-01 09:56:25 +02:00
Koen J 2dde04b979 Reduced padding on content types search. 2025-05-01 09:55:33 +02:00
Kelvin 8384f227be Plugin refs 2025-04-30 20:02:09 +02:00
Kelvin 697b3bc5f5 SLD domain checking fix, download notification if on metered, check for unstarted downloads on opening ui, minor fixes/imrpovements 2025-04-30 20:00:48 +02:00
Koen J 9e2041521e Made the disconnect button easier to click on casting connected dialog. 2025-04-29 15:27:24 +02:00
Koen J ee7b89ec6e Added new casting dialog. 2025-04-29 15:22:06 +02:00
Koen J 5b143bdc76 Switch to NsdManager. 2025-04-29 08:39:05 +02:00
Kai e3800426c9 Fix https://github.com/futo-org/grayjay-android/issues/1721
By setting video information section to visibility gone when going fullscreen

Changelog: changed
2025-04-28 10:13:26 -05:00
Koen J d9d00e452e Explicitly set network interface in joinGroup. 2025-04-28 16:59:11 +02:00
Koen J 14500e281c Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-04-28 13:59:59 +02:00
Koen J c4623c80ff Implemented app id and updated unit tests. 2025-04-28 13:59:50 +02:00
Kelvin 9e17dce9a9 Fix edgecase where activity killed before 5s after opening 2025-04-25 17:40:45 +02:00
Kai 4acc867634 make channel shorts tab work correctly for linked channels
Changelog: changed
2025-04-23 11:38:09 -05:00
Kai 1a061268de restore brightness when leaving app while a video is full screen
Changelog: changed
2025-04-23 09:30:07 -05:00
Koen J daa91986ef Add search type selector to suggestions fragment. 2025-04-22 13:40:05 +02:00
Koen J 63761cfc9a Simplified all searches to use ContentSearchResultsFragment. 2025-04-22 13:08:23 +02:00
Kai 5091a5485a grayjay only supports dark theme. set that on launch
Changelog: changed
2025-04-21 12:37:12 -05:00
Koen J d10026acd1 Added ping loop. 2025-04-21 13:32:58 +02:00
Kai f8f1cababe increase analyze duration
Changelog: changed
2025-04-17 15:06:30 -05:00
Chris Olin ad46841397 Update AndroidManifest.xml
resolves https://github.com/futo-org/grayjay-android/issues/47
2025-04-16 11:17:39 -04:00
Tim B 20fb1e0fd0 Added monochrome launcher icon 2025-04-16 02:44:40 +02:00
Koen J 9347351c37 Fixed issue where it would continuously try to connect over relay. 2025-04-15 09:39:35 +02:00
Koen J 0ef1f2d40f Added LinkType to Channel. 2025-04-14 15:19:16 +02:00
Koen J b460f9915d Added settings for enabling/disabling remote sync features. Fixed device pairing success showing too early. 2025-04-14 14:41:47 +02:00
Koen J 4e195dfbc3 Rename to direct and relayed. 2025-04-14 10:38:42 +02:00
buzzcola3 38b9fe3017 Fix prevQueueItem always returning the item from _queue, even when _queueShuffled is active 2025-04-13 20:36:15 +02:00
Koen 3c7f7bfca7 Merge branch 'remote-sync' into 'master'
Implemented remote sync.

See merge request videostreaming/grayjay!93
2025-04-11 14:31:47 +00:00
Koen 05230971b3 Implemented remote sync. 2025-04-11 14:31:47 +00:00
Kelvin K dccdf72c73 Message change 2025-04-09 23:35:44 +02:00
Kelvin K ca15983a72 Casting message, caching creator images 2025-04-09 23:26:35 +02:00
Kelvin K 4b6a2c9829 Keyboard hide on search end 2025-04-09 21:02:19 +02:00
Kelvin K 1755d03a6b Fcast clearer connection/reconnection overlay, disable ipv6 by default 2025-04-09 00:56:49 +02:00
Kelvin K 869b1fc15e Fix pager for landscape 2025-04-08 00:34:52 +02:00
Kelvin K ce2a2f8582 submods 2025-04-07 23:32:57 +02:00
Kelvin K 7b355139fb Subscription persistence fixes, home toggle fixes, subs exchange gzip, etc 2025-04-07 23:31:00 +02:00
Kelvin K b14518edb1 Home filter fixes, persistent sorts, subs exchange fixes, playlist video options 2025-04-05 01:02:50 +02:00
Kelvin K 7d64003d1c Feed filter loading improved, home filters support, various peripheral stuff 2025-04-04 00:37:26 +02:00
Kelvin K 0a59e04f19 Fix ui offset issue when opening video through search url 2025-04-02 23:40:37 +02:00
Kelvin b57abb646f Merge branch 'subs-exchange' into 'master'
Experimental Subs Exchange

See merge request videostreaming/grayjay!91
2025-04-02 21:12:07 +00:00
Kelvin K dd6bde97a9 Playlists sort and search support, Playlist search support, wip local playback, other fixes 2025-04-02 22:53:54 +02:00
Kelvin K b545545712 Remove dep 2025-04-01 01:01:45 +02:00
Kelvin K c1993ffa03 SubsExchange fixes 2025-04-01 00:56:24 +02:00
Kelvin K 7f7ebafa46 Resume on playback error instead of reseting, dont error on empty author url, subs exchange fixes 2025-03-31 20:02:49 +02:00
Kelvin K b652597924 chapters ui on text press 2025-03-28 21:40:17 +01:00
Koen 258fe77928 Merge branch 'add-plugin-tedtalks' into 'master'
add ted talks plugin

See merge request videostreaming/grayjay!90
2025-03-28 19:42:30 +00:00
Stefan 5a9fcd6fab add ted talks plugin 2025-03-28 19:13:55 +00:00
Kelvin K 3c05521a5b Chapter Overlay 2025-03-27 23:25:13 +01:00
Kelvin K 034b8b15ae WIP SubsExchange 2025-03-26 23:28:32 +01:00
Kelvin K 7bd687331b AddSource by url support, submods 2025-03-24 20:33:17 +01:00
Kai DeLorenzo bdae35b1a8 Merge branch 'master' into 'small-hls-fixes'
# Conflicts:
#   app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt
2025-03-24 02:12:27 +00:00
Kelvin K 54d58df4b6 Sync watch later on initial connection, Original audio boolean support, priority audio support, setting to prefer original audio 2025-03-21 02:23:55 +01:00
Kai DeLorenzo 9165a9f7cb Add : support for login button selector 2025-03-17 23:24:15 +00:00
Kelvin b556d1e81d Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-03-10 22:12:27 +01:00
Kelvin 7c25678211 Subgroup sub image url, ImageVariable default error icon on fail to load 2025-03-10 22:12:17 +01:00
Koen J c83a9924e2 Implemented new ApiMethods calls. 2025-03-05 17:04:48 +01:00
Koen J bbeb9b83a0 Removed dynamic Polycentric calls. 2025-03-05 11:58:09 +01:00
Kai 470b7bd2e5 add non iv version
Changelog: added
2025-02-20 21:27:44 -06:00
Kai 9014fb581d add support for downloading encrypted HLS streams
Changelog: changed
2025-02-20 16:07:35 -06:00
Kai 7ffa6b1bb3 revert params
Changelog: changed
2025-02-19 12:32:17 -06:00
Kai 3cd4b4503f fix other cookie handling
Changelog: changed
2025-02-19 11:59:59 -06:00
Kai d63fa521a1 Merge remote-tracking branch 'origin/hls-audio-fixes' into hls-audio-fixes
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt
2025-02-11 10:36:56 -06:00
Kai ca781dfe15 restore hard code HLS as mp4
Changelog: changed
2025-02-11 10:35:05 -06:00
Kai 4bc561ceab fix HLS audio download and download audio only
Changelog: changed
2025-02-11 10:34:39 -06:00
Kai 3d258180bd restore hard code HLS as mp4
Changelog: changed
2025-02-11 10:31:47 -06:00
Kai d5cab0910e fix HLS audio download and download audio only
Changelog: changed
2025-02-10 22:21:06 -06:00
Kai d4ccf232c1 fix HLS download odysee nebula peertube
Changelog: changed
2025-02-10 17:57:27 -06:00
Alexandre Picavet daf1d42a0f feat(player): Add a setting to adjust player seek duration
Create a seekOffset dropdown setting defaulting to 10 seconds.
Update the fastForwardTick method  of the GestureControlView to take the
seekOffset setting into account and update the view accordingly.
2025-02-04 22:00:08 +01:00
Kai a1d460385d prevent the user from needing to tap update on system dialog when self updating
Changelog: added
2025-01-20 15:38:26 -06:00
ajp-dev d2ed0c65ca fix link typo for Script Signing
typo causes HTTP 400 error when accessing link to Script Signing.md
2025-01-17 17:05:49 +08:00
312 changed files with 11023 additions and 5519 deletions
+2
View File
@@ -0,0 +1,2 @@
aar/* filter=lfs diff=lfs merge=lfs -text
app/aar/* filter=lfs diff=lfs merge=lfs -text
@@ -1,6 +1,9 @@
name: Bug Report name: Bug Report
description: Let us know about an unexpected error, a crash, or an incorrect behavior. description: Let us know about an unexpected error, a crash, or an incorrect behavior.
labels: ["Bug"] labels: ["Bug", "Android"]
title: "Bug: "
type: bug
projects: ["futo-org/19"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
@@ -18,11 +21,33 @@ body:
* if you've found out a particular series of UI interactions can introduce buggy behavior, please label those steps 1-n with markdown * if you've found out a particular series of UI interactions can introduce buggy behavior, please label those steps 1-n with markdown
- type: textarea - type: textarea
id: what-happened id: reproduction-steps
attributes: attributes:
label: What happened? label: Reproduction steps
description: What did you expect to happen? description: Please provide us with the steps to reproduce the issue if possible. This step makes a big difference if we are going to be able to fix it so be as precise as possible.
placeholder: Tell us what you see! placeholder: |
0. Play a Youtube video
1. Press on Download button
2. Select quality 1440p
3. Grayjay crashes when attempting to download
validations:
required: true
- type: textarea
id: actual-result
attributes:
label: Actual result
description: What happend?
placeholder: Tell us what you saw!
validations:
required: true
- type: textarea
id: expected-result
attributes:
label: Expected result
description: What was suppose to happen?
placeholder: Tell us what you expected to happen!
validations: validations:
required: true required: true
@@ -31,7 +56,7 @@ body:
attributes: attributes:
label: Grayjay Version label: Grayjay Version
description: In the application, select More > Settings, scroll to the bottom and locate the value next to "Version Name". description: In the application, select More > Settings, scroll to the bottom and locate the value next to "Version Name".
placeholder: "242" placeholder: "311"
validations: validations:
required: true required: true
@@ -42,19 +67,23 @@ body:
multiple: true multiple: true
options: options:
- "All" - "All"
- "Youtube" - "Apple Podcasts"
- "Odysee"
- "Rumble"
- "Kick"
- "Twitch"
- "PeerTube"
- "Patreon"
- "Nebula"
- "BiliBili (CN)" - "BiliBili (CN)"
- "Bitchute" - "Bitchute"
- "SoundCloud" - "Crunchyroll"
- "CuriosityStream"
- "Dailymotion" - "Dailymotion"
- "Apple Podcasts" - "Kick"
- "Nebula"
- "Odysee"
- "Patreon"
- "PeerTube"
- "Rumble"
- "SoundCloud"
- "Spotify"
- "TedTalks"
- "Twitch"
- "Youtube"
- "Other" - "Other"
validations: validations:
required: true required: true
@@ -66,6 +95,30 @@ body:
description: In the application, select Sources > [the broken plugin], write down the value under "Version". description: In the application, select Sources > [the broken plugin], write down the value under "Version".
placeholder: "12" placeholder: "12"
- type: input
id: android-version
attributes:
label: Which android version are you using?
placeholder: "Android 15"
validations:
required: true
- type: input
id: phone-model
attributes:
label: Which device are you using?
placeholder: "Google Pixel 9"
validations:
required: true
- type: input
id: os-version
attributes:
label: Which operating system are you using?
placeholder: "GrapheneOS/CalyxOS/Tizen/HyperOS 2/..."
validations:
required: true
- type: checkboxes - type: checkboxes
id: login id: login
attributes: attributes:
@@ -86,9 +139,28 @@ body:
validations: validations:
required: true required: true
- type: textarea
id: grayjay-references
attributes:
label: References
description: |
Are there any other GitHub issues, whether open or closed, that are related to the problem you've described above? If so, please create a list below that mentions each of them. For example:
```
- #10
```
placeholder:
value:
validations:
required: false
- type: textarea - type: textarea
id: logs id: logs
attributes: attributes:
label: Relevant log output label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell render: shell
- type: markdown
attributes:
value: |
**Note:** If the submit button is disabled and you have filled out all required fields, please check that you did not forget a **Title** for the issue.
@@ -1,13 +1,16 @@
name: Feature Request name: Feature Request
description: Suggest a new feature or other enhancement. description: Suggest a new feature or other enhancement.
labels: ["Enhancement"] labels: ["Enhancement", "Android"]
title: "Feature request: "
type: feature
projects: ["futo-org/19"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
# Thank you for opening a feature request. # Thank you for opening a feature request.
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues and feature requests relating to the Grayjay android application
For discussion related to enhancements, please see: [The FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay) For discussion related to enhancements, please see: [The FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
@@ -1,13 +1,16 @@
name: Documentation Issue name: Documentation Issue
description: Report an issue or suggest a change in the documentation. description: Report an issue or suggest a change in the documentation.
labels: ["Documentation"] labels: ["Documentation"]
title: "Documentation: "
type: task
projects: ["futo-org/19"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
# Thank you for opening a documentation change request. # Thank you for opening a documentation change request.
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application. Use the `Documentation` issue type to report problems with the documentation in our code repositories, inside the application, or on [https://grayjay.app](https://grayjay.app) The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay android application. Use the `Documentation` issue type to report problems with the documentation in our code repositories, inside the application, or on [https://grayjay.app](https://grayjay.app)
Technical writers monitor this issue type, so report Grayjay bugs or feature requests with the `Bug report` or `Feature Request` issue types instead to get engineering attention. Technical writers monitor this issue type, so report Grayjay bugs or feature requests with the `Bug report` or `Feature Request` issue types instead to get engineering attention.
For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay) For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
+18
View File
@@ -88,3 +88,21 @@
[submodule "app/src/unstable/assets/sources/apple-podcasts"] [submodule "app/src/unstable/assets/sources/apple-podcasts"]
path = app/src/unstable/assets/sources/apple-podcasts path = app/src/unstable/assets/sources/apple-podcasts
url = ../plugins/apple-podcasts.git url = ../plugins/apple-podcasts.git
[submodule "app/src/stable/assets/sources/tedtalks"]
path = app/src/stable/assets/sources/tedtalks
url = ../plugins/tedtalks.git
[submodule "app/src/unstable/assets/sources/tedtalks"]
path = app/src/unstable/assets/sources/tedtalks
url = ../plugins/tedtalks.git
[submodule "app/src/stable/assets/sources/curiositystream"]
path = app/src/stable/assets/sources/curiositystream
url = ../plugins/curiositystream.git
[submodule "app/src/unstable/assets/sources/curiositystream"]
path = app/src/unstable/assets/sources/curiositystream
url = ../plugins/curiositystream.git
[submodule "app/src/unstable/assets/sources/crunchyroll"]
path = app/src/unstable/assets/sources/crunchyroll
url = ../plugins/crunchyroll.git
[submodule "app/src/stable/assets/sources/crunchyroll"]
path = app/src/stable/assets/sources/crunchyroll
url = ../plugins/crunchyroll.git
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ea10d3c5562c9f449a4e89e9c3dfcf881ed79a952f3409bc005bcc62c2cf4b81
size 65512557
+2 -1
View File
@@ -197,7 +197,8 @@ dependencies {
implementation 'org.jsoup:jsoup:1.15.3' implementation 'org.jsoup:jsoup:1.15.3'
implementation 'com.google.android.flexbox:flexbox:3.0.0' implementation 'com.google.android.flexbox:flexbox:3.0.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'com.arthenica:ffmpeg-kit-full:6.0-2.LTS' implementation fileTree(dir: 'aar', include: ['*.aar'])
implementation 'com.arthenica:smart-exception-java:0.2.1'
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0' implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0'
implementation 'com.github.dhaval2404:imagepicker:2.1' implementation 'com.github.dhaval2404:imagepicker:2.1'
implementation 'com.google.zxing:core:3.4.1' implementation 'com.google.zxing:core:3.4.1'
@@ -0,0 +1,338 @@
package com.futo.platformplayer
import com.futo.platformplayer.noise.protocol.Noise
import com.futo.platformplayer.sync.internal.*
import kotlinx.coroutines.*
import kotlinx.coroutines.selects.select
import org.junit.Assert.*
import org.junit.Test
import java.net.Socket
import java.nio.ByteBuffer
import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class SyncServerTests {
//private val relayHost = "relay.grayjay.app"
//private val relayKey = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw="
private val relayKey = "XlUaSpIlRaCg0TGzZ7JYmPupgUHDqTZXUUBco2K7ejw="
private val relayHost = "192.168.1.138"
private val relayPort = 9000
/** Creates a client connected to the live relay server. */
private suspend fun createClient(
onHandshakeComplete: ((SyncSocketSession) -> Unit)? = null,
onData: ((SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit)? = null,
onNewChannel: ((SyncSocketSession, ChannelRelayed) -> Unit)? = null,
isHandshakeAllowed: ((LinkType, SyncSocketSession, String, String?, UInt) -> Boolean)? = null,
onException: ((Throwable) -> Unit)? = null
): SyncSocketSession = withContext(Dispatchers.IO) {
val p = Noise.createDH("25519")
p.generateKeyPair()
val socket = Socket(relayHost, relayPort)
val inputStream = LittleEndianDataInputStream(socket.getInputStream())
val outputStream = LittleEndianDataOutputStream(socket.getOutputStream())
val tcs = CompletableDeferred<Boolean>()
val socketSession = SyncSocketSession(
relayHost,
p,
inputStream,
outputStream,
onClose = { socket.close() },
onHandshakeComplete = { s ->
onHandshakeComplete?.invoke(s)
tcs.complete(true)
},
onData = onData ?: { _, _, _, _ -> },
onNewChannel = onNewChannel ?: { _, _ -> },
isHandshakeAllowed = isHandshakeAllowed ?: { _, _, _, _, _ -> true }
)
socketSession.authorizable = AlwaysAuthorized()
try {
socketSession.startAsInitiator(relayKey)
} catch (e: Throwable) {
onException?.invoke(e)
}
withTimeout(5000.milliseconds) { tcs.await() }
return@withContext socketSession
}
@Test
fun multipleClientsHandshake_Success() = runBlocking {
val client1 = createClient()
val client2 = createClient()
assertNotNull(client1.remotePublicKey, "Client 1 handshake failed")
assertNotNull(client2.remotePublicKey, "Client 2 handshake failed")
client1.stop()
client2.stop()
}
@Test
fun publishAndRequestConnectionInfo_Authorized_Success() = runBlocking {
val clientA = createClient()
val clientB = createClient()
val clientC = createClient()
clientA.publishConnectionInformation(arrayOf(clientB.localPublicKey), 12345, true, true, true, true)
delay(100.milliseconds)
val infoB = clientB.requestConnectionInfo(clientA.localPublicKey)
val infoC = clientC.requestConnectionInfo(clientA.localPublicKey)
assertNotNull("Client B should receive connection info", infoB)
assertEquals(12345.toUShort(), infoB!!.port)
assertNull("Client C should not receive connection info (unauthorized)", infoC)
clientA.stop()
clientB.stop()
clientC.stop()
}
@Test
fun relayedTransport_Bidirectional_Success() = runBlocking {
val tcsA = CompletableDeferred<ChannelRelayed>()
val tcsB = CompletableDeferred<ChannelRelayed>()
val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
channelA.authorizable = AlwaysAuthorized()
val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
channelB.authorizable = AlwaysAuthorized()
channelTask.await()
val tcsDataB = CompletableDeferred<ByteArray>()
channelB.setDataHandler { _, _, o, so, d ->
val b = ByteArray(d.remaining())
d.get(b)
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
}
channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(byteArrayOf(1, 2, 3)))
val tcsDataA = CompletableDeferred<ByteArray>()
channelA.setDataHandler { _, _, o, so, d ->
val b = ByteArray(d.remaining())
d.get(b)
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataA.complete(b)
}
channelB.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(byteArrayOf(4, 5, 6)))
val receivedB = withTimeout(5000.milliseconds) { tcsDataB.await() }
val receivedA = withTimeout(5000.milliseconds) { tcsDataA.await() }
assertArrayEquals(byteArrayOf(1, 2, 3), receivedB)
assertArrayEquals(byteArrayOf(4, 5, 6), receivedA)
clientA.stop()
clientB.stop()
}
@Test
fun relayedTransport_MaximumMessageSize_Success() = runBlocking {
val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE - 8 - 16 - 16
val maxSizeData = ByteArray(MAX_DATA_PER_PACKET).apply { Random.nextBytes(this) }
val tcsA = CompletableDeferred<ChannelRelayed>()
val tcsB = CompletableDeferred<ChannelRelayed>()
val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
channelA.authorizable = AlwaysAuthorized()
val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
channelB.authorizable = AlwaysAuthorized()
channelTask.await()
val tcsDataB = CompletableDeferred<ByteArray>()
channelB.setDataHandler { _, _, o, so, d ->
val b = ByteArray(d.remaining())
d.get(b)
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
}
channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(maxSizeData))
val receivedData = withTimeout(5000.milliseconds) { tcsDataB.await() }
assertArrayEquals(maxSizeData, receivedData)
clientA.stop()
clientB.stop()
}
@Test
fun publishAndGetRecord_Success() = runBlocking {
val clientA = createClient()
val clientB = createClient()
val clientC = createClient()
val data = byteArrayOf(1, 2, 3)
val success = clientA.publishRecords(listOf(clientB.localPublicKey), "testKey", data)
val recordB = clientB.getRecord(clientA.localPublicKey, "testKey")
val recordC = clientC.getRecord(clientA.localPublicKey, "testKey")
assertTrue(success)
assertNotNull(recordB)
assertArrayEquals(data, recordB!!.first)
assertNull("Unauthorized client should not access record", recordC)
clientA.stop()
clientB.stop()
clientC.stop()
}
@Test
fun getNonExistentRecord_ReturnsNull() = runBlocking {
val clientA = createClient()
val clientB = createClient()
val record = clientB.getRecord(clientA.localPublicKey, "nonExistentKey")
assertNull("Getting non-existent record should return null", record)
clientA.stop()
clientB.stop()
}
@Test
fun updateRecord_TimestampUpdated() = runBlocking {
val clientA = createClient()
val clientB = createClient()
val key = "updateKey"
val data1 = byteArrayOf(1)
val data2 = byteArrayOf(2)
clientA.publishRecords(listOf(clientB.localPublicKey), key, data1)
val record1 = clientB.getRecord(clientA.localPublicKey, key)
delay(1000.milliseconds)
clientA.publishRecords(listOf(clientB.localPublicKey), key, data2)
val record2 = clientB.getRecord(clientA.localPublicKey, key)
assertNotNull(record1)
assertNotNull(record2)
assertTrue(record2!!.second > record1!!.second)
assertArrayEquals(data2, record2.first)
clientA.stop()
clientB.stop()
}
@Test
fun deleteRecord_Success() = runBlocking {
val clientA = createClient()
val clientB = createClient()
val data = byteArrayOf(1, 2, 3)
clientA.publishRecords(listOf(clientB.localPublicKey), "toDelete", data)
val success = clientB.deleteRecords(clientA.localPublicKey, clientB.localPublicKey, listOf("toDelete"))
val record = clientB.getRecord(clientA.localPublicKey, "toDelete")
assertTrue(success)
assertNull(record)
clientA.stop()
clientB.stop()
}
@Test
fun listRecordKeys_Success() = runBlocking {
val clientA = createClient()
val clientB = createClient()
val keys = arrayOf("key1", "key2", "key3")
keys.forEach { key ->
clientA.publishRecords(listOf(clientB.localPublicKey), key, byteArrayOf(1))
}
val listedKeys = clientB.listRecordKeys(clientA.localPublicKey, clientB.localPublicKey)
assertArrayEquals(keys, listedKeys.map { it.first }.toTypedArray())
clientA.stop()
clientB.stop()
}
@Test
fun singleLargeMessageViaRelayedChannel_Success() = runBlocking {
val largeData = ByteArray(100000).apply { Random.nextBytes(this) }
val tcsA = CompletableDeferred<ChannelRelayed>()
val tcsB = CompletableDeferred<ChannelRelayed>()
val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
channelA.authorizable = AlwaysAuthorized()
val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
channelB.authorizable = AlwaysAuthorized()
channelTask.await()
val tcsDataB = CompletableDeferred<ByteArray>()
channelB.setDataHandler { _, _, o, so, d ->
val b = ByteArray(d.remaining())
d.get(b)
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
}
channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(largeData))
val receivedData = withTimeout(10000.milliseconds) { tcsDataB.await() }
assertArrayEquals(largeData, receivedData)
clientA.stop()
clientB.stop()
}
@Test
fun publishAndGetLargeRecord_Success() = runBlocking {
val largeData = ByteArray(1000000).apply { Random.nextBytes(this) }
val clientA = createClient()
val clientB = createClient()
val success = clientA.publishRecords(listOf(clientB.localPublicKey), "largeRecord", largeData)
val record = clientB.getRecord(clientA.localPublicKey, "largeRecord")
assertTrue(success)
assertNotNull(record)
assertArrayEquals(largeData, record!!.first)
clientA.stop()
clientB.stop()
}
@Test
fun relayedTransport_WithValidAppId_Success() = runBlocking {
// Arrange: Set up clients
val allowedAppId = 1234u
val tcsB = CompletableDeferred<ChannelRelayed>()
// Client B requires appId 1234
val clientB = createClient(
onNewChannel = { _, c -> tcsB.complete(c) },
isHandshakeAllowed = { linkType, _, _, _, appId -> linkType == LinkType.Relayed && appId == allowedAppId }
)
val clientA = createClient()
// Act: Start relayed channel with valid appId
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey, appId = allowedAppId) }
val channelB = withTimeout(5.seconds) { tcsB.await() }
withTimeout(5.seconds) { channelTask.await() }
// Assert: Channel is established
assertNotNull("Channel should be created on target with valid appId", channelB)
// Clean up
clientA.stop()
clientB.stop()
}
@Test
fun relayedTransport_WithInvalidAppId_Fails() = runBlocking {
// Arrange: Set up clients
val allowedAppId = 1234u
val invalidAppId = 5678u
val tcsB = CompletableDeferred<ChannelRelayed>()
// Client B requires appId 1234
val clientB = createClient(
onNewChannel = { _, c -> tcsB.complete(c) },
isHandshakeAllowed = { linkType, _, _, _, appId -> linkType == LinkType.Relayed && appId == allowedAppId },
onException = { }
)
val clientA = createClient()
// Act & Assert: Attempt with invalid appId should fail
try {
withTimeout(5.seconds) {
clientA.startRelayedChannel(clientB.localPublicKey, appId = invalidAppId)
}
fail("Starting relayed channel with invalid appId should fail")
} catch (e: Throwable) {
// Expected: The channel creation should time out or fail
}
// Ensure no channel was created on client B
val completedTask = select {
tcsB.onAwait { "channel" }
async { delay(1.seconds); "timeout" }.onAwait { "timeout" }
}
assertEquals("No channel should be created with invalid appId", "timeout", completedTask)
// Clean up
clientA.stop()
clientB.stop()
}
}
class AlwaysAuthorized : IAuthorizable {
override val isAuthorized: Boolean get() = true
}
@@ -0,0 +1,512 @@
package com.futo.platformplayer
import com.futo.platformplayer.noise.protocol.DHState
import com.futo.platformplayer.noise.protocol.Noise
import com.futo.platformplayer.sync.internal.*
import kotlinx.coroutines.*
import org.junit.Assert.*
import org.junit.Test
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.nio.ByteBuffer
import kotlin.random.Random
import java.io.InputStream
import java.io.OutputStream
import kotlin.time.Duration.Companion.seconds
data class PipeStreams(
val initiatorInput: LittleEndianDataInputStream,
val initiatorOutput: LittleEndianDataOutputStream,
val responderInput: LittleEndianDataInputStream,
val responderOutput: LittleEndianDataOutputStream
)
typealias OnHandshakeComplete = (SyncSocketSession) -> Unit
typealias IsHandshakeAllowed = (LinkType, SyncSocketSession, String, String?, UInt) -> Boolean
typealias OnClose = (SyncSocketSession) -> Unit
typealias OnData = (SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit
class SyncSocketTests {
private fun createPipeStreams(): PipeStreams {
val initiatorOutput = PipedOutputStream()
val responderOutput = PipedOutputStream()
val responderInput = PipedInputStream(initiatorOutput)
val initiatorInput = PipedInputStream(responderOutput)
return PipeStreams(
LittleEndianDataInputStream(initiatorInput), LittleEndianDataOutputStream(initiatorOutput),
LittleEndianDataInputStream(responderInput), LittleEndianDataOutputStream(responderOutput)
)
}
fun generateKeyPair(): DHState {
val p = Noise.createDH("25519")
p.generateKeyPair()
return p
}
private fun createSessions(
initiatorInput: LittleEndianDataInputStream,
initiatorOutput: LittleEndianDataOutputStream,
responderInput: LittleEndianDataInputStream,
responderOutput: LittleEndianDataOutputStream,
initiatorKeyPair: DHState,
responderKeyPair: DHState,
onInitiatorHandshakeComplete: OnHandshakeComplete,
onResponderHandshakeComplete: OnHandshakeComplete,
onInitiatorClose: OnClose? = null,
onResponderClose: OnClose? = null,
onClose: OnClose? = null,
isHandshakeAllowed: IsHandshakeAllowed? = null,
onDataA: OnData? = null,
onDataB: OnData? = null
): Pair<SyncSocketSession, SyncSocketSession> {
val initiatorSession = SyncSocketSession(
"", initiatorKeyPair, initiatorInput, initiatorOutput,
onClose = {
onClose?.invoke(it)
onInitiatorClose?.invoke(it)
},
onHandshakeComplete = onInitiatorHandshakeComplete,
onData = onDataA,
isHandshakeAllowed = isHandshakeAllowed
)
val responderSession = SyncSocketSession(
"", responderKeyPair, responderInput, responderOutput,
onClose = {
onClose?.invoke(it)
onResponderClose?.invoke(it)
},
onHandshakeComplete = onResponderHandshakeComplete,
onData = onDataB,
isHandshakeAllowed = isHandshakeAllowed
)
return Pair(initiatorSession, responderSession)
}
@Test
fun handshake_WithValidPairingCode_Succeeds(): Unit = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val validPairingCode = "secret"
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
)
initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = validPairingCode)
responderSession.startAsResponder()
withTimeout(5.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
}
@Test
fun handshake_WithInvalidPairingCode_Fails() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val validPairingCode = "secret"
val invalidPairingCode = "wrong"
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val initiatorClosed = CompletableDeferred<Boolean>()
val responderClosed = CompletableDeferred<Boolean>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onInitiatorClose = {
initiatorClosed.complete(true)
},
onResponderClose = {
responderClosed.complete(true)
},
isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
)
initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = invalidPairingCode)
responderSession.startAsResponder()
withTimeout(100.seconds) {
initiatorClosed.await()
responderClosed.await()
}
assertFalse(handshakeInitiatorCompleted.isCompleted)
assertFalse(handshakeResponderCompleted.isCompleted)
}
@Test
fun handshake_WithoutPairingCodeWhenRequired_Fails() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val validPairingCode = "secret"
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val initiatorClosed = CompletableDeferred<Boolean>()
val responderClosed = CompletableDeferred<Boolean>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onInitiatorClose = {
initiatorClosed.complete(true)
},
onResponderClose = {
responderClosed.complete(true)
},
isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
)
initiatorSession.startAsInitiator(responderSession.localPublicKey) // No pairing code
responderSession.startAsResponder()
withTimeout(5.seconds) {
initiatorClosed.await()
responderClosed.await()
}
assertFalse(handshakeInitiatorCompleted.isCompleted)
assertFalse(handshakeResponderCompleted.isCompleted)
}
@Test
fun handshake_WithPairingCodeWhenNotRequired_Succeeds(): Unit = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val pairingCode = "unnecessary"
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
isHandshakeAllowed = { _, _, _, _, _ -> true } // Always allow
)
initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = pairingCode)
responderSession.startAsResponder()
withTimeout(10.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
}
@Test
fun sendAndReceive_SmallDataPacket_Succeeds() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val tcsDataReceived = CompletableDeferred<ByteArray>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onDataB = { _, opcode, subOpcode, data ->
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
val b = ByteArray(data.remaining())
data.get(b)
tcsDataReceived.complete(b)
}
}
)
initiatorSession.startAsInitiator(responderSession.localPublicKey)
responderSession.startAsResponder()
withTimeout(10.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
// Ensure both sessions are authorized
initiatorSession.authorizable = Authorized()
responderSession.authorizable = Authorized()
val smallData = byteArrayOf(1, 2, 3)
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(smallData))
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
assertArrayEquals(smallData, receivedData)
}
@Test
fun sendAndReceive_ExactlyMaximumPacketSize_Succeeds() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val tcsDataReceived = CompletableDeferred<ByteArray>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onDataB = { _, opcode, subOpcode, data ->
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
val b = ByteArray(data.remaining())
data.get(b)
tcsDataReceived.complete(b)
}
}
)
initiatorSession.startAsInitiator(responderSession.localPublicKey)
responderSession.startAsResponder()
withTimeout(10.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
// Ensure both sessions are authorized
initiatorSession.authorizable = Authorized()
responderSession.authorizable = Authorized()
val maxData = ByteArray(SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE).apply { Random.nextBytes(this) }
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(maxData))
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
assertArrayEquals(maxData, receivedData)
}
@Test
fun stream_LargeData_Succeeds() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val tcsDataReceived = CompletableDeferred<ByteArray>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onDataB = { _, opcode, subOpcode, data ->
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
val b = ByteArray(data.remaining())
data.get(b)
tcsDataReceived.complete(b)
}
}
)
initiatorSession.startAsInitiator(responderSession.localPublicKey)
responderSession.startAsResponder()
withTimeout(10.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
// Ensure both sessions are authorized
initiatorSession.authorizable = Authorized()
responderSession.authorizable = Authorized()
val largeData = ByteArray(2 * (SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE)).apply { Random.nextBytes(this) }
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(largeData))
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
assertArrayEquals(largeData, receivedData)
}
@Test
fun authorizedSession_CanSendData() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val tcsDataReceived = CompletableDeferred<ByteArray>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onDataB = { _, opcode, subOpcode, data ->
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
val b = ByteArray(data.remaining())
data.get(b)
tcsDataReceived.complete(b)
}
}
)
initiatorSession.startAsInitiator(responderSession.localPublicKey)
responderSession.startAsResponder()
withTimeout(10.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
// Authorize both sessions
initiatorSession.authorizable = Authorized()
responderSession.authorizable = Authorized()
val data = byteArrayOf(1, 2, 3)
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(data))
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
assertArrayEquals(data, receivedData)
}
@Test
fun unauthorizedSession_CannotSendData() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val tcsDataReceived = CompletableDeferred<ByteArray>()
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onDataB = { _, _, _, _ -> }
)
initiatorSession.startAsInitiator(responderSession.localPublicKey)
responderSession.startAsResponder()
withTimeout(10.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
// Authorize initiator but not responder
initiatorSession.authorizable = Authorized()
responderSession.authorizable = Unauthorized()
val data = byteArrayOf(1, 2, 3)
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(data))
delay(1.seconds)
assertFalse(tcsDataReceived.isCompleted)
}
@Test
fun directHandshake_WithValidAppId_Succeeds() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val allowedAppId = 1234u
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val responderIsHandshakeAllowed = { linkType: LinkType, _: SyncSocketSession, _: String, _: String?, appId: UInt ->
linkType == LinkType.Direct && appId == allowedAppId
}
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
isHandshakeAllowed = responderIsHandshakeAllowed
)
initiatorSession.startAsInitiator(responderSession.localPublicKey, appId = allowedAppId)
responderSession.startAsResponder()
withTimeout(5.seconds) {
handshakeInitiatorCompleted.await()
handshakeResponderCompleted.await()
}
assertNotNull(initiatorSession.remotePublicKey)
assertNotNull(responderSession.remotePublicKey)
}
@Test
fun directHandshake_WithInvalidAppId_Fails() = runBlocking {
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
val initiatorKeyPair = generateKeyPair()
val responderKeyPair = generateKeyPair()
val allowedAppId = 1234u
val invalidAppId = 5678u
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
val initiatorClosed = CompletableDeferred<Boolean>()
val responderClosed = CompletableDeferred<Boolean>()
val responderIsHandshakeAllowed = { linkType: LinkType, _: SyncSocketSession, _: String, _: String?, appId: UInt ->
linkType == LinkType.Direct && appId == allowedAppId
}
val (initiatorSession, responderSession) = createSessions(
initiatorInput, initiatorOutput, responderInput, responderOutput,
initiatorKeyPair, responderKeyPair,
{ handshakeInitiatorCompleted.complete(true) },
{ handshakeResponderCompleted.complete(true) },
onInitiatorClose = {
initiatorClosed.complete(true)
},
onResponderClose = {
responderClosed.complete(true)
},
isHandshakeAllowed = responderIsHandshakeAllowed
)
initiatorSession.startAsInitiator(responderSession.localPublicKey, appId = invalidAppId)
responderSession.startAsResponder()
withTimeout(5.seconds) {
initiatorClosed.await()
responderClosed.await()
}
assertFalse(handshakeInitiatorCompleted.isCompleted)
assertFalse(handshakeResponderCompleted.isCompleted)
}
}
class Authorized : IAuthorizable {
override val isAuthorized: Boolean = true
}
class Unauthorized : IAuthorizable {
override val isAuthorized: Boolean = false
}
+2 -2
View File
@@ -55,7 +55,7 @@
<activity <activity
android:name=".activities.MainActivity" android:name=".activities.MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
android:exported="true" android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar" android:theme="@style/Theme.FutoVideo.NoActionBar"
android:launchMode="singleInstance" android:launchMode="singleInstance"
@@ -239,4 +239,4 @@
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBar" />
</application> </application>
</manifest> </manifest>
+40 -5
View File
@@ -32,7 +32,8 @@ let Type = {
Text: { Text: {
RAW: 0, RAW: 0,
HTML: 1, HTML: 1,
MARKUP: 2 MARKUP: 2,
CODE: 3
}, },
Chapter: { Chapter: {
NORMAL: 0, NORMAL: 0,
@@ -291,15 +292,39 @@ class PlatformPostDetails extends PlatformPost {
} }
} }
class PlatformArticleDetails extends PlatformContent { class PlatformWeb extends PlatformContent {
constructor(obj) {
super(obj, 7);
obj = obj ?? {};
this.plugin_type = "PlatformWeb";
}
}
class PlatformWebDetails extends PlatformWeb {
constructor(obj) {
super(obj, 7);
obj = obj ?? {};
this.plugin_type = "PlatformWebDetails";
this.html = obj.html;
}
}
class PlatformArticle extends PlatformContent {
constructor(obj) {
super(obj, 3);
obj = obj ?? {};
this.plugin_type = "PlatformArticle";
this.rating = obj.rating ?? new RatingLikes(-1);
this.summary = obj.summary ?? "";
this.thumbnails = obj.thumbnails ?? new Thumbnails([]);
}
}
class PlatformArticleDetails extends PlatformArticle {
constructor(obj) { constructor(obj) {
super(obj, 3); super(obj, 3);
obj = obj ?? {}; obj = obj ?? {};
this.plugin_type = "PlatformArticleDetails"; this.plugin_type = "PlatformArticleDetails";
this.rating = obj.rating ?? new RatingLikes(-1); this.rating = obj.rating ?? new RatingLikes(-1);
this.summary = obj.summary ?? "";
this.segments = obj.segments ?? []; this.segments = obj.segments ?? [];
this.thumbnails = obj.thumbnails ?? new Thumbnails([]);
} }
} }
class ArticleSegment { class ArticleSegment {
@@ -315,9 +340,17 @@ class ArticleTextSegment extends ArticleSegment {
} }
} }
class ArticleImagesSegment extends ArticleSegment { class ArticleImagesSegment extends ArticleSegment {
constructor(images) { constructor(images, caption) {
super(2); super(2);
this.images = images; this.images = images;
this.caption = caption;
}
}
class ArticleHeaderSegment extends ArticleSegment {
constructor(content, level) {
super(3);
this.level = level;
this.content = content;
} }
} }
class ArticleNestedSegment extends ArticleSegment { class ArticleNestedSegment extends ArticleSegment {
@@ -595,6 +628,8 @@ class PlatformComment {
this.date = obj.date ?? 0; this.date = obj.date ?? 0;
this.replyCount = obj.replyCount ?? 0; this.replyCount = obj.replyCount ?? 0;
this.context = obj.context ?? {}; this.context = obj.context ?? {};
if(obj.getReplies)
this.getReplies = obj.getReplies;
} }
} }
@@ -14,7 +14,6 @@ import java.text.DecimalFormat
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.math.roundToLong import kotlin.math.roundToLong
@@ -376,14 +375,19 @@ private val slds = hashSetOf(".com.ac", ".net.ac", ".gov.ac", ".org.ac", ".mil.a
fun String.matchesDomain(queryDomain: String): Boolean { fun String.matchesDomain(queryDomain: String): Boolean {
if(queryDomain.startsWith(".")) { if(queryDomain.startsWith(".")) {
val parts = this.lowercase().split(".");
val parts = queryDomain.lowercase().split("."); val queryParts = queryDomain.lowercase().trimStart("."[0]).split(".");
if(parts.size < 3) if(queryParts.size < 2)
throw IllegalStateException("Illegal use of wildcards on First-Level-Domain (" + queryDomain + ")"); throw IllegalStateException("Illegal use of wildcards on First-Level-Domain (" + queryDomain + ")");
if(parts.size >= 3){ else {
val isSLD = slds.contains("." + parts[parts.size - 2] + "." + parts[parts.size - 1]); val possibleDomain = "." + queryParts.joinToString(".");
if(isSLD && parts.size <= 3) if(slds.contains(possibleDomain))
throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")"); throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")");
/*
val isSLD = slds.contains("." + queryParts[queryParts.size - 2] + "." + queryParts[queryParts.size - 1]);
if(isSLD && queryParts.size <= 3)
throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")");
*/
} }
//TODO: Should be safe, but double verify if can't be exploited //TODO: Should be safe, but double verify if can't be exploited
@@ -395,9 +399,11 @@ fun String.matchesDomain(queryDomain: String): Boolean {
fun String.getSubdomainWildcardQuery(): String { fun String.getSubdomainWildcardQuery(): String {
val domainParts = this.split("."); val domainParts = this.split(".");
val sldParts = "." + domainParts[domainParts.size - 2].lowercase() + "." + domainParts[domainParts.size - 1].lowercase(); var wildcardDomain = if(domainParts.size > 2)
if(slds.contains(sldParts)) "." + domainParts.drop(1).joinToString(".")
return "." + domainParts.drop(domainParts.size - 3).joinToString(".");
else else
return "." + domainParts.drop(domainParts.size - 2).joinToString("."); "." + domainParts.joinToString(".");
if(slds.contains(wildcardDomain.lowercase()))
"." + domainParts.joinToString(".");
return wildcardDomain;
} }
@@ -216,8 +216,13 @@ private fun ByteArray.toInetAddress(): InetAddress {
return InetAddress.getByAddress(this); return InetAddress.getByAddress(this);
} }
fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? { fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? {
val timeout = 2000 ensureNotMainThread()
val timeout = 10000
val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance<Inet4Address>() else attemptAddresses;
if(addresses.isEmpty())
throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})");
if (addresses.isEmpty()) { if (addresses.isEmpty()) {
return null; return null;
@@ -236,8 +241,11 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
return null; return null;
} }
val sortedAddresses: List<InetAddress> = addresses
.sortedBy { addr -> addressScore(addr) }
val sockets: ArrayList<Socket> = arrayListOf(); val sockets: ArrayList<Socket> = arrayListOf();
for (i in addresses.indices) { for (i in sortedAddresses.indices) {
sockets.add(Socket()); sockets.add(Socket());
} }
@@ -245,7 +253,7 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
var connectedSocket: Socket? = null; var connectedSocket: Socket? = null;
val threads: ArrayList<Thread> = arrayListOf(); val threads: ArrayList<Thread> = arrayListOf();
for (i in 0 until sockets.size) { for (i in 0 until sockets.size) {
val address = addresses[i]; val address = sortedAddresses[i];
val socket = sockets[i]; val socket = sockets[i];
val thread = Thread { val thread = Thread {
try { try {
@@ -1,13 +1,13 @@
package com.futo.platformplayer package com.futo.platformplayer
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.AnnouncementType import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.StateAnnouncement import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.polycentric.core.ProcessHandle import com.futo.polycentric.core.ProcessHandle
import com.futo.polycentric.core.Store import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.base64UrlToByteArray
import userpackage.Protocol import userpackage.Protocol
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.min import kotlin.math.min
@@ -40,33 +40,25 @@ fun Protocol.ImageBundle?.selectHighestResolutionImage(): Protocol.ImageManifest
return imageManifestsList.filter { it.byteCount < maximumFileSize }.maxByOrNull { abs(it.width * it.height) } return imageManifestsList.filter { it.byteCount < maximumFileSize }.maxByOrNull { abs(it.width * it.height) }
} }
fun String.getDataLinkFromUrl(): Protocol.URLInfoDataLink? {
val urlData = if (this.startsWith("polycentric://")) {
this.substring("polycentric://".length)
} else this;
val urlBytes = urlData.base64UrlToByteArray();
val urlInfo = Protocol.URLInfo.parseFrom(urlBytes);
if (urlInfo.urlType != 4L) {
return null
}
val dataLink = Protocol.URLInfoDataLink.parseFrom(urlInfo.body);
return dataLink
}
fun Protocol.Claim.resolveChannelUrl(): String? { fun Protocol.Claim.resolveChannelUrl(): String? {
return StatePlatform.instance.resolveChannelUrlByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) }) return StatePlatform.instance.resolveChannelUrlByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
} }
fun Protocol.Claim.resolveChannelUrls(): List<String> { fun Protocol.Claim.resolveChannelUrls(): List<String> {
return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) }) return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
}
suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() {
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(system))
if (!systemState.servers.contains(PolycentricCache.SERVER)) {
Logger.w("Backfill", "Polycentric prod server not added, adding it.")
addServer(PolycentricCache.SERVER)
}
val exceptions = fullyBackfillServers()
for (pair in exceptions) {
val server = pair.key
val exception = pair.value
StateAnnouncement.instance.registerAnnouncement(
"backfill-failed",
"Backfill failed",
"Failed to backfill server $server. $exception",
AnnouncementType.SESSION_RECURRING
);
Logger.e("Backfill", "Failed to backfill server $server.", exception)
}
} }
@@ -7,6 +7,9 @@ import java.net.InetAddress
import java.net.URI import java.net.URI
import java.net.URISyntaxException import java.net.URISyntaxException
import java.net.URLEncoder import java.net.URLEncoder
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
//Syntax sugaring //Syntax sugaring
inline fun <reified T> Any.assume(): T?{ inline fun <reified T> Any.assume(): T?{
@@ -33,13 +36,37 @@ fun Boolean?.toYesNo(): String {
fun InetAddress?.toUrlAddress(): String { fun InetAddress?.toUrlAddress(): String {
return when (this) { return when (this) {
is Inet6Address -> { is Inet6Address -> {
"[${hostAddress}]" val hostAddr = this.hostAddress ?: throw Exception("Invalid address: hostAddress is null")
val index = hostAddr.indexOf('%')
if (index != -1) {
val addrPart = hostAddr.substring(0, index)
val scopeId = hostAddr.substring(index + 1)
"[${addrPart}%25${scopeId}]" // %25 is URL-encoded '%'
} else {
"[$hostAddr]"
}
} }
is Inet4Address -> { is Inet4Address -> {
hostAddress this.hostAddress ?: throw Exception("Invalid address: hostAddress is null")
} }
else -> { else -> {
throw Exception("Invalid address type") throw Exception("Invalid address type")
} }
} }
}
fun Long?.sToOffsetDateTimeUTC(): OffsetDateTime {
if (this == null || this < 0)
return OffsetDateTime.MIN
if(this > 4070912400)
return OffsetDateTime.MAX;
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(this), ZoneOffset.UTC)
}
fun Long?.msToOffsetDateTimeUTC(): OffsetDateTime {
if (this == null || this < 0)
return OffsetDateTime.MIN
if(this > 4070912400)
return OffsetDateTime.MAX;
return OffsetDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneOffset.UTC)
} }
@@ -29,6 +29,7 @@ import com.futo.platformplayer.states.StateUpdate
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.FragmentedStorageFileJson import com.futo.platformplayer.stores.FragmentedStorageFileJson
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.fields.AdvancedField
import com.futo.platformplayer.views.fields.DropdownFieldOptionsId import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormField import com.futo.platformplayer.views.fields.FormField
@@ -175,6 +176,10 @@ class Settings : FragmentedStorageFileJson() {
} }
}*/ }*/
@FormField(R.string.advanced_settings, FieldForm.TOGGLE, R.string.advanced_settings_description, -1, "advancedSettings")
var advancedSettings: Boolean = false;
@FormField(R.string.language, "group", -1, 0) @FormField(R.string.language, "group", -1, 0)
var language = LanguageSettings(); var language = LanguageSettings();
@Serializable @Serializable
@@ -205,7 +210,7 @@ class Settings : FragmentedStorageFileJson() {
var home = HomeSettings(); var home = HomeSettings();
@Serializable @Serializable
class HomeSettings { class HomeSettings {
@FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 5) @FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 3)
@DropdownFieldOptionsId(R.array.feed_style) @DropdownFieldOptionsId(R.array.feed_style)
var homeFeedStyle: Int = 1; var homeFeedStyle: Int = 1;
@@ -216,10 +221,16 @@ class Settings : FragmentedStorageFileJson() {
return FeedStyle.THUMBNAIL; return FeedStyle.THUMBNAIL;
} }
@FormField(R.string.show_home_filters, FieldForm.TOGGLE, R.string.show_home_filters_description, 4)
var showHomeFilters: Boolean = true;
@FormField(R.string.show_home_filters_plugin_names, FieldForm.TOGGLE, R.string.show_home_filters_plugin_names_description, 5)
var showHomeFiltersPluginNames: Boolean = false;
@AdvancedField
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6) @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
var previewFeedItems: Boolean = true; var previewFeedItems: Boolean = true;
@AdvancedField
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6) @FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = true; var progressBar: Boolean = true;
@@ -248,9 +259,11 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.feed_style) @DropdownFieldOptionsId(R.array.feed_style)
var searchFeedStyle: Int = 1; var searchFeedStyle: Int = 1;
@AdvancedField
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5) @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
var previewFeedItems: Boolean = true; var previewFeedItems: Boolean = true;
@AdvancedField
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6) @FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = true; var progressBar: Boolean = true;
@@ -272,6 +285,7 @@ class Settings : FragmentedStorageFileJson() {
@Serializable @Serializable
class ChannelSettings { class ChannelSettings {
@AdvancedField
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6) @FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = true; var progressBar: Boolean = true;
} }
@@ -294,16 +308,23 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.show_subscription_group, FieldForm.TOGGLE, R.string.show_subscription_group_description, 5) @FormField(R.string.show_subscription_group, FieldForm.TOGGLE, R.string.show_subscription_group_description, 5)
var showSubscriptionGroups: Boolean = true; var showSubscriptionGroups: Boolean = true;
@FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6)
var useSubscriptionExchange: Boolean = false;
@AdvancedField
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6) @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
var previewFeedItems: Boolean = true; var previewFeedItems: Boolean = true;
@AdvancedField
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 7) @FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 7)
var progressBar: Boolean = true; var progressBar: Boolean = true;
@AdvancedField
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 8) @FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 8)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var fetchOnAppBoot: Boolean = true; var fetchOnAppBoot: Boolean = true;
@AdvancedField
@FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 9) @FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 9)
var fetchOnTabOpen: Boolean = true; var fetchOnTabOpen: Boolean = true;
@@ -334,13 +355,16 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 12) @FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 12)
var showWatchMetrics: Boolean = false; var showWatchMetrics: Boolean = false;
@AdvancedField
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 13) @FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 13)
var allowPlaytimeTracking: Boolean = true; var allowPlaytimeTracking: Boolean = true;
@AdvancedField
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 14) @FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 14)
var alwaysReloadFromCache: Boolean = false; var alwaysReloadFromCache: Boolean = false;
@AdvancedField
@FormField(R.string.peek_channel_contents, FieldForm.TOGGLE, R.string.peek_channel_contents_description, 15) @FormField(R.string.peek_channel_contents, FieldForm.TOGGLE, R.string.peek_channel_contents_description, 15)
var peekChannelContents: Boolean = false; var peekChannelContents: Boolean = false;
@@ -356,7 +380,7 @@ class Settings : FragmentedStorageFileJson() {
var playback = PlaybackSettings(); var playback = PlaybackSettings();
@Serializable @Serializable
class PlaybackSettings { class PlaybackSettings {
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, -1) @FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, -2)
@DropdownFieldOptionsId(R.array.audio_languages) @DropdownFieldOptionsId(R.array.audio_languages)
var primaryLanguage: Int = 0; var primaryLanguage: Int = 0;
@@ -380,6 +404,8 @@ class Settings : FragmentedStorageFileJson() {
else -> null else -> null
} }
} }
@FormField(R.string.prefer_original_audio, FieldForm.TOGGLE, R.string.prefer_original_audio_description, -1)
var preferOriginalAudio: Boolean = true;
//= context.resources.getStringArray(R.array.audio_languages)[primaryLanguage]; //= context.resources.getStringArray(R.array.audio_languages)[primaryLanguage];
@@ -415,9 +441,11 @@ class Settings : FragmentedStorageFileJson() {
var preferredPreviewQuality: Int = 5; var preferredPreviewQuality: Int = 5;
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality); fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
@AdvancedField
@FormField(R.string.simplify_sources, FieldForm.TOGGLE, R.string.simplify_sources_description, 4) @FormField(R.string.simplify_sources, FieldForm.TOGGLE, R.string.simplify_sources_description, 4)
var simplifySources: Boolean = true; var simplifySources: Boolean = true;
@AdvancedField
@FormField(R.string.always_allow_reverse_landscape_auto_rotate, FieldForm.TOGGLE, R.string.always_allow_reverse_landscape_auto_rotate_description, 5) @FormField(R.string.always_allow_reverse_landscape_auto_rotate, FieldForm.TOGGLE, R.string.always_allow_reverse_landscape_auto_rotate_description, 5)
var alwaysAllowReverseLandscapeAutoRotate: Boolean = true var alwaysAllowReverseLandscapeAutoRotate: Boolean = true
@@ -428,6 +456,7 @@ class Settings : FragmentedStorageFileJson() {
fun isBackgroundContinue() = backgroundPlay == 1; fun isBackgroundContinue() = backgroundPlay == 1;
fun isBackgroundPictureInPicture() = backgroundPlay == 2; fun isBackgroundPictureInPicture() = backgroundPlay == 2;
@AdvancedField
@FormField(R.string.resume_after_preview, FieldForm.DROPDOWN, R.string.when_watching_a_video_in_preview_mode_resume_at_the_position_when_opening_the_video_code, 7) @FormField(R.string.resume_after_preview, FieldForm.DROPDOWN, R.string.when_watching_a_video_in_preview_mode_resume_at_the_position_when_opening_the_video_code, 7)
@DropdownFieldOptionsId(R.array.resume_after_preview) @DropdownFieldOptionsId(R.array.resume_after_preview)
var resumeAfterPreview: Int = 1; var resumeAfterPreview: Int = 1;
@@ -454,14 +483,10 @@ class Settings : FragmentedStorageFileJson() {
}; };
} }
@AdvancedField
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 9) @FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 9)
var useLiveChatWindow: Boolean = true; var useLiveChatWindow: Boolean = true;
@FormField(R.string.background_switch_audio, FieldForm.TOGGLE, R.string.background_switch_audio_description, 10)
var backgroundSwitchToAudio: Boolean = true;
@FormField(R.string.restart_after_audio_focus_loss, FieldForm.DROPDOWN, R.string.restart_playback_when_gaining_audio_focus_after_a_loss, 11) @FormField(R.string.restart_after_audio_focus_loss, FieldForm.DROPDOWN, R.string.restart_playback_when_gaining_audio_focus_after_a_loss, 11)
@DropdownFieldOptionsId(R.array.restart_playback_after_loss) @DropdownFieldOptionsId(R.array.restart_playback_after_loss)
var restartPlaybackAfterLoss: Int = 1; var restartPlaybackAfterLoss: Int = 1;
@@ -487,8 +512,78 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.autoplay, FieldForm.TOGGLE, R.string.autoplay, 21) @FormField(R.string.autoplay, FieldForm.TOGGLE, R.string.autoplay, 21)
var autoplay: Boolean = false; var autoplay: Boolean = false;
@AdvancedField
@FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22) @FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22)
var deleteFromWatchLaterAuto: Boolean = true; var deleteFromWatchLaterAuto: Boolean = true;
@FormField(R.string.seek_offset, FieldForm.DROPDOWN, R.string.seek_offset_description, 23)
@DropdownFieldOptionsId(R.array.seek_offset_duration)
var seekOffset: Int = 2;
fun getSeekOffset(): Long {
return when(seekOffset) {
0 -> 3_000L;
1 -> 5_000L;
2 -> 10_000L;
3 -> 20_000L;
4 -> 30_000L;
5 -> 60_000L;
else -> 10_000L;
}
}
@FormField(R.string.min_playback_speed, FieldForm.DROPDOWN, R.string.min_playback_speed_description, 25)
@DropdownFieldOptionsId(R.array.min_playback_speed)
var minimumPlaybackSpeed: Int = 0;
@FormField(R.string.max_playback_speed, FieldForm.DROPDOWN, R.string.max_playback_speed_description, 26)
@DropdownFieldOptionsId(R.array.max_playback_speed)
var maximumPlaybackSpeed: Int = 2;
@FormField(R.string.step_playback_speed, FieldForm.DROPDOWN, R.string.step_playback_speed_description, 26)
@DropdownFieldOptionsId(R.array.step_playback_speed)
var stepPlaybackSpeed: Int = 1;
fun getPlaybackSpeedStep(): Double {
return when(stepPlaybackSpeed) {
0 -> 0.05
1 -> 0.1
2 -> 0.25
else -> 0.1;
}
}
fun getPlaybackSpeeds(): List<Double> {
val playbackSpeeds = mutableListOf<Double>();
playbackSpeeds.add(1.0);
val minSpeed = when(minimumPlaybackSpeed) {
0 -> 0.25
1 -> 0.5
2 -> 1.0
else -> 0.25
}
val maxSpeed = when(maximumPlaybackSpeed) {
0 -> 2.0
1 -> 2.25
2 -> 3.0
3 -> 4.0
4 -> 5.0
else -> 2.25;
}
var testSpeed = 1.0;
while(testSpeed > minSpeed) {
val nextSpeed = (testSpeed - 0.25) as Double;
testSpeed = Math.max(nextSpeed, minSpeed);
playbackSpeeds.add(testSpeed);
}
testSpeed = 1.0;
while(testSpeed < maxSpeed) {
val nextSpeed = (testSpeed + if(testSpeed < 2) 0.25 else 1.0) as Double;
testSpeed = Math.min(nextSpeed, maxSpeed);
playbackSpeeds.add(testSpeed);
}
playbackSpeeds.sort();
return playbackSpeeds;
}
} }
@FormField(R.string.comments, "group", R.string.comments_description, 6) @FormField(R.string.comments, "group", R.string.comments_description, 6)
@@ -504,6 +599,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.default_recommendations, FieldForm.TOGGLE, R.string.default_recommendations_description, 0) @FormField(R.string.default_recommendations, FieldForm.TOGGLE, R.string.default_recommendations_description, 0)
var recommendationsDefault: Boolean = false; var recommendationsDefault: Boolean = false;
@AdvancedField
@FormField(R.string.hide_recommendations, FieldForm.TOGGLE, R.string.hide_recommendations_description, 0) @FormField(R.string.hide_recommendations, FieldForm.TOGGLE, R.string.hide_recommendations_description, 0)
var hideRecommendations: Boolean = false; var hideRecommendations: Boolean = false;
@@ -540,10 +636,12 @@ class Settings : FragmentedStorageFileJson() {
var preferredAudioQuality: Int = 1; var preferredAudioQuality: Int = 1;
fun isHighBitrateDefault(): Boolean = preferredAudioQuality > 0; fun isHighBitrateDefault(): Boolean = preferredAudioQuality > 0;
@AdvancedField
@FormField(R.string.byte_range_download, FieldForm.TOGGLE, R.string.attempt_to_utilize_byte_ranges, 4) @FormField(R.string.byte_range_download, FieldForm.TOGGLE, R.string.attempt_to_utilize_byte_ranges, 4)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var byteRangeDownload: Boolean = true; var byteRangeDownload: Boolean = true;
@AdvancedField
@FormField(R.string.byte_range_concurrency, FieldForm.DROPDOWN, R.string.number_of_concurrent_threads_to_multiply_download_speeds_from_throttled_sources, 5) @FormField(R.string.byte_range_concurrency, FieldForm.DROPDOWN, R.string.number_of_concurrent_threads_to_multiply_download_speeds_from_throttled_sources, 5)
@DropdownFieldOptionsId(R.array.thread_count) @DropdownFieldOptionsId(R.array.thread_count)
var byteRangeConcurrency: Int = 3; var byteRangeConcurrency: Int = 3;
@@ -573,10 +671,21 @@ class Settings : FragmentedStorageFileJson() {
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var keepScreenOn: Boolean = true; var keepScreenOn: Boolean = true;
@FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 1) @AdvancedField
@FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 3)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var alwaysProxyRequests: Boolean = false; var alwaysProxyRequests: Boolean = false;
@AdvancedField
@FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4)
@Serializable(with = FlexibleBooleanSerializer::class)
var allowIpv6: Boolean = true;
@AdvancedField
@FormField(R.string.allow_ipv4, FieldForm.TOGGLE, R.string.allow_ipv4_description, 5)
@Serializable(with = FlexibleBooleanSerializer::class)
var allowLinkLocalIpv4: Boolean = false;
/*TODO: Should we have a different casting quality? /*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@DropdownFieldOptionsId(R.array.preferred_quality_array) @DropdownFieldOptionsId(R.array.preferred_quality_array)
@@ -644,9 +753,11 @@ class Settings : FragmentedStorageFileJson() {
@Serializable @Serializable
class Plugins { class Plugins {
@AdvancedField
@FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1) @FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1)
var checkDisabledPluginsForUpdates: Boolean = false; var checkDisabledPluginsForUpdates: Boolean = false;
@AdvancedField
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0) @FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
var clearCookiesOnLogout: Boolean = true; var clearCookiesOnLogout: Boolean = true;
@@ -847,7 +958,23 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.payment_status, FieldForm.READONLYTEXT, -1, 1) @FormField(R.string.payment_status, FieldForm.READONLYTEXT, -1, 1)
val paymentStatus: String get() = SettingsActivity.getActivity()?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown"; val paymentStatus: String get() = SettingsActivity.getActivity()?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown";
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 2) @FormField(R.string.license_status, FieldForm.BUTTON, R.string.view_license_status, 2)
fun viewLicenseStatus() {
SettingsActivity.getActivity()?.let {
try {
if (StatePayment.instance.hasPaid) {
val paymentKey = StatePayment.instance.getPaymentKey()
UIDialogs.showDialogOk(it, R.drawable.ic_paid, "License activated\n" + paymentKey.first)
} else {
UIDialogs.showDialogOk(it, R.drawable.ic_paid, "No license activated")
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to show license status dialog", e)
}
}
}
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 3)
fun clearPayment() { fun clearPayment() {
SettingsActivity.getActivity()?.let { context -> SettingsActivity.getActivity()?.let { context ->
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete your license?", { UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete your license?", {
@@ -865,8 +992,10 @@ class Settings : FragmentedStorageFileJson() {
var other = Other(); var other = Other();
@Serializable @Serializable
class Other { class Other {
@AdvancedField
@FormField(R.string.playlist_delete_confirmation, FieldForm.TOGGLE, R.string.playlist_delete_confirmation_description, 2) @FormField(R.string.playlist_delete_confirmation, FieldForm.TOGGLE, R.string.playlist_delete_confirmation_description, 2)
var playlistDeleteConfirmation: Boolean = true; var playlistDeleteConfirmation: Boolean = true;
@AdvancedField
@FormField(R.string.playlist_allow_dups, FieldForm.TOGGLE, R.string.playlist_allow_dups_description, 3) @FormField(R.string.playlist_allow_dups, FieldForm.TOGGLE, R.string.playlist_allow_dups_description, 3)
var playlistAllowDups: Boolean = true; var playlistAllowDups: Boolean = true;
@@ -911,7 +1040,7 @@ class Settings : FragmentedStorageFileJson() {
@Serializable @Serializable
class Synchronization { class Synchronization {
@FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enabled_description, 1) @FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enabled_description, 1)
var enabled: Boolean = true; var enabled: Boolean = false;
@FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1) @FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1)
var broadcast: Boolean = false; var broadcast: Boolean = false;
@@ -921,6 +1050,21 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.connect_last, FieldForm.TOGGLE, R.string.connect_last_description, 3) @FormField(R.string.connect_last, FieldForm.TOGGLE, R.string.connect_last_description, 3)
var connectLast: Boolean = true; var connectLast: Boolean = true;
@FormField(R.string.discover_through_relay, FieldForm.TOGGLE, R.string.discover_through_relay_description, 3)
var discoverThroughRelay: Boolean = true;
@FormField(R.string.pair_through_relay, FieldForm.TOGGLE, R.string.pair_through_relay_description, 3)
var pairThroughRelay: Boolean = true;
@FormField(R.string.connect_through_relay, FieldForm.TOGGLE, R.string.connect_through_relay_description, 3)
var connectThroughRelay: Boolean = true;
@FormField(R.string.connect_local_direct_through_relay, FieldForm.TOGGLE, R.string.connect_local_direct_through_relay_description, 3)
var connectLocalDirectThroughRelay: Boolean = true;
@FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3)
var localConnections: Boolean = true;
} }
@FormField(R.string.info, FieldForm.GROUP, -1, 21) @FormField(R.string.info, FieldForm.GROUP, -1, 21)
@@ -989,4 +1133,4 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
//endregion //endregion
} }
@@ -5,6 +5,7 @@ import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.Animatable
import android.net.Uri import android.net.Uri
import android.text.Layout import android.text.Layout
import android.text.method.ScrollingMovementMethod import android.text.method.ScrollingMovementMethod
@@ -199,16 +200,21 @@ class UIDialogs {
dialog.show(); dialog.show();
} }
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action) { fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
return showDialog(context, icon, false, text, textDetails, code, defaultCloseAction, *actions);
}
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
val builder = AlertDialog.Builder(context); val builder = AlertDialog.Builder(context);
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null); val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
builder.setView(view); builder.setView(view);
builder.setCancelable(defaultCloseAction > -2);
val dialog = builder.create(); val dialog = builder.create();
registerDialogOpened(dialog); registerDialogOpened(dialog);
view.findViewById<ImageView>(R.id.dialog_icon).apply { view.findViewById<ImageView>(R.id.dialog_icon).apply {
this.setImageResource(icon); this.setImageResource(icon);
if(animated)
this.drawable.assume<Animatable, Unit> { it.start() };
} }
view.findViewById<TextView>(R.id.dialog_text).apply { view.findViewById<TextView>(R.id.dialog_text).apply {
this.text = text; this.text = text;
@@ -275,6 +281,7 @@ class UIDialogs {
registerDialogClosed(dialog); registerDialogClosed(dialog);
} }
dialog.show(); dialog.show();
return dialog;
} }
fun showGeneralErrorDialog(context: Context, msg: String, ex: Throwable? = null, button: String = "Ok", onOk: (()->Unit)? = null) { fun showGeneralErrorDialog(context: Context, msg: String, ex: Throwable? = null, button: String = "Ok", onOk: (()->Unit)? = null) {
@@ -312,7 +319,11 @@ class UIDialogs {
closeAction?.invoke() closeAction?.invoke()
}, UIDialogs.ActionStyle.NONE), }, UIDialogs.ActionStyle.NONE),
UIDialogs.Action(context.getString(R.string.retry), { UIDialogs.Action(context.getString(R.string.retry), {
retryAction?.invoke(); try {
retryAction?.invoke();
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception retrying", e)
}
}, UIDialogs.ActionStyle.PRIMARY) }, UIDialogs.ActionStyle.PRIMARY)
); );
else else
@@ -326,7 +337,11 @@ class UIDialogs {
closeAction?.invoke() closeAction?.invoke()
}, UIDialogs.ActionStyle.NONE), }, UIDialogs.ActionStyle.NONE),
UIDialogs.Action(context.getString(R.string.retry), { UIDialogs.Action(context.getString(R.string.retry), {
retryAction?.invoke(); try {
retryAction?.invoke();
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception retrying", e)
}
}, UIDialogs.ActionStyle.PRIMARY) }, UIDialogs.ActionStyle.PRIMARY)
); );
} }
@@ -4,8 +4,14 @@ import android.app.NotificationManager
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity import com.futo.platformplayer.activities.SettingsActivity
@@ -37,6 +43,9 @@ import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.parsers.HLS.MediaRendition
import com.futo.platformplayer.parsers.HLS.StreamInfo
import com.futo.platformplayer.parsers.HLS.VariantPlaylistReference
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StateHistory
@@ -63,6 +72,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream
import androidx.core.net.toUri
class UISlideOverlays { class UISlideOverlays {
companion object { companion object {
@@ -299,6 +310,7 @@ class UISlideOverlays {
} }
@OptIn(UnstableApi::class)
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay { fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
val items = arrayListOf<View>(LoaderView(container.context)) val items = arrayListOf<View>(LoaderView(container.context))
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items) val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
@@ -310,6 +322,8 @@ class UISlideOverlays {
val masterPlaylistContent = masterPlaylistResponse.body?.string() val masterPlaylistContent = masterPlaylistResponse.body?.string()
?: throw Exception("Master playlist content is empty") ?: throw Exception("Master playlist content is empty")
val resolvedPlaylistUrl = masterPlaylistResponse.url
val videoButtons = arrayListOf<SlideUpMenuItem>() val videoButtons = arrayListOf<SlideUpMenuItem>()
val audioButtons = arrayListOf<SlideUpMenuItem>() val audioButtons = arrayListOf<SlideUpMenuItem>()
//TODO: Implement subtitles //TODO: Implement subtitles
@@ -322,55 +336,103 @@ class UISlideOverlays {
val masterPlaylist: HLS.MasterPlaylist val masterPlaylist: HLS.MasterPlaylist
try { try {
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray())
val playlist = DefaultHlsPlaylistParserFactory().createPlaylistParser()
.parse(sourceUrl.toUri(), inputStream)
masterPlaylist.getAudioSources().forEach { it -> if (playlist is HlsMediaPlaylist) {
if (source is IHLSManifestAudioSource) {
val variant = HLS.mediaRenditionToVariant(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null))!!
val estSize = VideoHelper.estimateSourceSize(it); val estSize = VideoHelper.estimateSourceSize(variant);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else ""; val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
audioButtons.add(SlideUpMenuItem( audioButtons.add(SlideUpMenuItem(
container.context, container.context,
R.drawable.ic_music, R.drawable.ic_music,
it.name, variant.name,
listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), listOf(variant.language, variant.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
(prefix + it.codec).trim(), (prefix + variant.codec).trim(),
tag = it, tag = variant,
call = { call = {
selectedAudioVariant = it selectedAudioVariant = variant
slideUpMenuOverlay.selectOption(audioButtons, it) slideUpMenuOverlay.selectOption(audioButtons, variant)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
},
invokeParent = false
))
}
/*masterPlaylist.getSubtitleSources().forEach { it ->
subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
selectedSubtitleVariant = it
slideUpMenuOverlay.selectOption(subtitleButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}, false))
}*/
masterPlaylist.getVideoSources().forEach {
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
videoButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
it.name,
"${it.width}x${it.height}",
(prefix + it.codec).trim(),
tag = it,
call = {
selectedVideoVariant = it
slideUpMenuOverlay.selectOption(videoButtons, it)
if (audioButtons.isEmpty()){
slideUpMenuOverlay.setOk(container.context.getString(R.string.download)) slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
} },
}, invokeParent = false
invokeParent = false ))
)) } else {
val variant = HLS.variantReferenceToVariant(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null)))
val estSize = VideoHelper.estimateSourceSize(variant);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
videoButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
variant.name,
"${variant.width}x${variant.height}",
(prefix + variant.codec).trim(),
tag = variant,
call = {
selectedVideoVariant = variant
slideUpMenuOverlay.selectOption(videoButtons, variant)
if (audioButtons.isEmpty()){
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}
},
invokeParent = false
))
}
} else if (playlist is HlsMultivariantPlaylist) {
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, resolvedPlaylistUrl)
masterPlaylist.getAudioSources().forEach { it ->
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
audioButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_music,
it.name,
listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
(prefix + it.codec).trim(),
tag = it,
call = {
selectedAudioVariant = it
slideUpMenuOverlay.selectOption(audioButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
},
invokeParent = false
))
}
/*masterPlaylist.getSubtitleSources().forEach { it ->
subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
selectedSubtitleVariant = it
slideUpMenuOverlay.selectOption(subtitleButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}, false))
}*/
masterPlaylist.getVideoSources().forEach {
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
videoButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
it.name,
"${it.width}x${it.height}",
(prefix + it.codec).trim(),
tag = it,
call = {
selectedVideoVariant = it
slideUpMenuOverlay.selectOption(videoButtons, it)
if (audioButtons.isEmpty()){
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}
},
invokeParent = false
))
}
} }
val newItems = arrayListOf<View>() val newItems = arrayListOf<View>()
@@ -398,11 +460,11 @@ class UISlideOverlays {
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) { if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (source is IHLSManifestSource) { if (source is IHLSManifestSource) {
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, sourceUrl), null, null) StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null)
UIDialogs.toast(container.context, "Variant video HLS playlist download started") UIDialogs.toast(container.context, "Variant video HLS playlist download started")
slideUpMenuOverlay.hide() slideUpMenuOverlay.hide()
} else if (source is IHLSManifestAudioSource) { } else if (source is IHLSManifestAudioSource) {
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, sourceUrl), null) StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null)
UIDialogs.toast(container.context, "Variant audio HLS playlist download started") UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
slideUpMenuOverlay.hide() slideUpMenuOverlay.hide()
} else { } else {
@@ -684,6 +746,10 @@ class UISlideOverlays {
} }
} }
} }
if(!Settings.instance.downloads.shouldDownload()) {
UIDialogs.appToast("Download will start when you're back on wifi.\n" +
"(You can change this in settings)", true);
}
} }
}; };
return menu.apply { show() }; return menu.apply { show() };
@@ -980,26 +1046,30 @@ class UISlideOverlays {
+ actions).filterNotNull() + actions).filterNotNull()
)); ));
items.add( items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto", SlideUpMenuGroup(
SlideUpMenuItem(container.context, container.context, container.context.getString(R.string.add_to), "addto",
SlideUpMenuItem(
container.context,
R.drawable.ic_queue_add, R.drawable.ic_queue_add,
container.context.getString(R.string.add_to_queue), container.context.getString(R.string.add_to_queue),
"${queue.size} " + container.context.getString(R.string.videos), "${queue.size} " + container.context.getString(R.string.videos),
tag = "queue", tag = "queue",
call = { StatePlayer.instance.addToQueue(video); }), call = { StatePlayer.instance.addToQueue(video); }),
SlideUpMenuItem(container.context, SlideUpMenuItem(
container.context,
R.drawable.ic_watchlist_add, R.drawable.ic_watchlist_add,
"${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "", "${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "",
"${watchLater.size} " + container.context.getString(R.string.videos), "${watchLater.size} " + container.context.getString(R.string.videos),
tag = "watch later", tag = "watch later",
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }), call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }),
SlideUpMenuItem(container.context, SlideUpMenuItem(
container.context,
R.drawable.ic_history, R.drawable.ic_history,
container.context.getString(R.string.add_to_history), container.context.getString(R.string.add_to_history),
"Mark as watched", "Mark as watched",
tag = "history", tag = "history",
call = { StateHistory.instance.markAsWatched(video); }), call = { StateHistory.instance.markAsWatched(video); }),
)); ));
val playlistItems = arrayListOf<SlideUpMenuItem>(); val playlistItems = arrayListOf<SlideUpMenuItem>();
playlistItems.add(SlideUpMenuItem( playlistItems.add(SlideUpMenuItem(
@@ -1063,14 +1133,17 @@ class UISlideOverlays {
val queue = StatePlayer.instance.getQueue(); val queue = StatePlayer.instance.getQueue();
val watchLater = StatePlaylists.instance.getWatchLater(); val watchLater = StatePlaylists.instance.getWatchLater();
items.add( items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other", SlideUpMenuGroup(
SlideUpMenuItem(container.context, container.context, container.context.getString(R.string.other), "other",
SlideUpMenuItem(
container.context,
R.drawable.ic_queue_add, R.drawable.ic_queue_add,
container.context.getString(R.string.queue), container.context.getString(R.string.queue),
"${queue.size} " + container.context.getString(R.string.videos), "${queue.size} " + container.context.getString(R.string.videos),
tag = "queue", tag = "queue",
call = { StatePlayer.instance.addToQueue(video); }), call = { StatePlayer.instance.addToQueue(video); }),
SlideUpMenuItem(container.context, SlideUpMenuItem(
container.context,
R.drawable.ic_watchlist_add, R.drawable.ic_watchlist_add,
StatePlayer.TYPE_WATCHLATER, StatePlayer.TYPE_WATCHLATER,
"${watchLater.size} " + container.context.getString(R.string.videos), "${watchLater.size} " + container.context.getString(R.string.videos),
@@ -1079,7 +1152,7 @@ class UISlideOverlays {
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true)) if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true))
UIDialogs.appToast("Added to watch later", false); UIDialogs.appToast("Added to watch later", false);
}), }),
) )
); );
val playlistItems = arrayListOf<SlideUpMenuItem>(); val playlistItems = arrayListOf<SlideUpMenuItem>();
@@ -1117,8 +1190,8 @@ class UISlideOverlays {
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.add_to), null, true, items).apply { show() }; return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.add_to), null, true, items).apply { show() };
} }
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>, isChannelSearch: Boolean = false): SlideUpMenuFilters { fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>): SlideUpMenuFilters {
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues, isChannelSearch); val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues);
overlay.show(); overlay.show();
return overlay; return overlay;
} }
@@ -1148,7 +1221,7 @@ class UISlideOverlays {
container.context.getString(R.string.decide_which_buttons_should_be_pinned), container.context.getString(R.string.decide_which_buttons_should_be_pinned),
tag = "", tag = "",
call = { call = {
showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) { showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }, {
val selected = it val selected = it
.map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } } .map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } }
.filter { it != null } .filter { it != null }
@@ -1156,7 +1229,7 @@ class UISlideOverlays {
.toList(); .toList();
onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) }); onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) });
} });
}, },
invokeParent = false invokeParent = false
)) ))
@@ -1164,29 +1237,40 @@ class UISlideOverlays {
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.more_options), null, true, *views).apply { show() }; return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.more_options), null, true, *views).apply { show() };
} }
fun showOrderOverlay(container: ViewGroup, title: String, options: List<Pair<String, Any>>, onOrdered: (List<Any>)->Unit, description: String? = null) {
fun showOrderOverlay(container: ViewGroup, title: String, options: List<Pair<String, Any>>, onOrdered: (List<Any>)->Unit) {
val selection: MutableList<Any> = mutableListOf(); val selection: MutableList<Any> = mutableListOf();
var overlay: SlideUpMenuOverlay? = null; var overlay: SlideUpMenuOverlay? = null;
overlay = SlideUpMenuOverlay(container.context, container, title, container.context.getString(R.string.save), true, overlay = SlideUpMenuOverlay(container.context, container, title, container.context.getString(R.string.save), true,
options.map { SlideUpMenuItem( listOf(
if(!description.isNullOrEmpty()) SlideUpMenuGroup(container.context, "", description, "", listOf()) else null,
).filterNotNull() +
(options.map { SlideUpMenuItem(
container.context, container.context,
R.drawable.ic_move_up, R.drawable.ic_move_up,
it.first, it.first,
"", "",
tag = it.second, tag = it.second,
call = { call = {
val overlayItem = overlay?.getSlideUpItemByTag(it.second);
if(overlay!!.selectOption(null, it.second, true, true)) { if(overlay!!.selectOption(null, it.second, true, true)) {
if(!selection.contains(it.second)) if(!selection.contains(it.second)) {
selection.add(it.second); selection.add(it.second);
} else if(overlayItem != null) {
overlayItem.setSubText(selection.indexOf(it.second).toString());
}
}
} else {
selection.remove(it.second); selection.remove(it.second);
if(overlayItem != null) {
overlayItem.setSubText("");
}
}
}, },
invokeParent = false invokeParent = false
) )
}); }));
overlay.onOK.subscribe { overlay.onOK.subscribe {
onOrdered.invoke(selection); onOrdered.invoke(selection);
overlay.hide(); overlay.hide();
@@ -27,14 +27,23 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.PlatformVideoWithTime import com.futo.platformplayer.models.PlatformVideoWithTime
import com.futo.platformplayer.others.PlatformLinkMovementMethod import com.futo.platformplayer.others.PlatformLinkMovementMethod
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File import java.io.ByteArrayOutputStream
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
import java.net.InterfaceAddress
import java.net.NetworkInterface
import java.net.SocketException
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.security.SecureRandom
import java.time.OffsetDateTime
import java.util.* import java.util.*
import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.ThreadLocalRandom
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz "; private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
fun getRandomString(sizeOfRandomString: Int): String { fun getRandomString(sizeOfRandomString: Int): String {
@@ -66,7 +75,14 @@ fun warnIfMainThread(context: String) {
} }
fun ensureNotMainThread() { fun ensureNotMainThread() {
if (Looper.myLooper() == Looper.getMainLooper()) { val isMainLooper = try {
Looper.myLooper() == Looper.getMainLooper()
} catch (e: Throwable) {
//Ignore, for unit tests where its not mocked
false
}
if (isMainLooper) {
Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread") Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread")
throw IllegalStateException("Cannot run on main thread") throw IllegalStateException("Cannot run on main thread")
} }
@@ -269,7 +285,7 @@ fun <T> findNewIndex(originalArr: List<T>, newArr: List<T>, item: T): Int{
} }
} }
if(newIndex < 0) if(newIndex < 0)
return originalArr.size; return newArr.size;
else else
return newIndex; return newIndex;
} }
@@ -279,3 +295,167 @@ fun ByteBuffer.toUtf8String(): String {
get(remainingBytes) get(remainingBytes)
return String(remainingBytes, Charsets.UTF_8) return String(remainingBytes, Charsets.UTF_8)
} }
fun generateReadablePassword(length: Int): String {
val validChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"
val secureRandom = SecureRandom()
val randomBytes = ByteArray(length)
secureRandom.nextBytes(randomBytes)
val sb = StringBuilder(length)
for (byte in randomBytes) {
val index = (byte.toInt() and 0xFF) % validChars.length
sb.append(validChars[index])
}
return sb.toString()
}
fun ByteArray.toGzip(): ByteArray {
if (this == null || this.isEmpty()) return ByteArray(0)
val gzipTimeStart = OffsetDateTime.now();
val outputStream = ByteArrayOutputStream()
GZIPOutputStream(outputStream).use { gzip ->
gzip.write(this)
}
val result = outputStream.toByteArray();
Logger.i("Utility", "Gzip compression time: ${gzipTimeStart.getNowDiffMiliseconds()}ms");
return result;
}
fun ByteArray.fromGzip(): ByteArray {
if (this == null || this.isEmpty()) return ByteArray(0)
val inputStream = ByteArrayInputStream(this)
val outputStream = ByteArrayOutputStream()
GZIPInputStream(inputStream).use { gzip ->
val buffer = ByteArray(1024)
var bytesRead: Int
while (gzip.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
}
}
return outputStream.toByteArray()
}
fun findCandidateAddresses(): List<InetAddress> {
val candidates = NetworkInterface.getNetworkInterfaces()
.toList()
.asSequence()
.filter(::isUsableInterface)
.flatMap { nif ->
nif.interfaceAddresses
.asSequence()
.mapNotNull { ia ->
ia.address.takeIf(::isUsableAddress)?.let { addr ->
nif to ia
}
}
}
.toList()
return candidates
.sortedWith(
compareBy<Pair<NetworkInterface, InterfaceAddress>>(
{ addressScore(it.second.address) },
{ interfaceScore(it.first) },
{ -it.second.networkPrefixLength.toInt() },
{ -it.first.mtu }
)
).map { it.second.address }
}
fun findPreferredAddress(): InetAddress? {
val candidates = NetworkInterface.getNetworkInterfaces()
.toList()
.asSequence()
.filter(::isUsableInterface)
.flatMap { nif ->
nif.interfaceAddresses
.asSequence()
.mapNotNull { ia ->
ia.address.takeIf(::isUsableAddress)?.let { addr ->
nif to ia
}
}
}
.toList()
return candidates
.minWithOrNull(
compareBy<Pair<NetworkInterface, InterfaceAddress>>(
{ addressScore(it.second.address) },
{ interfaceScore(it.first) },
{ -it.second.networkPrefixLength.toInt() },
{ -it.first.mtu }
)
)?.second?.address
}
private fun isUsableInterface(nif: NetworkInterface): Boolean {
val name = nif.name.lowercase()
return try {
// must be up, not loopback/virtual/PtP, have a MAC, not Docker/tun/etc.
nif.isUp
&& !nif.isLoopback
&& !nif.isPointToPoint
&& !nif.isVirtual
&& !name.startsWith("docker")
&& !name.startsWith("veth")
&& !name.startsWith("br-")
&& !name.startsWith("virbr")
&& !name.startsWith("vmnet")
&& !name.startsWith("tun")
&& !name.startsWith("tap")
} catch (e: SocketException) {
false
}
}
private fun isUsableAddress(addr: InetAddress): Boolean {
return when {
addr.isAnyLocalAddress -> false // 0.0.0.0 / ::
addr.isLoopbackAddress -> false
addr.isLinkLocalAddress -> false // 169.254.x.x or fe80::/10
addr.isMulticastAddress -> false
else -> true
}
}
private fun interfaceScore(nif: NetworkInterface): Int {
val name = nif.name.lowercase()
return when {
name.matches(Regex("^(eth|enp|eno|ens|em)\\d+")) -> 0
name.startsWith("eth") || name.contains("ethernet") -> 0
name.matches(Regex("^(wlan|wlp)\\d+")) -> 1
name.contains("wi-fi") || name.contains("wifi") -> 1
else -> 2
}
}
fun addressScore(addr: InetAddress): Int {
return when (addr) {
is Inet4Address -> {
val octets = addr.address.map { it.toInt() and 0xFF }
when {
octets[0] == 10 -> 0 // 10/8
octets[0] == 192 && octets[1] == 168 -> 0 // 192.168/16
octets[0] == 172 && octets[1] in 16..31 -> 0 // 172.1631/12
else -> 1 // public IPv4
}
}
is Inet6Address -> {
// ULA (fc00::/7) vs global vs others
val b0 = addr.address[0].toInt() and 0xFF
when {
(b0 and 0xFE) == 0xFC -> 2 // ULA
(b0 and 0xE0) == 0x20 -> 3 // global
else -> 4
}
}
else -> Int.MAX_VALUE
}
}
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
@@ -10,11 +10,13 @@ import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput
import com.google.zxing.integration.android.IntentIntegrator import com.google.zxing.integration.android.IntentIntegrator
class AddSourceOptionsActivity : AppCompatActivity() { class AddSourceOptionsActivity : AppCompatActivity() {
lateinit var _buttonBack: ImageButton; lateinit var _buttonBack: ImageButton;
lateinit var _overlayContainer: FrameLayout;
lateinit var _buttonQR: BigButton; lateinit var _buttonQR: BigButton;
lateinit var _buttonBrowse: BigButton; lateinit var _buttonBrowse: BigButton;
lateinit var _buttonURL: BigButton; lateinit var _buttonURL: BigButton;
@@ -54,6 +56,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
setContentView(R.layout.activity_add_source_options); setContentView(R.layout.activity_add_source_options);
setNavigationBarColorAndIcons(); setNavigationBarColorAndIcons();
_overlayContainer = findViewById(R.id.overlay_container);
_buttonBack = findViewById(R.id.button_back); _buttonBack = findViewById(R.id.button_back);
_buttonQR = findViewById(R.id.option_qr); _buttonQR = findViewById(R.id.option_qr);
@@ -81,7 +84,25 @@ class AddSourceOptionsActivity : AppCompatActivity() {
} }
_buttonURL.onClick.subscribe { _buttonURL.onClick.subscribe {
UIDialogs.toast(this, getString(R.string.not_implemented_yet)); val nameInput = SlideUpMenuTextInput(this, "ex. https://yourplugin.com/config.json");
UISlideOverlays.showOverlay(_overlayContainer, "Enter your url", "Install", {
val content = nameInput.text;
val url = if (content.startsWith("https://")) {
content
} else if (content.startsWith("grayjay://plugin/")) {
content.substring("grayjay://plugin/".length)
} else {
UIDialogs.toast(this, getString(R.string.not_a_plugin_url))
return@showOverlay;
}
val intent = Intent(this, AddSourceActivity::class.java).apply {
data = Uri.parse(url);
};
startActivity(intent);
}, nameInput)
} }
} }
} }
@@ -113,7 +113,7 @@ class LoginActivity : AppCompatActivity() {
companion object { companion object {
private val TAG = "LoginActivity"; private val TAG = "LoginActivity";
private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#_ ]*"); private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#:_ ]*");
private var _callback: ((SourceAuth?) -> Unit)? = null; private var _callback: ((SourceAuth?) -> Unit)? = null;
@@ -1,14 +1,15 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ComponentName import android.app.AlertDialog
import android.app.UiModeManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.Configuration import android.content.res.Configuration
import android.media.AudioManager
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.StrictMode import android.os.StrictMode
import android.os.StrictMode.VmPolicy import android.os.StrictMode.VmPolicy
@@ -21,6 +22,7 @@ import android.widget.ImageView
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.motion.widget.MotionLayout import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
@@ -30,6 +32,8 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.whenStateAtLeast
import androidx.lifecycle.withStateAtLeast
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R import com.futo.platformplayer.R
@@ -38,7 +42,9 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.fragment.mainactivity.main.ArticleDetailFragment
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
import com.futo.platformplayer.fragment.mainactivity.main.BuyFragment import com.futo.platformplayer.fragment.mainactivity.main.BuyFragment
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
@@ -65,7 +71,9 @@ import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragm
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
import com.futo.platformplayer.fragment.mainactivity.main.TutorialFragment import com.futo.platformplayer.fragment.mainactivity.main.TutorialFragment
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment.State
import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment
import com.futo.platformplayer.fragment.mainactivity.main.WebDetailFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
@@ -74,7 +82,6 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImportCache import com.futo.platformplayer.models.ImportCache
import com.futo.platformplayer.models.UrlVideoWithTime import com.futo.platformplayer.models.UrlVideoWithTime
import com.futo.platformplayer.receivers.MediaButtonReceiver
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup import com.futo.platformplayer.states.StateBackup
@@ -108,6 +115,7 @@ import java.io.StringWriter
import java.lang.reflect.InvocationTargetException import java.lang.reflect.InvocationTargetException
import java.util.LinkedList import java.util.LinkedList
import java.util.Queue import java.util.Queue
import java.util.UUID
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
@@ -146,6 +154,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//Frags Main //Frags Main
lateinit var _fragMainHome: HomeFragment; lateinit var _fragMainHome: HomeFragment;
lateinit var _fragPostDetail: PostDetailFragment; lateinit var _fragPostDetail: PostDetailFragment;
lateinit var _fragArticleDetail: ArticleDetailFragment;
lateinit var _fragWebDetail: WebDetailFragment;
lateinit var _fragMainVideoSearchResults: ContentSearchResultsFragment; lateinit var _fragMainVideoSearchResults: ContentSearchResultsFragment;
lateinit var _fragMainCreatorSearchResults: CreatorSearchResultsFragment; lateinit var _fragMainCreatorSearchResults: CreatorSearchResultsFragment;
lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment; lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment;
@@ -175,7 +185,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragVideoDetail: VideoDetailFragment; lateinit var _fragVideoDetail: VideoDetailFragment;
//State //State
private val _queue: Queue<Pair<MainFragment, Any?>> = LinkedList(); private val _queue: LinkedList<Pair<MainFragment, Any?>> = LinkedList();
lateinit var fragCurrent: MainFragment private set; lateinit var fragCurrent: MainFragment private set;
private var _parameterCurrent: Any? = null; private var _parameterCurrent: Any? = null;
@@ -185,6 +195,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private var _isVisible = true; private var _isVisible = true;
private var _wasStopped = false; private var _wasStopped = false;
private var _privateModeEnabled = false
private var _pictureInPictureEnabled = false
private var _isFullscreen = false
private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data) val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
@@ -196,7 +209,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
try { try {
runBlocking { lifecycleScope.launch {
handleUrlAll(content) handleUrlAll(content)
} }
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -206,6 +219,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
} }
val mainId = UUID.randomUUID().toString().substring(0, 5)
constructor() : super() { constructor() : super() {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
StrictMode.setVmPolicy( StrictMode.setVmPolicy(
@@ -257,11 +272,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
@UnstableApi @UnstableApi
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Logger.i(TAG, "MainActivity Starting"); Logger.w(TAG, "MainActivity Starting [$mainId]");
StateApp.instance.setGlobalContext(this, lifecycleScope); StateApp.instance.setGlobalContext(this, lifecycleScope, mainId);
StateApp.instance.mainAppStarting(this); StateApp.instance.mainAppStarting(this);
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val uiMode = getSystemService(UiModeManager::class.java)
uiMode.setApplicationNightMode(UiModeManager.MODE_NIGHT_YES)
}
setContentView(R.layout.activity_main); setContentView(R.layout.activity_main);
setNavigationBarColorAndIcons(); setNavigationBarColorAndIcons();
if (Settings.instance.playback.allowVideoToGoUnderCutout) if (Settings.instance.playback.allowVideoToGoUnderCutout)
@@ -269,7 +288,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
runBlocking { runBlocking {
StatePlatform.instance.updateAvailableClients(this@MainActivity); try {
StatePlatform.instance.updateAvailableClients(this@MainActivity);
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception in updateAvailableClients", e)
}
} }
//Preload common files to memory //Preload common files to memory
@@ -313,6 +336,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragMainPlaylist = PlaylistFragment.newInstance(); _fragMainPlaylist = PlaylistFragment.newInstance();
_fragMainRemotePlaylist = RemotePlaylistFragment.newInstance(); _fragMainRemotePlaylist = RemotePlaylistFragment.newInstance();
_fragPostDetail = PostDetailFragment.newInstance(); _fragPostDetail = PostDetailFragment.newInstance();
_fragArticleDetail = ArticleDetailFragment.newInstance();
_fragWebDetail = WebDetailFragment.newInstance();
_fragWatchlist = WatchLaterFragment.newInstance(); _fragWatchlist = WatchLaterFragment.newInstance();
_fragHistory = HistoryFragment.newInstance(); _fragHistory = HistoryFragment.newInstance();
_fragSourceDetail = SourceDetailFragment.newInstance(); _fragSourceDetail = SourceDetailFragment.newInstance();
@@ -354,22 +379,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragMainSubscriptionsFeed.setPreviewsEnabled(true); _fragMainSubscriptionsFeed.setPreviewsEnabled(true);
_fragContainerVideoDetail.visibility = View.INVISIBLE; _fragContainerVideoDetail.visibility = View.INVISIBLE;
updateSegmentPaddings(); updateSegmentPaddings();
updatePrivateModeVisibility()
}; };
_buttonIncognito = findViewById(R.id.incognito_button); _buttonIncognito = findViewById(R.id.incognito_button);
_buttonIncognito.elevation = -99f; updatePrivateModeVisibility()
_buttonIncognito.alpha = 0f;
StateApp.instance.privateModeChanged.subscribe { StateApp.instance.privateModeChanged.subscribe {
//Messing with visibility causes some issues with layout ordering? //Messing with visibility causes some issues with layout ordering?
if (it) { _privateModeEnabled = it
_buttonIncognito.elevation = 99f; updatePrivateModeVisibility()
_buttonIncognito.alpha = 1f;
} else {
_buttonIncognito.elevation = -99f;
_buttonIncognito.alpha = 0f;
}
} }
_buttonIncognito.setOnClickListener { _buttonIncognito.setOnClickListener {
if (!StateApp.instance.privateMode) if (!StateApp.instance.privateMode)
return@setOnClickListener; return@setOnClickListener;
@@ -386,19 +407,16 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}; };
_fragVideoDetail.onFullscreenChanged.subscribe { _fragVideoDetail.onFullscreenChanged.subscribe {
Logger.i(TAG, "onFullscreenChanged ${it}"); Logger.i(TAG, "onFullscreenChanged ${it}");
_isFullscreen = it
updatePrivateModeVisibility()
}
if (it) { _fragVideoDetail.onMinimize.subscribe {
_buttonIncognito.elevation = -99f; updatePrivateModeVisibility()
_buttonIncognito.alpha = 0f; }
} else {
if (StateApp.instance.privateMode) { _fragVideoDetail.onMaximized.subscribe {
_buttonIncognito.elevation = 99f; updatePrivateModeVisibility()
_buttonIncognito.alpha = 1f;
} else {
_buttonIncognito.elevation = -99f;
_buttonIncognito.alpha = 0f;
}
}
} }
StatePlayer.instance.also { StatePlayer.instance.also {
@@ -446,6 +464,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragMainPlaylist.topBar = _fragTopBarNavigation; _fragMainPlaylist.topBar = _fragTopBarNavigation;
_fragMainRemotePlaylist.topBar = _fragTopBarNavigation; _fragMainRemotePlaylist.topBar = _fragTopBarNavigation;
_fragPostDetail.topBar = _fragTopBarNavigation; _fragPostDetail.topBar = _fragTopBarNavigation;
_fragArticleDetail.topBar = _fragTopBarNavigation;
_fragWebDetail.topBar = _fragTopBarNavigation;
_fragWatchlist.topBar = _fragTopBarNavigation; _fragWatchlist.topBar = _fragTopBarNavigation;
_fragHistory.topBar = _fragTopBarNavigation; _fragHistory.topBar = _fragTopBarNavigation;
_fragSourceDetail.topBar = _fragTopBarNavigation; _fragSourceDetail.topBar = _fragTopBarNavigation;
@@ -613,8 +633,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
UIDialogs.toast(this, "No external file permissions\nExporting and auto backups will not work"); UIDialogs.toast(this, "No external file permissions\nExporting and auto backups will not work");
}*/ }*/
private var _qrCodeLoadingDialog: AlertDialog? = null
fun showUrlQrCodeScanner() { fun showUrlQrCodeScanner() {
try { try {
_qrCodeLoadingDialog = UIDialogs.showDialog(this, R.drawable.ic_loader_animated, true,
"Launching QR scanner",
"Make sure your camera is enabled", null, -2,
UIDialogs.Action("Close", {
_qrCodeLoadingDialog?.dismiss()
_qrCodeLoadingDialog = null
}));
val integrator = IntentIntegrator(this) val integrator = IntentIntegrator(this)
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt(getString(R.string.scan_a_qr_code)) integrator.setPrompt(getString(R.string.scan_a_qr_code))
@@ -630,21 +660,36 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
} }
@OptIn(UnstableApi::class)
private fun updatePrivateModeVisibility() {
if (_privateModeEnabled && (_fragVideoDetail.state == State.CLOSED || !_pictureInPictureEnabled && !_isFullscreen)) {
_buttonIncognito.elevation = 99f;
_buttonIncognito.alpha = 1f;
_buttonIncognito.translationY = if (_fragVideoDetail.state == State.MINIMIZED) -60.dp(resources).toFloat() else 0f
} else {
_buttonIncognito.elevation = -99f;
_buttonIncognito.alpha = 0f;
}
}
override fun onResume() { override fun onResume() {
super.onResume(); super.onResume();
Logger.v(TAG, "onResume") Logger.w(TAG, "onResume [$mainId]")
_isVisible = true; _isVisible = true;
} }
override fun onPause() { override fun onPause() {
super.onPause(); super.onPause();
Logger.v(TAG, "onPause") Logger.w(TAG, "onPause [$mainId]")
_isVisible = false; _isVisible = false;
_qrCodeLoadingDialog?.dismiss()
_qrCodeLoadingDialog = null
} }
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
Logger.v(TAG, "_wasStopped = true"); Logger.w(TAG, "onStop [$mainId]");
_wasStopped = true; _wasStopped = true;
} }
@@ -678,7 +723,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
"VIDEO" -> { "VIDEO" -> {
val url = intent.getStringExtra("VIDEO"); val url = intent.getStringExtra("VIDEO");
navigate(_fragVideoDetail, url); navigateWhenReady(_fragVideoDetail, url);
} }
"IMPORT_OPTIONS" -> { "IMPORT_OPTIONS" -> {
@@ -696,11 +741,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
"Sources" -> { "Sources" -> {
runBlocking { runBlocking {
StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed.. StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed..
navigate(_fragMainSources); navigateWhenReady(_fragMainSources);
} }
}; };
"BROWSE_PLUGINS" -> { "BROWSE_PLUGINS" -> {
navigate(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf( navigateWhenReady(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
Pair("grayjay") { req -> Pair("grayjay") { req ->
StateApp.instance.contextOrNull?.let { StateApp.instance.contextOrNull?.let {
if (it is MainActivity) { if (it is MainActivity) {
@@ -718,8 +763,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
try { try {
if (targetData != null) { if (targetData != null) {
runBlocking { lifecycleScope.launch(Dispatchers.Main) {
handleUrlAll(targetData) try {
handleUrlAll(targetData)
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception in handleUrlAll", e)
}
} }
} }
} catch (ex: Throwable) { } catch (ex: Throwable) {
@@ -747,10 +796,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
startActivity(intent); startActivity(intent);
} else if (url.startsWith("grayjay://video/")) { } else if (url.startsWith("grayjay://video/")) {
val videoUrl = url.substring("grayjay://video/".length); val videoUrl = url.substring("grayjay://video/".length);
navigate(_fragVideoDetail, videoUrl); navigateWhenReady(_fragVideoDetail, videoUrl);
} else if (url.startsWith("grayjay://channel/")) { } else if (url.startsWith("grayjay://channel/")) {
val channelUrl = url.substring("grayjay://channel/".length); val channelUrl = url.substring("grayjay://channel/".length);
navigate(_fragMainChannel, channelUrl); navigateWhenReady(_fragMainChannel, channelUrl);
} }
} }
@@ -816,29 +865,29 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
Logger.i(TAG, "handleUrl(url=$url) on IO"); Logger.i(TAG, "handleUrl(url=$url) on IO");
if (StatePlatform.instance.hasEnabledVideoClient(url)) { if (StatePlatform.instance.hasEnabledContentClient(url)) {
Logger.i(TAG, "handleUrl(url=$url) found video client"); Logger.i(TAG, "handleUrl(url=$url) found video client");
lifecycleScope.launch(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (position > 0) if (position > 0)
navigate(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true)); navigateWhenReady(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true));
else else
navigate(_fragVideoDetail, url); navigateWhenReady(_fragVideoDetail, url);
_fragVideoDetail.maximizeVideoDetail(true); _fragVideoDetail.maximizeVideoDetail(true);
} }
return@withContext true; return@withContext true;
} else if (StatePlatform.instance.hasEnabledChannelClient(url)) { } else if (StatePlatform.instance.hasEnabledChannelClient(url)) {
Logger.i(TAG, "handleUrl(url=$url) found channel client"); Logger.i(TAG, "handleUrl(url=$url) found channel client");
lifecycleScope.launch(Dispatchers.Main) { withContext(Dispatchers.Main) {
navigate(_fragMainChannel, url); navigateWhenReady(_fragMainChannel, url);
delay(100); delay(100);
_fragVideoDetail.minimizeVideoDetail(); _fragVideoDetail.minimizeVideoDetail();
}; };
return@withContext true; return@withContext true;
} else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) { } else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) {
Logger.i(TAG, "handleUrl(url=$url) found playlist client"); Logger.i(TAG, "handleUrl(url=$url) found playlist client");
lifecycleScope.launch(Dispatchers.Main) { withContext(Dispatchers.Main) {
navigate(_fragMainRemotePlaylist, url); navigateWhenReady(_fragMainRemotePlaylist, url);
delay(100); delay(100);
_fragVideoDetail.minimizeVideoDetail(); _fragVideoDetail.minimizeVideoDetail();
}; };
@@ -1050,18 +1099,33 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.v(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop") Logger.v(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop")
_fragVideoDetail.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig); _fragVideoDetail.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig);
Logger.v(TAG, "onPictureInPictureModeChanged Ready"); Logger.v(TAG, "onPictureInPictureModeChanged Ready");
_pictureInPictureEnabled = isInPictureInPictureMode
updatePrivateModeVisibility()
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy(); super.onDestroy();
Logger.v(TAG, "onDestroy") Logger.w(TAG, "onDestroy [$mainId]")
StateApp.instance.mainAppDestroyed(this); StateApp.instance.mainAppDestroyed(this, mainId);
} }
inline fun <reified T> isFragmentActive(): Boolean { inline fun <reified T> isFragmentActive(): Boolean {
return fragCurrent is T; return fragCurrent is T;
} }
fun navigateWhenReady(segment: MainFragment, parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) {
if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
navigate(segment, parameter, withHistory, isBack)
} else {
lifecycleScope.launch {
lifecycle.withStateAtLeast(Lifecycle.State.RESUMED) {
navigate(segment, parameter, withHistory, isBack)
}
}
}
}
/** /**
* Navigate takes a MainFragment, and makes them the current main visible view * Navigate takes a MainFragment, and makes them the current main visible view
* A parameter can be provided which becomes available in the onShow of said fragment * A parameter can be provided which becomes available in the onShow of said fragment
@@ -1123,7 +1187,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory) if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory)
fragBeforeOverlay = fragCurrent; fragBeforeOverlay = fragCurrent;
fragCurrent = segment; fragCurrent = segment;
_parameterCurrent = parameter; _parameterCurrent = parameter;
} }
@@ -1186,6 +1249,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
PlaylistFragment::class -> _fragMainPlaylist as T; PlaylistFragment::class -> _fragMainPlaylist as T;
RemotePlaylistFragment::class -> _fragMainRemotePlaylist as T; RemotePlaylistFragment::class -> _fragMainRemotePlaylist as T;
PostDetailFragment::class -> _fragPostDetail as T; PostDetailFragment::class -> _fragPostDetail as T;
ArticleDetailFragment::class -> _fragArticleDetail as T;
WebDetailFragment::class -> _fragWebDetail as T;
WatchLaterFragment::class -> _fragWatchlist as T; WatchLaterFragment::class -> _fragWatchlist as T;
HistoryFragment::class -> _fragHistory as T; HistoryFragment::class -> _fragHistory as T;
SourceDetailFragment::class -> _fragSourceDetail as T; SourceDetailFragment::class -> _fragSourceDetail as T;
@@ -11,16 +11,16 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.polycentric.PolycentricStorage import com.futo.platformplayer.polycentric.PolycentricStorage
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.LoaderView import com.futo.platformplayer.views.LoaderView
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ProcessHandle import com.futo.polycentric.core.ProcessHandle
import com.futo.polycentric.core.Store import com.futo.polycentric.core.Store
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -87,7 +87,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
Logger.e(TAG, "Failed to save process secret to secret storage.", e) Logger.e(TAG, "Failed to save process secret to secret storage.", e)
} }
processHandle.addServer(PolycentricCache.SERVER); processHandle.addServer(ApiMethods.SERVER);
processHandle.setUsername(username); processHandle.setUsername(username);
StatePolycentric.instance.setProcessHandle(processHandle); StatePolycentric.instance.setProcessHandle(processHandle);
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -12,12 +12,12 @@ import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.polycentric.PolycentricStorage import com.futo.platformplayer.polycentric.PolycentricStorage
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.overlays.LoaderOverlay import com.futo.platformplayer.views.overlays.LoaderOverlay
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.KeyPair import com.futo.polycentric.core.KeyPair
import com.futo.polycentric.core.Process import com.futo.polycentric.core.Process
import com.futo.polycentric.core.ProcessSecret import com.futo.polycentric.core.ProcessSecret
@@ -145,7 +145,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
} }
StatePolycentric.instance.setProcessHandle(processHandle); StatePolycentric.instance.setProcessHandle(processHandle);
processHandle.fullyBackfillClient(PolycentricCache.SERVER); processHandle.fullyBackfillClient(ApiMethods.SERVER);
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java)); startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
finish(); finish();
@@ -21,10 +21,8 @@ import com.bumptech.glide.Glide
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.polycentric.PolycentricStorage import com.futo.platformplayer.polycentric.PolycentricStorage
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
@@ -32,8 +30,10 @@ import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.views.overlays.LoaderOverlay import com.futo.platformplayer.views.overlays.LoaderOverlay
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.Store import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
import com.futo.polycentric.core.toBase64Url import com.futo.polycentric.core.toBase64Url
import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.futo.polycentric.core.toURLInfoSystemLinkUrl
@@ -145,7 +145,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {
processHandle.fullyBackfillClient(PolycentricCache.SERVER) processHandle.fullyBackfillClient(ApiMethods.SERVER)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
updateUI(); updateUI();
@@ -9,6 +9,8 @@ import android.widget.LinearLayout
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateSync import com.futo.platformplayer.states.StateSync
@@ -29,6 +31,16 @@ class SyncHomeActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (StateApp.instance.contextOrNull == null) {
Logger.w(TAG, "No main activity, restarting main.")
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent)
finish()
return
}
setContentView(R.layout.activity_sync_home) setContentView(R.layout.activity_sync_home)
setNavigationBarColorAndIcons() setNavigationBarColorAndIcons()
@@ -54,7 +66,6 @@ class SyncHomeActivity : AppCompatActivity() {
val view = _viewMap[publicKey] val view = _viewMap[publicKey]
if (!session.isAuthorized) { if (!session.isAuthorized) {
if (view != null) { if (view != null) {
_layoutDevices.removeView(view)
_viewMap.remove(publicKey) _viewMap.remove(publicKey)
} }
return@launch return@launch
@@ -89,6 +100,20 @@ class SyncHomeActivity : AppCompatActivity() {
updateEmptyVisibility() updateEmptyVisibility()
} }
} }
StateSync.instance.confirmStarted(this, onStarted = {
if (StateSync.instance.syncService?.serverSocketFailedToStart == true) {
UIDialogs.toast(this, "Server socket failed to start, is the port in use?", true)
}
if (StateSync.instance.syncService?.relayConnected == false) {
UIDialogs.toast(this, "Not connected to relay, remote connections will work.", false)
}
if (StateSync.instance.syncService?.serverSocketStarted == false) {
UIDialogs.toast(this, "Listener not started, local connections will not work.", false)
}
}, onNotStarted = {
finish()
})
} }
override fun onDestroy() { override fun onDestroy() {
@@ -100,10 +125,12 @@ class SyncHomeActivity : AppCompatActivity() {
private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView { private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView {
val connected = session?.connected ?: false val connected = session?.connected ?: false
syncDeviceView.setLinkType(if (connected) LinkType.Local else LinkType.None) val authorized = session?.isAuthorized ?: false
syncDeviceView.setLinkType(session?.linkType ?: LinkType.None)
.setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey) .setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey)
//TODO: also display public key? //TODO: also display public key?
.setStatus(if (connected) "Connected" else "Disconnected") .setStatus(if (connected && authorized) "Connected" else "Disconnected or unauthorized")
return syncDeviceView return syncDeviceView
} }
@@ -83,6 +83,7 @@ class SyncPairActivity : AppCompatActivity() {
_layoutPairingSuccess.setOnClickListener { _layoutPairingSuccess.setOnClickListener {
_layoutPairingSuccess.visibility = View.GONE _layoutPairingSuccess.visibility = View.GONE
finish()
} }
_layoutPairingError.setOnClickListener { _layoutPairingError.setOnClickListener {
_layoutPairingError.visibility = View.GONE _layoutPairingError.visibility = View.GONE
@@ -109,11 +110,17 @@ class SyncPairActivity : AppCompatActivity() {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {
StateSync.instance.connect(deviceInfo) { session, complete, message -> StateSync.instance.syncService?.connect(deviceInfo) { complete, message ->
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
if (complete) { if (complete != null) {
_layoutPairingSuccess.visibility = View.VISIBLE if (complete) {
_layoutPairing.visibility = View.GONE _layoutPairingSuccess.visibility = View.VISIBLE
_layoutPairing.visibility = View.GONE
} else {
_textError.text = message
_layoutPairingError.visibility = View.VISIBLE
_layoutPairing.visibility = View.GONE
}
} else { } else {
_textPairingStatus.text = message _textPairingStatus.text = message
} }
@@ -137,8 +144,6 @@ class SyncPairActivity : AppCompatActivity() {
_textError.text = e.message _textError.text = e.message
_layoutPairing.visibility = View.GONE _layoutPairing.visibility = View.GONE
Logger.e(TAG, "Failed to pair", e) Logger.e(TAG, "Failed to pair", e)
} finally {
_layoutPairing.visibility = View.GONE
} }
} }
@@ -67,11 +67,18 @@ class SyncShowPairingCodeActivity : AppCompatActivity() {
} }
val ips = getIPs() val ips = getIPs()
val selfDeviceInfo = SyncDeviceInfo(StateSync.instance.publicKey!!, ips.toTypedArray(), StateSync.PORT) val publicKey = StateSync.instance.syncService?.publicKey
val json = Json.encodeToString(selfDeviceInfo) val pairingCode = StateSync.instance.syncService?.pairingCode
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) if (publicKey == null || pairingCode == null) {
val url = "grayjay://sync/${base64}" setCode("Public key or pairing code was not known, is sync enabled?")
setCode(url) } else {
val selfDeviceInfo = SyncDeviceInfo(publicKey, ips.toTypedArray(), StateSync.PORT, pairingCode)
val json = Json.encodeToString(selfDeviceInfo)
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
val url = "grayjay://sync/${base64}"
setCode(url)
}
} }
fun setCode(code: String?) { fun setCode(code: String?) {
@@ -90,6 +90,7 @@ open class ManagedHttpClient {
} }
fun tryHead(url: String): Map<String, String>? { fun tryHead(url: String): Map<String, String>? {
ensureNotMainThread()
try { try {
val result = head(url); val result = head(url);
if(result.isOk) if(result.isOk)
@@ -104,7 +105,7 @@ open class ManagedHttpClient {
} }
fun socket(url: String, headers: MutableMap<String, String> = HashMap(), listener: SocketListener): Socket { fun socket(url: String, headers: MutableMap<String, String> = HashMap(), listener: SocketListener): Socket {
ensureNotMainThread()
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder() val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
.url(url); .url(url);
if(user_agent.isNotEmpty() && !headers.any { it.key.lowercase() == "user-agent" }) if(user_agent.isNotEmpty() && !headers.any { it.key.lowercase() == "user-agent" })
@@ -300,6 +301,7 @@ open class ManagedHttpClient {
} }
fun send(msg: String) { fun send(msg: String) {
ensureNotMainThread()
socket.send(msg); socket.send(msg);
} }
@@ -1,5 +1,6 @@
package com.futo.platformplayer.api.media package com.futo.platformplayer.api.media
import com.futo.platformplayer.api.media.models.IPlatformChannelContent
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
@@ -66,6 +67,11 @@ interface IPlatformClient {
*/ */
fun searchChannels(query: String): IPager<PlatformAuthorLink>; fun searchChannels(query: String): IPager<PlatformAuthorLink>;
/**
* Searches for channels and returns a content pager
*/
fun searchChannelsAsContent(query: String): IPager<IPlatformContent>;
//Video Pages //Video Pages
/** /**
@@ -14,14 +14,16 @@ class PlatformClientPool {
private var _poolCounter = 0; private var _poolCounter = 0;
private val _poolName: String?; private val _poolName: String?;
private val _privatePool: Boolean; private val _privatePool: Boolean;
private val _isolatedInitialization: Boolean
var isDead: Boolean = false var isDead: Boolean = false
private set; private set;
val onDead = Event2<JSClient, PlatformClientPool>(); val onDead = Event2<JSClient, PlatformClientPool>();
constructor(parentClient: IPlatformClient, name: String? = null, privatePool: Boolean = false) { constructor(parentClient: IPlatformClient, name: String? = null, privatePool: Boolean = false, isolatedInitialization: Boolean = false) {
_poolName = name; _poolName = name;
_privatePool = privatePool; _privatePool = privatePool;
_isolatedInitialization = isolatedInitialization
if(parentClient !is JSClient) if(parentClient !is JSClient)
throw IllegalArgumentException("Pooling only supported for JSClients right now"); throw IllegalArgumentException("Pooling only supported for JSClients right now");
Logger.i(TAG, "Pool for ${parentClient.name} was started"); Logger.i(TAG, "Pool for ${parentClient.name} was started");
@@ -53,7 +55,7 @@ class PlatformClientPool {
reserved = _pool.keys.find { !it.isBusy }; reserved = _pool.keys.find { !it.isBusy };
if(reserved == null && _pool.size < capacity) { if(reserved == null && _pool.size < capacity) {
Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})"); Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})");
reserved = _parent.getCopy(_privatePool); reserved = _parent.getCopy(_privatePool, _isolatedInitialization);
reserved?.onCaptchaException?.subscribe { client, ex -> reserved?.onCaptchaException?.subscribe { client, ex ->
StateApp.instance.handleCaptchaException(client, ex); StateApp.instance.handleCaptchaException(client, ex);
@@ -7,13 +7,15 @@ class PlatformMultiClientPool {
private var _isFake = false; private var _isFake = false;
private var _privatePool = false; private var _privatePool = false;
private val _isolatedInitialization: Boolean
constructor(name: String, maxCap: Int = -1, isPrivatePool: Boolean = false) { constructor(name: String, maxCap: Int = -1, isPrivatePool: Boolean = false, isolatedInitialization: Boolean = false) {
_name = name; _name = name;
_maxCap = if(maxCap > 0) _maxCap = if(maxCap > 0)
maxCap maxCap
else 99; else 99;
_privatePool = isPrivatePool; _privatePool = isPrivatePool;
_isolatedInitialization = isolatedInitialization
} }
fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient { fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient {
@@ -21,7 +23,7 @@ class PlatformMultiClientPool {
return parentClient; return parentClient;
val pool = synchronized(_clientPools) { val pool = synchronized(_clientPools) {
if(!_clientPools.containsKey(parentClient)) if(!_clientPools.containsKey(parentClient))
_clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool).apply { _clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool, _isolatedInitialization).apply {
this.onDead.subscribe { _, pool -> this.onDead.subscribe { _, pool ->
synchronized(_clientPools) { synchronized(_clientPools) {
if(_clientPools[parentClient] == pool) if(_clientPools[parentClient] == pool)
@@ -2,7 +2,10 @@ package com.futo.platformplayer.api.media.models
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.models.JSContent
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
@@ -42,4 +45,21 @@ open class PlatformAuthorLink {
); );
} }
} }
}
interface IPlatformChannelContent : IPlatformContent {
val thumbnail: String?
val subscribers: Long?
}
open class JSChannelContent : JSContent, IPlatformChannelContent {
override val contentType: ContentType get() = ContentType.CHANNEL
override val thumbnail: String?
override val subscribers: Long?
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
val contextName = "Channel";
thumbnail = obj.getOrDefault<String>(config, "thumbnail", contextName, null)
subscribers = if(obj.has("subscribers")) obj.getOrThrow(config,"subscribers", contextName) else null
}
} }
@@ -0,0 +1,9 @@
package com.futo.platformplayer.api.media.models.article
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
interface IPlatformArticle: IPlatformContent {
val summary: String?;
val thumbnails: Thumbnails?;
}
@@ -0,0 +1,12 @@
package com.futo.platformplayer.api.media.models.article
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.platforms.js.models.IJSArticleSegment
interface IPlatformArticleDetails: IPlatformContent, IPlatformArticle, IPlatformContentDetails {
val segments: List<IJSArticleSegment>;
val rating : IRating;
}
@@ -8,10 +8,12 @@ enum class ContentType(val value: Int) {
POST(2), POST(2),
ARTICLE(3), ARTICLE(3),
PLAYLIST(4), PLAYLIST(4),
WEB(7),
URL(9), URL(9),
NESTED_VIDEO(11), NESTED_VIDEO(11),
CHANNEL(60),
LOCKED(70), LOCKED(70),
@@ -2,6 +2,8 @@ package com.futo.platformplayer.api.media.models.contents
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
import java.time.OffsetDateTime import java.time.OffsetDateTime
interface IPlatformContent { interface IPlatformContent {
@@ -5,7 +5,8 @@ import com.futo.platformplayer.api.media.exceptions.UnknownPlatformException
enum class TextType(val value: Int) { enum class TextType(val value: Int) {
RAW(0), RAW(0),
HTML(1), HTML(1),
MARKUP(2); MARKUP(2),
CODE(3);
companion object { companion object {
fun fromInt(value: Int): TextType fun fromInt(value: Int): TextType
@@ -3,7 +3,7 @@ package com.futo.platformplayer.api.media.models.streams
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.downloads.VideoLocal import com.futo.platformplayer.downloads.VideoLocal
class LocalVideoMuxedSourceDescriptor( class DownloadedVideoMuxedSourceDescriptor(
private val video: VideoLocal private val video: VideoLocal
) : VideoMuxedSourceDescriptor() { ) : VideoMuxedSourceDescriptor() {
override val videoSources: Array<IVideoSource> get() = video.videoSource.toTypedArray(); override val videoSources: Array<IVideoSource> get() = video.videoSource.toTypedArray();
@@ -13,7 +13,8 @@ class AudioUrlSource(
override val codec: String = "", override val codec: String = "",
override val language: String = Language.UNKNOWN, override val language: String = Language.UNKNOWN,
override val duration: Long? = null, override val duration: Long? = null,
override var priority: Boolean = false override var priority: Boolean = false,
override var original: Boolean = false
) : IAudioUrlSource, IStreamMetaDataSource{ ) : IAudioUrlSource, IStreamMetaDataSource{
override var streamMetaData: StreamMetaData? = null; override var streamMetaData: StreamMetaData? = null;
@@ -36,7 +37,9 @@ class AudioUrlSource(
source.container, source.container,
source.codec, source.codec,
source.language, source.language,
source.duration source.duration,
source.priority,
source.original
); );
ret.streamMetaData = streamData; ret.streamMetaData = streamData;
@@ -27,6 +27,7 @@ class HLSVariantAudioUrlSource(
override val language: String, override val language: String,
override val duration: Long?, override val duration: Long?,
override val priority: Boolean, override val priority: Boolean,
override val original: Boolean,
val url: String val url: String
) : IAudioUrlSource { ) : IAudioUrlSource {
override fun getAudioUrl(): String { override fun getAudioUrl(): String {
@@ -8,4 +8,5 @@ interface IAudioSource {
val language : String; val language : String;
val duration : Long?; val duration : Long?;
val priority: Boolean; val priority: Boolean;
val original: Boolean;
} }
@@ -15,6 +15,7 @@ class LocalAudioSource : IAudioSource, IStreamMetaDataSource {
override val duration: Long? = null; override val duration: Long? = null;
override var priority: Boolean = false; override var priority: Boolean = false;
override val original: Boolean = false;
val filePath : String; val filePath : String;
val fileSize: Long; val fileSize: Long;
@@ -10,15 +10,18 @@ import com.futo.polycentric.core.combineHashCodes
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNames
import java.time.OffsetDateTime import java.time.OffsetDateTime
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
open class SerializedPlatformVideo( open class SerializedPlatformVideo(
override val contentType: ContentType = ContentType.MEDIA,
override val id: PlatformID, override val id: PlatformID,
override val name: String, override val name: String,
override val thumbnails: Thumbnails, override val thumbnails: Thumbnails,
override val author: PlatformAuthorLink, override val author: PlatformAuthorLink,
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
@JsonNames("datetime", "dateTime")
override val datetime: OffsetDateTime? = null, override val datetime: OffsetDateTime? = null,
override val url: String, override val url: String,
override val shareUrl: String = "", override val shareUrl: String = "",
@@ -27,7 +30,6 @@ open class SerializedPlatformVideo(
override val viewCount: Long, override val viewCount: Long,
override val isShort: Boolean = false override val isShort: Boolean = false
) : IPlatformVideo, SerializedPlatformContent { ) : IPlatformVideo, SerializedPlatformContent {
override val contentType: ContentType = ContentType.MEDIA;
override val isLive: Boolean = false; override val isLive: Boolean = false;
@@ -44,6 +46,7 @@ open class SerializedPlatformVideo(
companion object { companion object {
fun fromVideo(video: IPlatformVideo) : SerializedPlatformVideo { fun fromVideo(video: IPlatformVideo) : SerializedPlatformVideo {
return SerializedPlatformVideo( return SerializedPlatformVideo(
ContentType.MEDIA,
video.id, video.id,
video.name, video.name,
video.thumbnails, video.thumbnails,
@@ -54,8 +54,11 @@ class DevJSClient : JSClient {
return DevJSClient(context, config, _devScript, _auth, _captcha, devID, descriptor.settings); return DevJSClient(context, config, _devScript, _auth, _captcha, devID, descriptor.settings);
} }
override fun getCopy(privateCopy: Boolean): JSClient { override fun getCopy(privateCopy: Boolean, noSaveState: Boolean): JSClient {
return DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, saveState(), devID); val client = DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, if (noSaveState) null else saveState(), devID);
if (noSaveState)
client.initialize()
return client
} }
override fun initialize() { override fun initialize() {
@@ -10,6 +10,7 @@ import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformClientCapabilities import com.futo.platformplayer.api.media.PlatformClientCapabilities
import com.futo.platformplayer.api.media.models.IPlatformChannelContent
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
@@ -31,6 +32,7 @@ import com.futo.platformplayer.api.media.platforms.js.internal.JSParameterDocs
import com.futo.platformplayer.api.media.platforms.js.models.IJSContent import com.futo.platformplayer.api.media.platforms.js.models.IJSContent
import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails
import com.futo.platformplayer.api.media.platforms.js.models.JSChannel import com.futo.platformplayer.api.media.platforms.js.models.JSChannel
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelContentPager
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager
import com.futo.platformplayer.api.media.platforms.js.models.JSChapter import com.futo.platformplayer.api.media.platforms.js.models.JSChapter
import com.futo.platformplayer.api.media.platforms.js.models.JSComment import com.futo.platformplayer.api.media.platforms.js.models.JSComment
@@ -193,8 +195,11 @@ open class JSClient : IPlatformClient {
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit); _plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
} }
open fun getCopy(withoutCredentials: Boolean = false): JSClient { open fun getCopy(withoutCredentials: Boolean = false, noSaveState: Boolean = false): JSClient {
return JSClient(_context, descriptor, saveState(), _script, withoutCredentials); val client = JSClient(_context, descriptor, if (noSaveState) null else saveState(), _script, withoutCredentials);
if (noSaveState)
client.initialize()
return client
} }
fun getUnderlyingPlugin(): V8Plugin { fun getUnderlyingPlugin(): V8Plugin {
@@ -209,6 +214,8 @@ open class JSClient : IPlatformClient {
} }
override fun initialize() { override fun initialize() {
if (_initialized) return
Logger.i(TAG, "Plugin [${config.name}] initializing"); Logger.i(TAG, "Plugin [${config.name}] initializing");
plugin.start(); plugin.start();
plugin.execute("plugin.config = ${Json.encodeToString(config)}"); plugin.execute("plugin.config = ${Json.encodeToString(config)}");
@@ -361,6 +368,10 @@ open class JSClient : IPlatformClient {
return@isBusyWith JSChannelPager(config, this, return@isBusyWith JSChannelPager(config, this,
plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})")); plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"));
} }
override fun searchChannelsAsContent(query: String): IPager<IPlatformContent> = isBusyWith("searchChannels") {
ensureEnabled();
return@isBusyWith JSChannelContentPager(config, this, plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"), );
}
@JSDocs(6, "source.isChannelUrl(url)", "Validates if an channel url is for this platform") @JSDocs(6, "source.isChannelUrl(url)", "Validates if an channel url is for this platform")
@JSDocsParameter("url", "A channel url (May not be your platform)") @JSDocsParameter("url", "A channel url (May not be your platform)")
@@ -127,7 +127,7 @@ class JSHttpClient : ManagedHttpClient {
} }
if(doApplyCookies) { if(doApplyCookies) {
if (_currentCookieMap.isNotEmpty()) { if (_currentCookieMap.isNotEmpty() || _otherCookieMap.isNotEmpty()) {
val cookiesToApply = hashMapOf<String, String>(); val cookiesToApply = hashMapOf<String, String>();
synchronized(_currentCookieMap) { synchronized(_currentCookieMap) {
for(cookie in _currentCookieMap for(cookie in _currentCookieMap
@@ -135,6 +135,12 @@ class JSHttpClient : ManagedHttpClient {
.flatMap { it.value.toList() }) .flatMap { it.value.toList() })
cookiesToApply[cookie.first] = cookie.second; cookiesToApply[cookie.first] = cookie.second;
}; };
synchronized(_otherCookieMap) {
for(cookie in _otherCookieMap
.filter { domain.matchesDomain(it.key) }
.flatMap { it.value.toList() })
cookiesToApply[cookie.first] = cookie.second;
}
if(cookiesToApply.size > 0) { if(cookiesToApply.size > 0) {
val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; "); val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; ");
@@ -1,6 +1,7 @@
package com.futo.platformplayer.api.media.platforms.js.models package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.JSChannelContent
import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
@@ -26,6 +27,9 @@ interface IJSContent: IPlatformContent {
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj); ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
ContentType.PLAYLIST -> JSPlaylist(config, obj); ContentType.PLAYLIST -> JSPlaylist(config, obj);
ContentType.LOCKED -> JSLockedContent(config, obj); ContentType.LOCKED -> JSLockedContent(config, obj);
ContentType.CHANNEL -> JSChannelContent(config, obj);
ContentType.ARTICLE -> JSArticle(config, obj);
ContentType.WEB -> JSWeb(config, obj);
else -> throw NotImplementedError("Unknown content type ${type}"); else -> throw NotImplementedError("Unknown content type ${type}");
} }
} }
@@ -17,6 +17,7 @@ interface IJSContentDetails: IPlatformContent {
ContentType.MEDIA -> JSVideoDetails(plugin, obj); ContentType.MEDIA -> JSVideoDetails(plugin, obj);
ContentType.POST -> JSPostDetails(plugin.config, obj); ContentType.POST -> JSPostDetails(plugin.config, obj);
ContentType.ARTICLE -> JSArticleDetails(plugin, obj); ContentType.ARTICLE -> JSArticleDetails(plugin, obj);
ContentType.WEB -> JSWebDetails(plugin, obj);
else -> throw NotImplementedError("Unknown content type ${type}"); else -> throw NotImplementedError("Unknown content type ${type}");
} }
} }
@@ -0,0 +1,39 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.post.TextType
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.states.StateDeveloper
open class JSArticle : JSContent, IPlatformArticle, IPluginSourced {
final override val contentType: ContentType get() = ContentType.ARTICLE;
override val summary: String;
override val thumbnails: Thumbnails?;
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformArticle";
summary = _content.getOrDefault(config, "summary", contextName, "") ?: "";
thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName));
}
}
@@ -4,6 +4,8 @@ import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.models.Thumbnails import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails
import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
@@ -21,20 +23,20 @@ import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
open class JSArticleDetails : JSContent, IPluginSourced, IPlatformContentDetails { open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
final override val contentType: ContentType get() = ContentType.ARTICLE; final override val contentType: ContentType get() = ContentType.ARTICLE;
private val _hasGetComments: Boolean; private val _hasGetComments: Boolean;
private val _hasGetContentRecommendations: Boolean; private val _hasGetContentRecommendations: Boolean;
val rating: IRating; override val rating: IRating;
val summary: String; override val summary: String;
val thumbnails: Thumbnails?; override val thumbnails: Thumbnails?;
val segments: List<IJSArticleSegment>; override val segments: List<IJSArticleSegment>;
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) { constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
val contextName = "PlatformPost"; val contextName = "PlatformArticle";
rating = obj.getOrDefault<V8ValueObject>(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0); rating = obj.getOrDefault<V8ValueObject>(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0);
summary = _content.getOrThrow(client.config, "summary", contextName); summary = _content.getOrThrow(client.config, "summary", contextName);
@@ -99,6 +101,7 @@ open class JSArticleDetails : JSContent, IPluginSourced, IPlatformContentDetails
return when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) { return when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) {
SegmentType.TEXT -> JSTextSegment(client, obj); SegmentType.TEXT -> JSTextSegment(client, obj);
SegmentType.IMAGES -> JSImagesSegment(client, obj); SegmentType.IMAGES -> JSImagesSegment(client, obj);
SegmentType.HEADER -> JSHeaderSegment(client, obj);
SegmentType.NESTED -> JSNestedSegment(client, obj); SegmentType.NESTED -> JSNestedSegment(client, obj);
else -> null; else -> null;
} }
@@ -110,6 +113,7 @@ enum class SegmentType(val value: Int) {
UNKNOWN(0), UNKNOWN(0),
TEXT(1), TEXT(1),
IMAGES(2), IMAGES(2),
HEADER(3),
NESTED(9); NESTED(9);
@@ -150,6 +154,17 @@ class JSImagesSegment: IJSArticleSegment {
caption = obj.getOrDefault(client.config, "caption", contextName, "") ?: ""; caption = obj.getOrDefault(client.config, "caption", contextName, "") ?: "";
} }
} }
class JSHeaderSegment: IJSArticleSegment {
override val type = SegmentType.HEADER;
val content: String;
val level: Int;
constructor(client: JSClient, obj: V8ValueObject) {
val contextName = "JSHeaderSegment";
content = obj.getOrDefault(client.config, "content", contextName, "") ?: "";
level = obj.getOrDefault(client.config, "level", contextName, 1) ?: 1;
}
}
class JSNestedSegment: IJSArticleSegment { class JSNestedSegment: IJSArticleSegment {
override val type = SegmentType.NESTED; override val type = SegmentType.NESTED;
val nested: IPlatformContent; val nested: IPlatformContent;
@@ -5,7 +5,6 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.engine.V8Plugin
class JSChannelPager : JSPager<PlatformAuthorLink>, IPager<PlatformAuthorLink> { class JSChannelPager : JSPager<PlatformAuthorLink>, IPager<PlatformAuthorLink> {
@@ -49,8 +49,8 @@ open class JSContent : IPlatformContent, IPluginSourced {
else else
author = PlatformAuthorLink.UNKNOWN; author = PlatformAuthorLink.UNKNOWN;
val datetimeInt = _content.getOrThrow<Int>(config, "datetime", contextName).toLong(); val datetimeInt = _content.getOrDefault<Int>(config, "datetime", contextName, null)?.toLong();
if(datetimeInt == 0.toLong()) if(datetimeInt == null || datetimeInt == 0.toLong())
datetime = null; datetime = null;
else else
datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC); datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.models.JSChannelContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@@ -15,4 +16,14 @@ class JSContentPager : JSPager<IPlatformContent>, IPluginSourced {
override fun convertResult(obj: V8ValueObject): IPlatformContent { override fun convertResult(obj: V8ValueObject): IPlatformContent {
return IJSContent.fromV8(plugin, obj); return IJSContent.fromV8(plugin, obj);
} }
}
class JSChannelContentPager : JSPager<IPlatformContent>, IPluginSourced {
override val sourceConfig: SourcePluginConfig get() = config;
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {}
override fun convertResult(obj: V8ValueObject): IPlatformContent {
return JSChannelContent(config, obj);
}
} }
@@ -0,0 +1,31 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.post.TextType
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.states.StateDeveloper
open class JSWeb : JSContent, IPluginSourced {
final override val contentType: ContentType get() = ContentType.WEB;
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformWeb";
}
}
@@ -0,0 +1,41 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.post.TextType
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.states.StateDeveloper
open class JSWebDetails : JSContent, IPluginSourced, IPlatformContentDetails {
final override val contentType: ContentType get() = ContentType.WEB;
val html: String?;
//TODO: Options?
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
val contextName = "PlatformWeb";
html = obj.getOrDefault(client.config, "html", contextName, null);
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null;
override fun getPlaybackTracker(): IPlaybackTracker? = null;
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? = null;
}
@@ -21,6 +21,8 @@ open class JSAudioUrlSource : IAudioUrlSource, JSSource {
override var priority: Boolean = false; override var priority: Boolean = false;
override var original: Boolean = false;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_AUDIOURL, plugin, obj) { constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_AUDIOURL, plugin, obj) {
val contextName = "AudioUrlSource"; val contextName = "AudioUrlSource";
val config = plugin.config; val config = plugin.config;
@@ -35,6 +37,7 @@ open class JSAudioUrlSource : IAudioUrlSource, JSSource {
name = _obj.getOrDefault(config, "name", contextName, "${container} ${bitrate}") ?: "${container} ${bitrate}"; name = _obj.getOrDefault(config, "name", contextName, "${container} ${bitrate}") ?: "${container} ${bitrate}";
priority = if(_obj.has("priority")) obj.getOrThrow(config, "priority", contextName) else false; priority = if(_obj.has("priority")) obj.getOrThrow(config, "priority", contextName) else false;
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
} }
override fun getAudioUrl() : String { override fun getAudioUrl() : String {
@@ -23,6 +23,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
override val bitrate: Int; override val bitrate: Int;
override val duration: Long; override val duration: Long;
override val priority: Boolean; override val priority: Boolean;
override var original: Boolean = false;
override val language: String; override val language: String;
@@ -45,6 +46,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0; duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false; priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN; language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN;
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
hasGenerate = _obj.has("generate"); hasGenerate = _obj.has("generate");
} }
@@ -21,6 +21,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
override val language: String; override val language: String;
override var priority: Boolean = false; override var priority: Boolean = false;
override var original: Boolean = false;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) { constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
val contextName = "HLSAudioSource"; val contextName = "HLSAudioSource";
@@ -32,6 +33,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
language = _obj.getOrThrow(config, "language", contextName); language = _obj.getOrThrow(config, "language", contextName);
priority = obj.getOrNull(config, "priority", contextName) ?: false; priority = obj.getOrNull(config, "priority", contextName) ?: false;
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
} }
@@ -0,0 +1,5 @@
package com.futo.platformplayer.api.media.platforms.local
class LocalClient {
//TODO
}
@@ -0,0 +1,85 @@
package com.futo.platformplayer.api.media.platforms.local.models
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.DownloadedVideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.downloads.VideoLocal
import java.io.File
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneId
class LocalVideoDetails: IPlatformVideoDetails {
override val contentType: ContentType get() = ContentType.UNKNOWN;
override val id: PlatformID;
override val name: String;
override val author: PlatformAuthorLink;
override val datetime: OffsetDateTime?;
override val url: String;
override val shareUrl: String;
override val rating: IRating = RatingLikes(0);
override val description: String = "";
override val video: IVideoSourceDescriptor;
override val preview: IVideoSourceDescriptor? = null;
override val live: IVideoSource? = null;
override val dash: IDashManifestSource? = null;
override val hls: IHLSManifestSource? = null;
override val subtitles: List<ISubtitleSource> = listOf()
override val thumbnails: Thumbnails;
override val duration: Long;
override val viewCount: Long = 0;
override val isLive: Boolean = false;
override val isShort: Boolean = false;
constructor(file: File) {
id = PlatformID("Local", file.path, "LOCAL")
name = file.name;
author = PlatformAuthorLink.UNKNOWN;
url = file.canonicalPath;
shareUrl = "";
duration = 0;
thumbnails = Thumbnails(arrayOf());
datetime = OffsetDateTime.ofInstant(
Instant.ofEpochMilli(file.lastModified()),
ZoneId.systemDefault()
);
video = LocalVideoMuxedSourceDescriptor(LocalVideoFileSource(file));
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
return null;
}
override fun getPlaybackTracker(): IPlaybackTracker? {
return null;
}
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
return null;
}
}
@@ -0,0 +1,13 @@
package com.futo.platformplayer.api.media.platforms.local.models
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
import com.futo.platformplayer.downloads.VideoLocal
class LocalVideoMuxedSourceDescriptor(
private val video: LocalVideoFileSource
) : VideoMuxedSourceDescriptor() {
override val videoSources: Array<IVideoSource> get() = arrayOf(video);
}
@@ -0,0 +1,25 @@
package com.futo.platformplayer.api.media.platforms.local.models
import android.content.Context
import android.database.Cursor
import android.provider.MediaStore
import android.provider.MediaStore.Video
class MediaStoreVideo {
companion object {
val URI = MediaStore.Files.getContentUri("external");
val PROJECTION = arrayOf(Video.Media._ID, Video.Media.TITLE, Video.Media.DURATION, Video.Media.HEIGHT, Video.Media.WIDTH, Video.Media.MIME_TYPE);
val ORDER = MediaStore.Video.Media.TITLE;
fun readMediaStoreVideo(cursor: Cursor) {
}
fun query(context: Context, selection: String, args: Array<String>, order: String? = null): Cursor? {
val cursor = context.contentResolver.query(URI, PROJECTION, selection, args, order ?: ORDER, null);
return cursor;
}
}
}
@@ -0,0 +1,31 @@
package com.futo.platformplayer.api.media.platforms.local.models.sources
import android.content.Context
import android.provider.MediaStore
import android.provider.MediaStore.Video
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
import com.futo.platformplayer.helpers.VideoHelper
import java.io.File
class LocalVideoFileSource: IVideoSource {
override val name: String;
override val width: Int;
override val height: Int;
override val container: String;
override val codec: String = ""
override val bitrate: Int = 0
override val duration: Long;
override val priority: Boolean = false;
constructor(file: File) {
name = file.name;
width = 0;
height = 0;
container = VideoHelper.videoExtensionToMimetype(file.extension) ?: "";
duration = 0;
}
}
@@ -6,7 +6,7 @@ import com.futo.platformplayer.constructs.Event1
* A RefreshPager represents a pager that can be modified overtime (eg. By getting more results later, by recreating the pager) * A RefreshPager represents a pager that can be modified overtime (eg. By getting more results later, by recreating the pager)
* When the onPagerChanged event is emitted, a new pager instance is passed, or requested via getCurrentPager * When the onPagerChanged event is emitted, a new pager instance is passed, or requested via getCurrentPager
*/ */
interface IRefreshPager<T> { interface IRefreshPager<T>: IPager<T> {
val onPagerChanged: Event1<IPager<T>>; val onPagerChanged: Event1<IPager<T>>;
val onPagerError: Event1<Throwable>; val onPagerError: Event1<Throwable>;
@@ -1,5 +1,7 @@
package com.futo.platformplayer.api.media.structures package com.futo.platformplayer.api.media.structures
import com.futo.platformplayer.api.media.structures.ReusablePager.Window
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
/** /**
@@ -9,8 +11,8 @@ import com.futo.platformplayer.logging.Logger
* A "Window" is effectively a pager that just reads previous results from the shared results, but when the end is reached, it will call nextPage on the parent if possible for new results. * A "Window" is effectively a pager that just reads previous results from the shared results, but when the end is reached, it will call nextPage on the parent if possible for new results.
* This allows multiple Windows to exist of the same pager, without messing with position, or duplicate requests * This allows multiple Windows to exist of the same pager, without messing with position, or duplicate requests
*/ */
class ReusablePager<T>: INestedPager<T>, IPager<T> { open class ReusablePager<T>: INestedPager<T>, IReusablePager<T> {
private val _pager: IPager<T>; protected var _pager: IPager<T>;
val previousResults = arrayListOf<T>(); val previousResults = arrayListOf<T>();
constructor(subPager: IPager<T>) { constructor(subPager: IPager<T>) {
@@ -44,7 +46,7 @@ class ReusablePager<T>: INestedPager<T>, IPager<T> {
return previousResults; return previousResults;
} }
fun getWindow(): Window<T> { override fun getWindow(): Window<T> {
return Window(this); return Window(this);
} }
@@ -95,4 +97,118 @@ class ReusablePager<T>: INestedPager<T>, IPager<T> {
return ReusablePager(this); return ReusablePager(this);
} }
} }
}
public class ReusableRefreshPager<T>: INestedPager<T>, IReusablePager<T> {
protected var _pager: IRefreshPager<T>;
val previousResults = arrayListOf<T>();
private var _currentPage: IPager<T>;
val onPagerChanged = Event1<IPager<T>>()
val onPagerError = Event1<Throwable>()
constructor(subPager: IRefreshPager<T>) {
this._pager = subPager;
_currentPage = this;
synchronized(previousResults) {
previousResults.addAll(subPager.getResults());
}
_pager.onPagerError.subscribe(onPagerError::emit);
_pager.onPagerChanged.subscribe {
_currentPage = it;
synchronized(previousResults) {
previousResults.clear();
previousResults.addAll(it.getResults());
}
onPagerChanged.emit(_currentPage);
};
}
override fun findPager(query: (IPager<T>) -> Boolean): IPager<T>? {
if(query(_pager))
return _pager;
else if(_pager is INestedPager<*>)
return (_pager as INestedPager<T>).findPager(query);
return null;
}
override fun hasMorePages(): Boolean {
return _pager.hasMorePages();
}
override fun nextPage() {
_pager.nextPage();
}
override fun getResults(): List<T> {
val results = _pager.getResults();
synchronized(previousResults) {
previousResults.addAll(results);
}
return previousResults;
}
override fun getWindow(): RefreshWindow<T> {
return RefreshWindow(this);
}
class RefreshWindow<T>: IPager<T>, INestedPager<T>, IRefreshPager<T> {
private val _parent: ReusableRefreshPager<T>;
private var _position: Int = 0;
private var _read: Int = 0;
private var _currentResults: List<T>;
override val onPagerChanged = Event1<IPager<T>>();
override val onPagerError = Event1<Throwable>();
override fun getCurrentPager(): IPager<T> {
return _parent.getWindow();
}
constructor(parent: ReusableRefreshPager<T>) {
_parent = parent;
synchronized(_parent.previousResults) {
_currentResults = _parent.previousResults.toList();
_read += _currentResults.size;
}
parent.onPagerChanged.subscribe(onPagerChanged::emit);
parent.onPagerError.subscribe(onPagerError::emit);
}
override fun hasMorePages(): Boolean {
return _parent.previousResults.size > _read || _parent.hasMorePages();
}
override fun nextPage() {
synchronized(_parent.previousResults) {
if (_parent.previousResults.size <= _read) {
_parent.nextPage();
_parent.getResults();
}
_currentResults = _parent.previousResults.drop(_read).toList();
_read += _currentResults.size;
}
}
override fun getResults(): List<T> {
return _currentResults;
}
override fun findPager(query: (IPager<T>) -> Boolean): IPager<T>? {
return _parent.findPager(query);
}
}
}
interface IReusablePager<T>: IPager<T> {
fun getWindow(): IPager<T>;
} }
@@ -149,6 +149,7 @@ class AirPlayCastingDevice : CastingDevice {
break; break;
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e) Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
delay(1000);
} }
} }
@@ -108,7 +108,7 @@ abstract class CastingDevice {
val expectedCurrentTime: Double val expectedCurrentTime: Double
get() { get() {
val diff = (System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0; val diff = if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff; return time + diff;
}; };
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
@@ -10,7 +10,9 @@ import com.futo.platformplayer.toHexString
import com.futo.platformplayer.toInetAddress import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.json.JSONObject import org.json.JSONObject
@@ -56,6 +58,10 @@ class ChromecastCastingDevice : CastingDevice {
private var _mediaSessionId: Int? = null; private var _mediaSessionId: Int? = null;
private var _thread: Thread? = null; private var _thread: Thread? = null;
private var _pingThread: Thread? = null; private var _pingThread: Thread? = null;
private var _launchRetries = 0
private val MAX_LAUNCH_RETRIES = 3
private var _lastLaunchTime_ms = 0L
private var _retryJob: Job? = null
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() { constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name; this.name = name;
@@ -229,6 +235,7 @@ class ChromecastCastingDevice : CastingDevice {
launchObject.put("appId", "CC1AD845"); launchObject.put("appId", "CC1AD845");
launchObject.put("requestId", _requestId++); launchObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString()); sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
_lastLaunchTime_ms = System.currentTimeMillis()
} }
private fun getStatus() { private fun getStatus() {
@@ -268,6 +275,7 @@ class ChromecastCastingDevice : CastingDevice {
_contentType = null; _contentType = null;
_streamType = null; _streamType = null;
_sessionId = null; _sessionId = null;
_launchRetries = 0
_transportId = null; _transportId = null;
} }
@@ -282,6 +290,7 @@ class ChromecastCastingDevice : CastingDevice {
_started = true; _started = true;
_sessionId = null; _sessionId = null;
_launchRetries = 0
_mediaSessionId = null; _mediaSessionId = null;
Logger.i(TAG, "Starting..."); Logger.i(TAG, "Starting...");
@@ -322,6 +331,7 @@ class ChromecastCastingDevice : CastingDevice {
break; break;
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e) Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
Thread.sleep(1000);
} }
} }
@@ -334,6 +344,10 @@ class ChromecastCastingDevice : CastingDevice {
//Connection loop //Connection loop
while (_scopeIO?.isActive == true) { while (_scopeIO?.isActive == true) {
_sessionId = null;
_launchRetries = 0
_mediaSessionId = null;
Logger.i(TAG, "Connecting to Chromecast."); Logger.i(TAG, "Connecting to Chromecast.");
connectionState = CastConnectionState.CONNECTING; connectionState = CastConnectionState.CONNECTING;
@@ -392,7 +406,7 @@ class ChromecastCastingDevice : CastingDevice {
try { try {
val inputStream = _inputStream ?: break; val inputStream = _inputStream ?: break;
synchronized(_inputStreamLock) val message = synchronized(_inputStreamLock)
{ {
Log.d(TAG, "Receiving next packet..."); Log.d(TAG, "Receiving next packet...");
val b1 = inputStream.readUnsignedByte(); val b1 = inputStream.readUnsignedByte();
@@ -404,7 +418,7 @@ class ChromecastCastingDevice : CastingDevice {
if (size > buffer.size) { if (size > buffer.size) {
Logger.w(TAG, "Skipping packet that is too large $size bytes.") Logger.w(TAG, "Skipping packet that is too large $size bytes.")
inputStream.skip(size.toLong()); inputStream.skip(size.toLong());
return@synchronized return@synchronized null
} }
Log.d(TAG, "Received header indicating $size bytes. Waiting for message."); Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
@@ -413,15 +427,19 @@ class ChromecastCastingDevice : CastingDevice {
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end? //TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
val messageBytes = buffer.sliceArray(IntRange(0, size - 1)); val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}."); Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
val message = ChromeCast.CastMessage.parseFrom(messageBytes); val msg = ChromeCast.CastMessage.parseFrom(messageBytes);
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") { if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
Logger.i(TAG, "Received message: $message"); Logger.i(TAG, "Received message: $msg");
} }
return@synchronized msg
}
if (message != null) {
try { try {
handleMessage(message); handleMessage(message);
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to handle message.", e); Logger.w(TAG, "Failed to handle message.", e);
break
} }
} }
} catch (e: java.net.SocketException) { } catch (e: java.net.SocketException) {
@@ -485,6 +503,10 @@ class ChromecastCastingDevice : CastingDevice {
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to send channel message (sourceId: $sourceId, destinationId: $destinationId, namespace: $namespace, json: $json)", e); Logger.w(TAG, "Failed to send channel message (sourceId: $sourceId, destinationId: $destinationId, namespace: $namespace, json: $json)", e);
_socket?.close();
Logger.i(TAG, "Socket disconnected.");
connectionState = CastConnectionState.CONNECTING;
} }
} }
@@ -511,6 +533,7 @@ class ChromecastCastingDevice : CastingDevice {
if (_sessionId == null) { if (_sessionId == null) {
connectionState = CastConnectionState.CONNECTED; connectionState = CastConnectionState.CONNECTED;
_sessionId = applicationUpdate.getString("sessionId"); _sessionId = applicationUpdate.getString("sessionId");
_launchRetries = 0
val transportId = applicationUpdate.getString("transportId"); val transportId = applicationUpdate.getString("transportId");
connectMediaChannel(transportId); connectMediaChannel(transportId);
@@ -525,21 +548,40 @@ class ChromecastCastingDevice : CastingDevice {
} }
if (!sessionIsRunning) { if (!sessionIsRunning) {
_sessionId = null; if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
_mediaSessionId = null; _sessionId = null
setTime(0.0); _mediaSessionId = null
_transportId = null; setTime(0.0)
Logger.w(TAG, "Session not found."); _transportId = null
if (_launching) { if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
Logger.i(TAG, "Player not found, launching."); Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
launchPlayer(); _launchRetries++
launchPlayer()
} else if (!_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
// Maybe the first GET_STATUS came back empty; still try launching
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
_launching = true
_launchRetries++
launchPlayer()
} else {
Logger.e(TAG, "Player not found after $_launchRetries attempts; giving up.")
Logger.i(TAG, "Unable to start media receiver on device")
stop()
}
} else { } else {
Logger.i(TAG, "Player not found, disconnecting."); if (_retryJob == null) {
stop(); Logger.i(TAG, "Scheduled retry job over 5 seconds")
_retryJob = _scopeIO?.launch(Dispatchers.IO) {
delay(5000)
getStatus()
_retryJob = null
}
}
} }
} else { } else {
_launching = false; _launching = false
_launchRetries = 0
} }
val volume = status.getJSONObject("volume"); val volume = status.getJSONObject("volume");
@@ -566,7 +608,7 @@ class ChromecastCastingDevice : CastingDevice {
} }
isPlaying = playerState == "PLAYING"; isPlaying = playerState == "PLAYING";
if (isPlaying) { if (isPlaying || playerState == "PAUSED") {
setTime(currentTime); setTime(currentTime);
} }
@@ -581,6 +623,8 @@ class ChromecastCastingDevice : CastingDevice {
if (message.sourceId == "receiver-0") { if (message.sourceId == "receiver-0") {
Logger.i(TAG, "Close received."); Logger.i(TAG, "Close received.");
stop(); stop();
} else if (_transportId == message.sourceId) {
throw Exception("Transport id closed.")
} }
} }
} else { } else {
@@ -615,6 +659,9 @@ class ChromecastCastingDevice : CastingDevice {
localAddress = null; localAddress = null;
_started = false; _started = false;
_retryJob?.cancel()
_retryJob = null
val socket = _socket; val socket = _socket;
val scopeIO = _scopeIO; val scopeIO = _scopeIO;
@@ -3,6 +3,7 @@ package com.futo.platformplayer.casting
import android.os.Looper import android.os.Looper
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.casting.models.FCastDecryptedMessage import com.futo.platformplayer.casting.models.FCastDecryptedMessage
import com.futo.platformplayer.casting.models.FCastEncryptedMessage import com.futo.platformplayer.casting.models.FCastEncryptedMessage
@@ -24,6 +25,7 @@ import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@@ -32,6 +34,7 @@ import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.math.BigInteger import java.math.BigInteger
import java.net.Inet4Address
import java.net.InetAddress import java.net.InetAddress
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket
@@ -90,7 +93,7 @@ class FCastCastingDevice : CastingDevice {
private var _version: Long = 1; private var _version: Long = 1;
private var _thread: Thread? = null private var _thread: Thread? = null
private var _pingThread: Thread? = null private var _pingThread: Thread? = null
private var _lastPongTime = -1L @Volatile private var _lastPongTime = System.currentTimeMillis()
private var _outputStreamLock = Object() private var _outputStreamLock = Object()
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() { constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
@@ -287,6 +290,7 @@ class FCastCastingDevice : CastingDevice {
break; break;
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e) Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
Thread.sleep(1000);
} }
} }
@@ -324,9 +328,9 @@ class FCastCastingDevice : CastingDevice {
continue; continue;
} }
localAddress = _socket?.localAddress; localAddress = _socket?.localAddress
connectionState = CastConnectionState.CONNECTED; _lastPongTime = System.currentTimeMillis()
_lastPongTime = -1L connectionState = CastConnectionState.CONNECTED
val buffer = ByteArray(4096); val buffer = ByteArray(4096);
@@ -402,36 +406,32 @@ class FCastCastingDevice : CastingDevice {
_pingThread = Thread { _pingThread = Thread {
Logger.i(TAG, "Started ping loop.") Logger.i(TAG, "Started ping loop.")
while (_scopeIO?.isActive == true) { while (_scopeIO?.isActive == true) {
try { if (connectionState == CastConnectionState.CONNECTED) {
send(Opcode.Ping)
} catch (e: Throwable) {
Log.w(TAG, "Failed to send ping.")
try { try {
_socket?.close() send(Opcode.Ping)
_inputStream?.close() if (System.currentTimeMillis() - _lastPongTime > 15000) {
_outputStream?.close() Logger.w(TAG, "Closing socket due to last pong time being larger than 15 seconds.")
try {
_socket?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}
} catch (e: Throwable) { } catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e) Log.w(TAG, "Failed to send ping.")
try {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
} }
} }
Thread.sleep(5000)
/*if (_lastPongTime != -1L && System.currentTimeMillis() - _lastPongTime > 6000) {
Logger.w(TAG, "Closing socket due to last pong time being larger than 6 seconds.")
try {
_socket?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}*/
Thread.sleep(2000)
} }
Logger.i(TAG, "Stopped ping loop.")
Logger.i(TAG, "Stopped ping loop.");
}.apply { start() } }.apply { start() }
} else { } else {
Log.i(TAG, "Thread was still alive, not restarted") Log.i(TAG, "Thread was still alive, not restarted")
@@ -1,14 +1,20 @@
package com.futo.platformplayer.casting package com.futo.platformplayer.casting
import android.app.AlertDialog
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.Looper import android.os.Looper
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import android.util.Xml import java.net.NetworkInterface
import java.net.Inet4Address
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
@@ -37,9 +43,8 @@ import com.futo.platformplayer.builders.DashBuilder
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.exceptions.UnsupportedCastException import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.findPreferredAddress
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.mdns.DnsService
import com.futo.platformplayer.mdns.ServiceDiscoverer
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
@@ -53,10 +58,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream import java.net.Inet6Address
import java.net.InetAddress import java.net.InetAddress
import java.net.URLDecoder import java.net.URLDecoder
import java.net.URLEncoder import java.net.URLEncoder
import java.util.Collections
import java.util.UUID import java.util.UUID
class StateCasting { class StateCasting {
@@ -68,7 +74,6 @@ class StateCasting {
private var _started = false; private var _started = false;
var devices: HashMap<String, CastingDevice> = hashMapOf(); var devices: HashMap<String, CastingDevice> = hashMapOf();
var rememberedDevices: ArrayList<CastingDevice> = arrayListOf();
val onDeviceAdded = Event1<CastingDevice>(); val onDeviceAdded = Event1<CastingDevice>();
val onDeviceChanged = Event1<CastingDevice>(); val onDeviceChanged = Event1<CastingDevice>();
val onDeviceRemoved = Event1<CastingDevice>(); val onDeviceRemoved = Event1<CastingDevice>();
@@ -82,48 +87,15 @@ class StateCasting {
private var _audioExecutor: JSRequestExecutor? = null private var _audioExecutor: JSRequestExecutor? = null
private val _client = ManagedHttpClient(); private val _client = ManagedHttpClient();
var _resumeCastingDevice: CastingDeviceInfo? = null; var _resumeCastingDevice: CastingDeviceInfo? = null;
val _serviceDiscoverer = ServiceDiscoverer(arrayOf( private var _nsdManager: NsdManager? = null
"_googlecast._tcp.local",
"_airplay._tcp.local",
"_fastcast._tcp.local",
"_fcast._tcp.local"
)) { handleServiceUpdated(it) }
val isCasting: Boolean get() = activeDevice != null; val isCasting: Boolean get() = activeDevice != null;
private fun handleServiceUpdated(services: List<DnsService>) { private val _discoveryListeners = mapOf(
for (s in services) { "_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
//TODO: Addresses IPv4 only? "_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
val addresses = s.addresses.toTypedArray() "_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
val port = s.port.toInt() "_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
var name = s.texts.firstOrNull { it.startsWith("md=") }?.substring("md=".length) )
if (s.name.endsWith("._googlecast._tcp.local")) {
if (name == null) {
name = s.name.substring(0, s.name.length - "._googlecast._tcp.local".length)
}
addOrUpdateChromeCastDevice(name, addresses, port)
} else if (s.name.endsWith("._airplay._tcp.local")) {
if (name == null) {
name = s.name.substring(0, s.name.length - "._airplay._tcp.local".length)
}
addOrUpdateAirPlayDevice(name, addresses, port)
} else if (s.name.endsWith("._fastcast._tcp.local")) {
if (name == null) {
name = s.name.substring(0, s.name.length - "._fastcast._tcp.local".length)
}
addOrUpdateFastCastDevice(name, addresses, port)
} else if (s.name.endsWith("._fcast._tcp.local")) {
if (name == null) {
name = s.name.substring(0, s.name.length - "._fcast._tcp.local".length)
}
addOrUpdateFastCastDevice(name, addresses, port)
}
}
}
fun handleUrl(context: Context, url: String) { fun handleUrl(context: Context, url: String) {
val uri = Uri.parse(url) val uri = Uri.parse(url)
@@ -188,30 +160,34 @@ class StateCasting {
Logger.i(TAG, "CastingService starting..."); Logger.i(TAG, "CastingService starting...");
rememberedDevices.clear();
rememberedDevices.addAll(_storage.deviceInfos.map { deviceFromCastingDeviceInfo(it) });
_castServer.start(); _castServer.start();
enableDeveloper(true); enableDeveloper(true);
Logger.i(TAG, "CastingService started."); Logger.i(TAG, "CastingService started.");
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
startDiscovering()
} }
@Synchronized @Synchronized
fun startDiscovering() { private fun startDiscovering() {
try { _nsdManager?.apply {
_serviceDiscoverer.start() _discoveryListeners.forEach {
} catch (e: Throwable) { discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
Logger.i(TAG, "Failed to start ServiceDiscoverer", e) }
} }
} }
@Synchronized @Synchronized
fun stopDiscovering() { private fun stopDiscovering() {
try { _nsdManager?.apply {
_serviceDiscoverer.stop() _discoveryListeners.forEach {
} catch (e: Throwable) { try {
Logger.i(TAG, "Failed to stop ServiceDiscoverer", e) stopServiceDiscovery(it.value)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
} }
} }
@@ -237,8 +213,90 @@ class StateCasting {
_castServer.removeAllHandlers(); _castServer.removeAllHandlers();
Logger.i(TAG, "CastingService stopped.") Logger.i(TAG, "CastingService stopped.")
_nsdManager = null
} }
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
return object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(regType: String) {
Log.d(TAG, "Service discovery started for $regType")
}
override fun onDiscoveryStopped(serviceType: String) {
Log.i(TAG, "Discovery stopped: $serviceType")
}
override fun onServiceLost(service: NsdServiceInfo) {
Log.e(TAG, "service lost: $service")
// TODO: Handle service lost, e.g., remove device
}
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onServiceFound(service: NsdServiceInfo) {
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
service.hostAddresses.toTypedArray()
} else {
arrayOf(service.host)
}
addOrUpdate(service.serviceName, addresses, service.port)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
_nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback {
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "onServiceUpdated: $serviceInfo")
addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port)
}
override fun onServiceLost() {
Log.v(TAG, "onServiceLost: $service")
// TODO: Handle service lost
}
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
}
override fun onServiceInfoCallbackUnregistered() {
Log.v(TAG, "onServiceInfoCallbackUnregistered")
}
})
} else {
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
Log.v(TAG, "Resolve failed: $errorCode")
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port)
}
})
}
}
}
}
private val _castingDialogLock = Any();
private var _currentDialog: AlertDialog? = null;
@Synchronized @Synchronized
fun connectDevice(device: CastingDevice) { fun connectDevice(device: CastingDevice) {
if (activeDevice == device) if (activeDevice == device)
@@ -272,10 +330,41 @@ class StateCasting {
invokeInMainScopeIfRequired { invokeInMainScopeIfRequired {
StateApp.withContext(false) { context -> StateApp.withContext(false) { context ->
context.let { context.let {
Logger.i(TAG, "Casting state changed to ${castConnectionState}");
when (castConnectionState) { when (castConnectionState) {
CastConnectionState.CONNECTED -> UIDialogs.toast(it, "Connected to device") CastConnectionState.CONNECTED -> {
CastConnectionState.CONNECTING -> UIDialogs.toast(it, "Connecting to device...") Logger.i(TAG, "Casting connected to [${device.name}]");
CastConnectionState.DISCONNECTED -> UIDialogs.toast(it, "Disconnected from device") UIDialogs.appToast("Connected to device")
synchronized(_castingDialogLock) {
if(_currentDialog != null) {
_currentDialog?.hide();
_currentDialog = null;
}
}
}
CastConnectionState.CONNECTING -> {
Logger.i(TAG, "Casting connecting to [${device.name}]");
UIDialogs.toast(it, "Connecting to device...")
synchronized(_castingDialogLock) {
if(_currentDialog == null) {
_currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true,
"Connecting to [${device.name}]",
"Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2,
UIDialogs.Action("Disconnect", {
device.stop();
}));
}
}
}
CastConnectionState.DISCONNECTED -> {
UIDialogs.toast(it, "Disconnected from device")
synchronized(_castingDialogLock) {
if(_currentDialog != null) {
_currentDialog?.hide();
_currentDialog = null;
}
}
}
} }
} }
}; };
@@ -295,9 +384,6 @@ class StateCasting {
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) }; invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
}; };
addRememberedDevice(device);
Logger.i(TAG, "Device added to active discovery. Active discovery now contains ${_storage.getDevicesCount()} devices.")
try { try {
device.start(); device.start();
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -319,21 +405,22 @@ class StateCasting {
return addRememberedDevice(device); return addRememberedDevice(device);
} }
fun getRememberedCastingDevices(): List<CastingDevice> {
return _storage.getDevices().map { deviceFromCastingDeviceInfo(it) }
}
fun getRememberedCastingDeviceNames(): List<String> {
return _storage.getDeviceNames()
}
fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo { fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo {
val deviceInfo = device.getDeviceInfo() val deviceInfo = device.getDeviceInfo()
val foundInfo = _storage.addDevice(deviceInfo) return _storage.addDevice(deviceInfo)
if (foundInfo == deviceInfo) {
rememberedDevices.add(device);
return foundInfo;
}
return foundInfo;
} }
fun removeRememberedDevice(device: CastingDevice) { fun removeRememberedDevice(device: CastingDevice) {
val name = device.name ?: return; val name = device.name ?: return
_storage.removeDevice(name); _storage.removeDevice(name)
rememberedDevices.remove(device);
} }
private fun invokeInMainScopeIfRequired(action: () -> Unit){ private fun invokeInMainScopeIfRequired(action: () -> Unit){
@@ -402,7 +489,7 @@ class StateCasting {
} }
} else { } else {
val proxyStreams = Settings.instance.casting.alwaysProxyRequests; val proxyStreams = Settings.instance.casting.alwaysProxyRequests;
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
if (videoSource is IVideoUrlSource) { if (videoSource is IVideoUrlSource) {
@@ -497,7 +584,7 @@ class StateCasting {
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> { private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val videoPath = "/video-${id}" val videoPath = "/video-${id}"
val videoUrl = url + videoPath; val videoUrl = url + videoPath;
@@ -516,7 +603,7 @@ class StateCasting {
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List<String> { private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val audioPath = "/audio-${id}" val audioPath = "/audio-${id}"
val audioUrl = url + audioPath; val audioUrl = url + audioPath;
@@ -535,7 +622,7 @@ class StateCasting {
private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List<String> { private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List<String> {
val ad = activeDevice ?: return listOf() val ad = activeDevice ?: return listOf()
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}" val url = getLocalUrl(ad)
val id = UUID.randomUUID() val id = UUID.randomUUID()
val hlsPath = "/hls-${id}" val hlsPath = "/hls-${id}"
@@ -631,7 +718,7 @@ class StateCasting {
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List<String> { private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val dashPath = "/dash-${id}" val dashPath = "/dash-${id}"
@@ -681,7 +768,7 @@ class StateCasting {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice; val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val videoPath = "/video-${id}" val videoPath = "/video-${id}"
@@ -746,7 +833,7 @@ class StateCasting {
_castServer.removeAllHandlers("castProxiedHlsMaster") _castServer.removeAllHandlers("castProxiedHlsMaster")
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val hlsPath = "/hls-${id}" val hlsPath = "/hls-${id}"
@@ -916,7 +1003,7 @@ class StateCasting {
private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> { private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val hlsPath = "/hls-${id}" val hlsPath = "/hls-${id}"
@@ -1046,7 +1133,7 @@ class StateCasting {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice; val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val dashPath = "/dash-${id}" val dashPath = "/dash-${id}"
@@ -1132,6 +1219,22 @@ class StateCasting {
} }
} }
private fun getLocalUrl(ad: CastingDevice): String {
var address = ad.localAddress!!
if (Settings.instance.casting.allowLinkLocalIpv4) {
if (address.isLinkLocalAddress && address is Inet6Address) {
address = findPreferredAddress() ?: address
Logger.i(TAG, "Selected casting address: $address")
}
} else {
if (address.isLinkLocalAddress) {
address = findPreferredAddress() ?: address
Logger.i(TAG, "Selected casting address: $address")
}
}
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
}
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> { private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
@@ -1139,7 +1242,7 @@ class StateCasting {
cleanExecutors() cleanExecutors()
_castServer.removeAllHandlers("castDashRaw") _castServer.removeAllHandlers("castDashRaw")
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val dashPath = "/dash-${id}" val dashPath = "/dash-${id}"
@@ -82,7 +82,11 @@ class TaskHandler<TParameter, TResult> {
handled = true; handled = true;
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Handled exception in TaskHandler onSuccess.", e); Logger.w(TAG, "Handled exception in TaskHandler onSuccess.", e);
onError.emit(e, parameter); try {
onError.emit(e, parameter);
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception in .exception handler 1", e)
}
handled = true; handled = true;
} }
} }
@@ -99,10 +103,14 @@ class TaskHandler<TParameter, TResult> {
if (id != _idGenerator) if (id != _idGenerator)
return@withContext; return@withContext;
if (!onError.emit(e, parameter)) { try {
Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e); if (!onError.emit(e, parameter)) {
} else { Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e);
//Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); (Prevents duplicate logs) } else {
//Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); (Prevents duplicate logs)
}
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception in .exception handler 2", e)
} }
} }
} }
@@ -22,7 +22,6 @@ import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComm
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
@@ -30,6 +29,7 @@ import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.ClaimType import com.futo.polycentric.core.ClaimType
import com.futo.polycentric.core.Store import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
@@ -9,7 +9,9 @@ import android.view.View
import android.widget.Button import android.widget.Button
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R import com.futo.platformplayer.R
@@ -21,22 +23,21 @@ import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.adapters.DeviceAdapter import com.futo.platformplayer.views.adapters.DeviceAdapter
import com.futo.platformplayer.views.adapters.DeviceAdapterEntry
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class ConnectCastingDialog(context: Context?) : AlertDialog(context) { class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
private lateinit var _imageLoader: ImageView; private lateinit var _imageLoader: ImageView;
private lateinit var _buttonClose: Button; private lateinit var _buttonClose: Button;
private lateinit var _buttonAdd: ImageButton; private lateinit var _buttonAdd: LinearLayout;
private lateinit var _buttonScanQR: ImageButton; private lateinit var _buttonScanQR: LinearLayout;
private lateinit var _textNoDevicesFound: TextView; private lateinit var _textNoDevicesFound: TextView;
private lateinit var _textNoDevicesRemembered: TextView;
private lateinit var _recyclerDevices: RecyclerView; private lateinit var _recyclerDevices: RecyclerView;
private lateinit var _recyclerRememberedDevices: RecyclerView;
private lateinit var _adapter: DeviceAdapter; private lateinit var _adapter: DeviceAdapter;
private lateinit var _rememberedAdapter: DeviceAdapter; private val _devices: MutableSet<String> = mutableSetOf()
private val _devices: ArrayList<CastingDevice> = arrayListOf(); private val _rememberedDevices: MutableSet<String> = mutableSetOf()
private val _rememberedDevices: ArrayList<CastingDevice> = arrayListOf(); private val _unifiedDevices: MutableList<DeviceAdapterEntry> = mutableListOf()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
@@ -45,42 +46,40 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
_imageLoader = findViewById(R.id.image_loader); _imageLoader = findViewById(R.id.image_loader);
_buttonClose = findViewById(R.id.button_close); _buttonClose = findViewById(R.id.button_close);
_buttonAdd = findViewById(R.id.button_add); _buttonAdd = findViewById(R.id.button_add);
_buttonScanQR = findViewById(R.id.button_scan_qr); _buttonScanQR = findViewById(R.id.button_qr);
_recyclerDevices = findViewById(R.id.recycler_devices); _recyclerDevices = findViewById(R.id.recycler_devices);
_recyclerRememberedDevices = findViewById(R.id.recycler_remembered_devices);
_textNoDevicesFound = findViewById(R.id.text_no_devices_found); _textNoDevicesFound = findViewById(R.id.text_no_devices_found);
_textNoDevicesRemembered = findViewById(R.id.text_no_devices_remembered);
_adapter = DeviceAdapter(_devices, false); _adapter = DeviceAdapter(_unifiedDevices)
_recyclerDevices.adapter = _adapter; _recyclerDevices.adapter = _adapter;
_recyclerDevices.layoutManager = LinearLayoutManager(context); _recyclerDevices.layoutManager = LinearLayoutManager(context);
_rememberedAdapter = DeviceAdapter(_rememberedDevices, true); _adapter.onPin.subscribe { d ->
_rememberedAdapter.onRemove.subscribe { d -> val isRemembered = _rememberedDevices.contains(d.name)
if (StateCasting.instance.activeDevice == d) { val newIsRemembered = !isRemembered
d.stopCasting(); if (newIsRemembered) {
StateCasting.instance.addRememberedDevice(d)
val name = d.name
if (name != null) {
_rememberedDevices.add(name)
}
} else {
StateCasting.instance.removeRememberedDevice(d)
_rememberedDevices.remove(d.name)
} }
updateUnifiedList()
StateCasting.instance.removeRememberedDevice(d);
val index = _rememberedDevices.indexOf(d);
if (index != -1) {
_rememberedDevices.removeAt(index);
_rememberedAdapter.notifyItemRemoved(index);
}
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
};
_rememberedAdapter.onConnect.subscribe { _ ->
dismiss()
UIDialogs.showCastingDialog(context)
} }
//TODO: Integrate remembered into the main list
//TODO: Add green indicator to indicate a device is oneline
//TODO: Add pinning
//TODO: Implement QR code as an option in add manually
//TODO: Remove start button
_adapter.onConnect.subscribe { _ -> _adapter.onConnect.subscribe { _ ->
dismiss() dismiss()
UIDialogs.showCastingDialog(context) //UIDialogs.showCastingDialog(context)
} }
_recyclerRememberedDevices.adapter = _rememberedAdapter;
_recyclerRememberedDevices.layoutManager = LinearLayoutManager(context);
_buttonClose.setOnClickListener { dismiss(); }; _buttonClose.setOnClickListener { dismiss(); };
_buttonAdd.setOnClickListener { _buttonAdd.setOnClickListener {
@@ -104,78 +103,111 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
super.show(); super.show();
Logger.i(TAG, "Dialog shown."); Logger.i(TAG, "Dialog shown.");
StateCasting.instance.startDiscovering()
(_imageLoader.drawable as Animatable?)?.start(); (_imageLoader.drawable as Animatable?)?.start();
_devices.clear(); synchronized(StateCasting.instance.devices) {
synchronized (StateCasting.instance.devices) { _devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name })
_devices.addAll(StateCasting.instance.devices.values);
} }
_rememberedDevices.clear(); _rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
synchronized (StateCasting.instance.rememberedDevices) { updateUnifiedList()
_rememberedDevices.addAll(StateCasting.instance.rememberedDevices);
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
val name = d.name
if (name != null)
_devices.add(name)
updateUnifiedList()
}
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name }
if (index != -1) {
_unifiedDevices[index] = DeviceAdapterEntry(d, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice)
_adapter.notifyItemChanged(index)
}
}
StateCasting.instance.onDeviceRemoved.subscribe(this) { d ->
_devices.remove(d.name)
updateUnifiedList()
}
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
if (connectionState == CastConnectionState.CONNECTED) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
dismiss()
}
}
} }
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE; _textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE; _recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
_devices.add(d);
_adapter.notifyItemInserted(_devices.size - 1);
_textNoDevicesFound.visibility = View.GONE;
_recyclerDevices.visibility = View.VISIBLE;
};
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
val index = _devices.indexOf(d);
if (index == -1) {
return@subscribe;
}
_devices[index] = d;
_adapter.notifyItemChanged(index);
};
StateCasting.instance.onDeviceRemoved.subscribe(this) { d ->
val index = _devices.indexOf(d);
if (index == -1) {
return@subscribe;
}
_devices.removeAt(index);
_adapter.notifyItemRemoved(index);
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
};
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
if (connectionState != CastConnectionState.CONNECTED) {
return@subscribe;
}
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
dismiss();
};
};
_adapter.notifyDataSetChanged();
_rememberedAdapter.notifyDataSetChanged();
} }
override fun dismiss() { override fun dismiss() {
super.dismiss(); super.dismiss()
(_imageLoader.drawable as Animatable?)?.stop()
StateCasting.instance.onDeviceAdded.remove(this)
StateCasting.instance.onDeviceChanged.remove(this)
StateCasting.instance.onDeviceRemoved.remove(this)
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this)
}
(_imageLoader.drawable as Animatable?)?.stop(); private fun updateUnifiedList() {
val oldList = ArrayList(_unifiedDevices)
val newList = buildUnifiedList()
StateCasting.instance.stopDiscovering() val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
StateCasting.instance.onDeviceAdded.remove(this); override fun getOldListSize() = oldList.size
StateCasting.instance.onDeviceChanged.remove(this); override fun getNewListSize() = newList.size
StateCasting.instance.onDeviceRemoved.remove(this); override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return oldItem.castingDevice.name == newItem.castingDevice.name
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return oldItem.castingDevice.name == newItem.castingDevice.name
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
}
})
_unifiedDevices.clear()
_unifiedDevices.addAll(newList)
diffResult.dispatchUpdatesTo(_adapter)
_textNoDevicesFound.visibility = if (_unifiedDevices.isEmpty()) View.VISIBLE else View.GONE
_recyclerDevices.visibility = if (_unifiedDevices.isNotEmpty()) View.VISIBLE else View.GONE
}
private fun buildUnifiedList(): List<DeviceAdapterEntry> {
val onlineDevices = StateCasting.instance.devices.values.associateBy { it.name }
val rememberedDevices = StateCasting.instance.getRememberedCastingDevices().associateBy { it.name }
val unifiedList = mutableListOf<DeviceAdapterEntry>()
val intersectionNames = _devices.intersect(_rememberedDevices)
for (name in intersectionNames) {
onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, true)) }
}
val onlineOnlyNames = _devices - _rememberedDevices
for (name in onlineOnlyNames) {
onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, false, true)) }
}
val rememberedOnlyNames = _rememberedDevices - _devices
for (name in rememberedOnlyNames) {
rememberedDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, false)) }
}
return unifiedList
} }
companion object { companion object {
@@ -47,6 +47,7 @@ import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.toHumanBitrate import com.futo.platformplayer.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSpeed import com.futo.platformplayer.toHumanBytesSpeed
import com.futo.polycentric.core.hexStringToByteArray
import hasAnySource import hasAnySource
import isDownloadable import isDownloadable
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -59,16 +60,21 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Contextual import kotlinx.serialization.Contextual
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.lang.Thread.sleep import java.lang.Thread.sleep
import java.nio.ByteBuffer
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.UUID import java.util.UUID
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask import java.util.concurrent.ForkJoinTask
import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.ThreadLocalRandom
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlin.time.times import kotlin.time.times
@@ -564,6 +570,14 @@ class VideoDownload {
} }
} }
private fun decryptSegment(encryptedSegment: ByteArray, key: ByteArray, iv: ByteArray): ByteArray {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val secretKey = SecretKeySpec(key, "AES")
val ivSpec = IvParameterSpec(iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec)
return cipher.doFinal(encryptedSegment)
}
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists()) if(targetFile.exists())
targetFile.delete(); targetFile.delete();
@@ -579,6 +593,14 @@ class VideoDownload {
?: throw Exception("Variant playlist content is empty") ?: throw Exception("Variant playlist content is empty")
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl) val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) {
val keyResponse = client.get(variantPlaylist.decryptionInfo.keyUrl)
check(keyResponse.isOk) { "HLS request failed for decryption key: ${keyResponse.code}" }
DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv?.hexStringToByteArray())
} else {
null
}
variantPlaylist.segments.forEachIndexed { index, segment -> variantPlaylist.segments.forEachIndexed { index, segment ->
if (segment !is HLS.MediaSegment) { if (segment !is HLS.MediaSegment) {
return@forEachIndexed return@forEachIndexed
@@ -590,7 +612,7 @@ class VideoDownload {
try { try {
segmentFiles.add(segmentFile) segmentFiles.add(segmentFile)
val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri) { segmentLength, totalRead, lastSpeed -> val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo, index) { segmentLength, totalRead, lastSpeed ->
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed) onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
@@ -630,10 +652,8 @@ class VideoDownload {
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) { private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt") val cmd =
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" }) "-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\""
val statisticsCallback = StatisticsCallback { _ -> val statisticsCallback = StatisticsCallback { _ ->
//TODO: Show progress? //TODO: Show progress?
@@ -643,7 +663,6 @@ class VideoDownload {
val session = FFmpegKit.executeAsync(cmd, val session = FFmpegKit.executeAsync(cmd,
{ session -> { session ->
if (ReturnCode.isSuccess(session.returnCode)) { if (ReturnCode.isSuccess(session.returnCode)) {
fileList.delete()
continuation.resumeWith(Result.success(Unit)) continuation.resumeWith(Result.success(Unit))
} else { } else {
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) { val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
@@ -651,7 +670,6 @@ class VideoDownload {
} else { } else {
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}" "Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
} }
fileList.delete()
continuation.resumeWithException(RuntimeException(errorMessage)) continuation.resumeWithException(RuntimeException(errorMessage))
} }
}, },
@@ -706,7 +724,7 @@ class VideoDownload {
val t = cue.groupValues[1]; val t = cue.groupValues[1];
val d = cue.groupValues[2]; val d = cue.groupValues[2];
val url = foundTemplateUrl.replace("\$Number\$", indexCounter.toString()); val url = foundTemplateUrl.replace("\$Number\$", (indexCounter + 1).toString());
val data = if(executor != null) val data = if(executor != null)
executor.executeRequest("GET", url, null, mapOf()); executor.executeRequest("GET", url, null, mapOf());
@@ -771,7 +789,7 @@ class VideoDownload {
else { else {
Logger.i(TAG, "Download $name Sequential"); Logger.i(TAG, "Download $name Sequential");
try { try {
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, onProgress); sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, 0, onProgress);
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)") Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
throw e throw e
@@ -798,7 +816,31 @@ class VideoDownload {
} }
return sourceLength!!; return sourceLength!!;
} }
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, onProgress: (Long, Long, Long) -> Unit): Long {
data class DecryptionInfo(
val key: ByteArray,
val iv: ByteArray?
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DecryptionInfo
if (!key.contentEquals(other.key)) return false
if (!iv.contentEquals(other.iv)) return false
return true
}
override fun hashCode(): Int {
var result = key.contentHashCode()
result = 31 * result + iv.contentHashCode()
return result
}
}
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long {
val progressRate: Int = 4096 * 5; val progressRate: Int = 4096 * 5;
var lastProgressCount: Int = 0; var lastProgressCount: Int = 0;
val speedRate: Int = 4096 * 5; val speedRate: Int = 4096 * 5;
@@ -818,6 +860,8 @@ class VideoDownload {
val sourceLength = result.body.contentLength(); val sourceLength = result.body.contentLength();
val sourceStream = result.body.byteStream(); val sourceStream = result.body.byteStream();
val segmentBuffer = ByteArrayOutputStream()
var totalRead: Long = 0; var totalRead: Long = 0;
try { try {
var read: Int; var read: Int;
@@ -828,7 +872,7 @@ class VideoDownload {
if (read < 0) if (read < 0)
break; break;
fileStream.write(buffer, 0, read); segmentBuffer.write(buffer, 0, read);
totalRead += read; totalRead += read;
@@ -854,6 +898,21 @@ class VideoDownload {
result.body.close() result.body.close()
} }
if (decryptionInfo != null) {
var iv = decryptionInfo.iv
if (iv == null) {
iv = ByteBuffer.allocate(16)
.putLong(0L)
.putLong(index.toLong())
.array()
}
val decryptedData = decryptSegment(segmentBuffer.toByteArray(), decryptionInfo.key, iv!!)
fileStream.write(decryptedData)
} else {
fileStream.write(segmentBuffer.toByteArray())
}
onProgress(sourceLength, totalRead, 0); onProgress(sourceLength, totalRead, 0);
return sourceLength; return sourceLength;
} }
@@ -1160,6 +1219,8 @@ class VideoDownload {
fun audioContainerToExtension(container: String): String { fun audioContainerToExtension(container: String): String {
if (container.contains("audio/mp4")) if (container.contains("audio/mp4"))
return "mp4a"; return "mp4a";
else if (container.contains("video/mp4"))
return "mp4";
else if (container.contains("audio/mpeg")) else if (container.contains("audio/mpeg"))
return "mpga"; return "mpga";
else if (container.contains("audio/mp3")) else if (container.contains("audio/mp3"))
@@ -1167,7 +1228,7 @@ class VideoDownload {
else if (container.contains("audio/webm")) else if (container.contains("audio/webm"))
return "webm"; return "webm";
else if (container == "application/vnd.apple.mpegurl") else if (container == "application/vnd.apple.mpegurl")
return "mp4a"; return "m4a";
else else
return "audio";// throw IllegalStateException("Unknown container: " + container) return "audio";// throw IllegalStateException("Unknown container: " + container)
} }
@@ -69,7 +69,7 @@ class VideoExport {
outputFile = f; outputFile = f;
} else if (v != null) { } else if (v != null) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container); val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container);
val f = downloadRoot.createFile(v.container, outputFileName) val f = downloadRoot.createFile(if (v.container == "application/vnd.apple.mpegurl") "video/mp4" else v.container, outputFileName)
?: throw Exception("Failed to create file in external directory."); ?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying video."); Logger.i(TAG, "Copying video.");
@@ -81,8 +81,8 @@ class VideoExport {
outputFile = f; outputFile = f;
} else if (a != null) { } else if (a != null) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container); val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container);
val f = downloadRoot.createFile(a.container, outputFileName) val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") "video/mp4" else a.container, outputFileName)
?: throw Exception("Failed to create file in external directory."); ?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying audio."); Logger.i(TAG, "Copying audio.");
@@ -10,7 +10,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.ratings.IRating import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.LocalVideoMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.DownloadedVideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.LocalVideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.LocalVideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
@@ -57,7 +57,7 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem {
override val video: IVideoSourceDescriptor get() = if(audioSource.isNotEmpty()) override val video: IVideoSourceDescriptor get() = if(audioSource.isNotEmpty())
LocalVideoUnMuxedSourceDescriptor(this) LocalVideoUnMuxedSourceDescriptor(this)
else else
LocalVideoMuxedSourceDescriptor(this); DownloadedVideoMuxedSourceDescriptor(this);
override val preview: IVideoSourceDescriptor? get() = videoSerialized.preview; override val preview: IVideoSourceDescriptor? get() = videoSerialized.preview;
override val live: IVideoSource? get() = videoSerialized.live; override val live: IVideoSource? get() = videoSerialized.live;
@@ -188,6 +188,14 @@ class V8Plugin {
whenNotBusy { whenNotBusy {
synchronized(_runtimeLock) { synchronized(_runtimeLock) {
isStopped = true; isStopped = true;
//Cleanup http
for(pack in _depsPackages) {
if(pack is PackageHttp) {
pack.cleanup();
}
}
_runtime?.let { _runtime?.let {
_runtime = null; _runtime = null;
if(!it.isClosed && !it.isDead) { if(!it.isClosed && !it.isDead) {
@@ -12,6 +12,7 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.JSClientConstants import com.futo.platformplayer.api.media.platforms.js.JSClientConstants
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@@ -72,6 +73,26 @@ class PackageBridge : V8Package {
fun buildSpecVersion(): Int { fun buildSpecVersion(): Int {
return JSClientConstants.PLUGIN_SPEC_VERSION; return JSClientConstants.PLUGIN_SPEC_VERSION;
} }
@V8Property
fun buildPlatform(): String {
return "android";
}
@V8Property
fun supportedContent(): Array<Int> {
return arrayOf(
ContentType.MEDIA.value,
ContentType.POST.value,
ContentType.PLAYLIST.value,
ContentType.WEB.value,
ContentType.URL.value,
ContentType.NESTED_VIDEO.value,
ContentType.CHANNEL.value,
ContentType.LOCKED.value,
ContentType.PLACEHOLDER.value,
ContentType.DEFERRED.value
)
}
@V8Function @V8Function
fun dispose(value: V8Value) { fun dispose(value: V8Value) {
@@ -8,9 +8,7 @@ import com.caoccao.javet.enums.V8ProxyMode
import com.caoccao.javet.interop.V8Runtime import com.caoccao.javet.interop.V8Runtime
import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.primitive.V8ValueString import com.caoccao.javet.values.primitive.V8ValueString
import com.caoccao.javet.values.reference.V8ValueArrayBuffer
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.caoccao.javet.values.reference.V8ValueSharedArrayBuffer
import com.caoccao.javet.values.reference.V8ValueTypedArray import com.caoccao.javet.values.reference.V8ValueTypedArray
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@@ -20,15 +18,9 @@ import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.engine.internal.IV8Convertable import com.futo.platformplayer.engine.internal.IV8Convertable
import com.futo.platformplayer.engine.internal.V8BindObject import com.futo.platformplayer.engine.internal.V8BindObject
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask import java.util.concurrent.ForkJoinTask
import kotlin.concurrent.thread
import kotlin.streams.asSequence
class PackageHttp: V8Package { class PackageHttp: V8Package {
@Transient @Transient
@@ -49,6 +41,9 @@ class PackageHttp: V8Package {
private var _batchPoolLock: Any = Any(); private var _batchPoolLock: Any = Any();
private var _batchPool: ForkJoinPool? = null; private var _batchPool: ForkJoinPool? = null;
private val aliveSockets = mutableListOf<SocketResult>();
private var _cleanedUp = false;
constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) { constructor(plugin: V8Plugin, config: IV8PluginConfig): super(plugin) {
_config = config; _config = config;
@@ -58,6 +53,27 @@ class PackageHttp: V8Package {
_packageClientAuth = PackageHttpClient(this, _clientAuth); _packageClientAuth = PackageHttpClient(this, _clientAuth);
} }
fun cleanup(){
Logger.w(TAG, "PackageHttp Cleaning up")
val sockets = synchronized(aliveSockets) { aliveSockets.toList() }
_cleanedUp = true;
for(socket in sockets){
try {
Logger.w(TAG, "PackageHttp Socket Cleaned Up");
socket.close(1001, "Cleanup");
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to close socket", ex);
}
}
if(sockets.size > 0) {
//Thread.sleep(100); //Give sockets a bit
}
synchronized(aliveSockets) {
aliveSockets.clear();
}
}
/* /*
Automatically adjusting threadpool dedicated per PackageHttp for batch requests. Automatically adjusting threadpool dedicated per PackageHttp for batch requests.
@@ -111,24 +127,24 @@ class PackageHttp: V8Package {
@V8Function @V8Function
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse { fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse {
return if(useAuth) return if(useAuth)
_packageClientAuth.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING) _packageClientAuth.requestInternal(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
else else
_packageClient.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING); _packageClient.requestInternal(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
} }
@V8Function @V8Function
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse { fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse {
return if(useAuth) return if(useAuth)
_packageClientAuth.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING) _packageClientAuth.requestWithBodyInternal(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
else else
_packageClient.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING); _packageClient.requestWithBodyInternal(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
} }
@V8Function @V8Function
fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse { fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse {
return if(useAuth) return if(useAuth)
_packageClientAuth.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING) _packageClientAuth.GETInternal(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING)
else else
_packageClient.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); _packageClient.GETInternal(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
} }
@V8Function @V8Function
fun POST(url: String, body: Any, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse { fun POST(url: String, body: Any, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse {
@@ -136,15 +152,15 @@ class PackageHttp: V8Package {
val client = if(useAuth) _packageClientAuth else _packageClient; val client = if(useAuth) _packageClientAuth else _packageClient;
if(body is V8ValueString) if(body is V8ValueString)
return client.POST(url, body.value, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); return client.POSTInternal(url, body.value, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else if(body is String) else if(body is String)
return client.POST(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); return client.POSTInternal(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else if(body is V8ValueTypedArray) else if(body is V8ValueTypedArray)
return client.POST(url, body.toBytes(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); return client.POSTInternal(url, body.toBytes(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else if(body is ByteArray) else if(body is ByteArray)
return client.POST(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); return client.POSTInternal(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else if(body is ArrayList<*>) //Avoid this case, used purely for testing else if(body is ArrayList<*>) //Avoid this case, used purely for testing
return client.POST(url, body.map { (it as Double).toInt().toByte() }.toByteArray(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); return client.POSTInternal(url, body.map { (it as Double).toInt().toByte() }.toByteArray(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else else
throw NotImplementedError("Body type " + body?.javaClass?.name?.toString() + " not implemented for POST"); throw NotImplementedError("Body type " + body?.javaClass?.name?.toString() + " not implemented for POST");
} }
@@ -276,9 +292,9 @@ class PackageHttp: V8Package {
if(it.second.method == "DUMMY") if(it.second.method == "DUMMY")
return@autoParallelPool null; return@autoParallelPool null;
if(it.second.body != null) if(it.second.body != null)
return@autoParallelPool it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType); return@autoParallelPool it.first.requestWithBodyInternal(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType);
else else
return@autoParallelPool it.first.request(it.second.method, it.second.url, it.second.headers, it.second.respType); return@autoParallelPool it.first.requestInternal(it.second.method, it.second.url, it.second.headers, it.second.respType);
}.map { }.map {
if(it.second != null) if(it.second != null)
throw it.second!!; throw it.second!!;
@@ -345,7 +361,9 @@ class PackageHttp: V8Package {
} }
@V8Function @V8Function
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse { fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
= requestInternal(method, url, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING);
fun requestInternal(method: String, url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse {
applyDefaultHeaders(headers); applyDefaultHeaders(headers);
return logExceptions { return logExceptions {
return@logExceptions catchHttp { return@logExceptions catchHttp {
@@ -364,7 +382,9 @@ class PackageHttp: V8Package {
}; };
} }
@V8Function @V8Function
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse { fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
= requestWithBodyInternal(method, url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
fun requestWithBodyInternal(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse {
applyDefaultHeaders(headers); applyDefaultHeaders(headers);
return logExceptions { return logExceptions {
catchHttp { catchHttp {
@@ -385,7 +405,9 @@ class PackageHttp: V8Package {
} }
@V8Function @V8Function
fun GET(url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
= GETInternal(url, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
fun GETInternal(url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
applyDefaultHeaders(headers); applyDefaultHeaders(headers);
return logExceptions { return logExceptions {
catchHttp { catchHttp {
@@ -407,7 +429,9 @@ class PackageHttp: V8Package {
}; };
} }
@V8Function @V8Function
fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
= POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
fun POSTInternal(url: String, body: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
applyDefaultHeaders(headers); applyDefaultHeaders(headers);
return logExceptions { return logExceptions {
catchHttp { catchHttp {
@@ -429,7 +453,9 @@ class PackageHttp: V8Package {
}; };
} }
@V8Function @V8Function
fun POST(url: String, body: ByteArray, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { fun POST(url: String, body: ByteArray, headers: MutableMap<String, String> = HashMap(), useBytes: Boolean = false) : IBridgeHttpResponse
= POSTInternal(url, body, headers, if(useBytes) ReturnType.BYTES else ReturnType.STRING)
fun POSTInternal(url: String, body: ByteArray, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
applyDefaultHeaders(headers); applyDefaultHeaders(headers);
return logExceptions { return logExceptions {
catchHttp { catchHttp {
@@ -453,9 +479,16 @@ class PackageHttp: V8Package {
@V8Function @V8Function
fun socket(url: String, headers: Map<String, String>? = null): SocketResult { fun socket(url: String, headers: Map<String, String>? = null): SocketResult {
if(_package._cleanedUp)
throw IllegalStateException("Plugin shutdown");
val socketHeaders = headers?.toMutableMap() ?: HashMap(); val socketHeaders = headers?.toMutableMap() ?: HashMap();
applyDefaultHeaders(socketHeaders); applyDefaultHeaders(socketHeaders);
return SocketResult(this, _client, url, socketHeaders); val socket = SocketResult(_package, this, _client, url, socketHeaders);
Logger.w(TAG, "PackageHttp Socket opened");
synchronized(_package.aliveSockets) {
_package.aliveSockets.add(socket);
}
return socket;
} }
private fun applyDefaultHeaders(headerMap: MutableMap<String, String>) { private fun applyDefaultHeaders(headerMap: MutableMap<String, String>) {
@@ -561,13 +594,15 @@ class PackageHttp: V8Package {
private var _listeners: V8ValueObject? = null; private var _listeners: V8ValueObject? = null;
private val _package: PackageHttp;
private val _packageClient: PackageHttpClient; private val _packageClient: PackageHttpClient;
private val _client: ManagedHttpClient; private val _client: ManagedHttpClient;
private val _url: String; private val _url: String;
private val _headers: Map<String, String>; private val _headers: Map<String, String>;
constructor(pack: PackageHttpClient, client: ManagedHttpClient, url: String, headers: Map<String,String>) { constructor(parent: PackageHttp, pack: PackageHttpClient, client: ManagedHttpClient, url: String, headers: Map<String,String>) {
_packageClient = pack; _packageClient = pack;
_package = parent;
_client = client; _client = client;
_url = url; _url = url;
_headers = headers; _headers = headers;
@@ -593,7 +628,7 @@ class PackageHttp: V8Package {
override fun open() { override fun open() {
Logger.i(TAG, "Websocket opened: " + _url); Logger.i(TAG, "Websocket opened: " + _url);
_isOpen = true; _isOpen = true;
if(hasOpen) { if(hasOpen && _listeners?.isClosed != true) {
try { try {
_listeners?.invokeVoid("open", arrayOf<Any>()); _listeners?.invokeVoid("open", arrayOf<Any>());
} }
@@ -603,7 +638,7 @@ class PackageHttp: V8Package {
} }
} }
override fun message(msg: String) { override fun message(msg: String) {
if(hasMessage) { if(hasMessage && _listeners?.isClosed != true) {
try { try {
_listeners?.invokeVoid("message", msg); _listeners?.invokeVoid("message", msg);
} }
@@ -611,7 +646,7 @@ class PackageHttp: V8Package {
} }
} }
override fun closing(code: Int, reason: String) { override fun closing(code: Int, reason: String) {
if(hasClosing) if(hasClosing && _listeners?.isClosed != true)
{ {
try { try {
_listeners?.invokeVoid("closing", code, reason); _listeners?.invokeVoid("closing", code, reason);
@@ -623,7 +658,7 @@ class PackageHttp: V8Package {
} }
override fun closed(code: Int, reason: String) { override fun closed(code: Int, reason: String) {
_isOpen = false; _isOpen = false;
if(hasClosed) { if(hasClosed && _listeners?.isClosed != true) {
try { try {
_listeners?.invokeVoid("closed", code, reason); _listeners?.invokeVoid("closed", code, reason);
} }
@@ -631,11 +666,15 @@ class PackageHttp: V8Package {
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex); Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
} }
} }
Logger.w(TAG, "PackageHttp Socket removed");
synchronized(_package.aliveSockets) {
_package.aliveSockets.remove(this@SocketResult);
}
} }
override fun failure(exception: Throwable) { override fun failure(exception: Throwable) {
_isOpen = false; _isOpen = false;
Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception); Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception);
if(hasFailure) { if(hasFailure && _listeners?.isClosed != true) {
try { try {
_listeners?.invokeVoid("failure", exception.message); _listeners?.invokeVoid("failure", exception.message);
} }
@@ -13,7 +13,6 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
import com.futo.platformplayer.fixHtmlLinks import com.futo.platformplayer.fixHtmlLinks
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
@@ -21,6 +20,7 @@ import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.platform.PlatformLinkView import com.futo.platformplayer.views.platform.PlatformLinkView
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.toName import com.futo.polycentric.core.toName
import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.futo.polycentric.core.toURLInfoSystemLinkUrl
@@ -134,9 +134,7 @@ class ChannelAboutFragment : Fragment, IChannelTabFragment {
} }
} }
if(!map.containsKey("Harbor")) if(!map.containsKey("Harbor"))
this.context?.let { map.set("Harbor", polycentricProfile.getHarborUrl());
map.set("Harbor", polycentricProfile.getHarborUrl(it));
}
if (map.isNotEmpty()) if (map.isNotEmpty())
setLinks(map, if (polycentricProfile.systemState.username.isNotBlank()) polycentricProfile.systemState.username else _lastChannel?.name ?: "") setLinks(map, if (polycentricProfile.systemState.username.isNotBlank()) polycentricProfile.systemState.username else _lastChannel?.name ?: "")
@@ -5,6 +5,7 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
@@ -25,20 +26,23 @@ import com.futo.platformplayer.api.media.structures.MultiPager
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.fragment.mainactivity.main.FeedView import com.futo.platformplayer.fragment.mainactivity.main.FeedView
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.SearchView
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
import com.futo.polycentric.core.PolycentricProfile
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.max import kotlin.math.max
@@ -54,6 +58,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
private var _results: ArrayList<IPlatformContent> = arrayListOf(); private var _results: ArrayList<IPlatformContent> = arrayListOf();
private var _adapterResults: InsertedViewAdapterWithLoader<ContentPreviewViewHolder>? = null; private var _adapterResults: InsertedViewAdapterWithLoader<ContentPreviewViewHolder>? = null;
private var _lastPolycentricProfile: PolycentricProfile? = null; private var _lastPolycentricProfile: PolycentricProfile? = null;
private var _query: String? = null
private var _searchView: SearchView? = null
val onContentClicked = Event2<IPlatformContent, Long>(); val onContentClicked = Event2<IPlatformContent, Long>();
val onContentUrlClicked = Event2<String, ContentType>(); val onContentUrlClicked = Event2<String, ContentType>();
@@ -68,16 +74,32 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> { private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
Logger.i(TAG, "getContentPager"); Logger.i(TAG, "getContentPager");
val lastPolycentricProfile = _lastPolycentricProfile; var pager: IPager<IPlatformContent>? = null
var pager: IPager<IPlatformContent>? = null; val query = _query
if (lastPolycentricProfile != null) if (!query.isNullOrBlank()) {
pager= StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile); if(subType != null) {
Logger.i(TAG, "StatePlatform.instance.searchChannel(channel.url = ${channel.url}, query = ${query}, subType = ${subType})")
pager = StatePlatform.instance.searchChannel(channel.url, query, subType);
} else {
Logger.i(TAG, "StatePlatform.instance.searchChannel(channel.url = ${channel.url}, query = ${query})")
pager = StatePlatform.instance.searchChannel(channel.url, query);
}
} else {
val lastPolycentricProfile = _lastPolycentricProfile;
if (lastPolycentricProfile != null && StatePolycentric.instance.enabled) {
pager = StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = subType);
Logger.i(TAG, "StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = ${subType})")
}
if(pager == null) { if(pager == null) {
if(subType != null) if(subType != null) {
pager = StatePlatform.instance.getChannelContent(channel.url, subType); pager = StatePlatform.instance.getChannelContent(channel.url, subType);
else Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url}, subType = ${subType})")
pager = StatePlatform.instance.getChannelContent(channel.url); } else {
pager = StatePlatform.instance.getChannelContent(channel.url);
Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url})")
}
}
} }
return pager; return pager;
} }
@@ -144,19 +166,49 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
_taskLoadVideos.cancel(); _taskLoadVideos.cancel();
_query = null
_channel = channel; _channel = channel;
updateSearchViewVisibility()
_results.clear(); _results.clear();
_adapterResults?.notifyDataSetChanged(); _adapterResults?.notifyDataSetChanged();
loadInitial(); loadInitial();
} }
private fun updateSearchViewVisibility() {
if (subType != null) {
_searchView?.visibility = View.GONE
return
}
val client = _channel?.id?.pluginId?.let { StatePlatform.instance.getClientOrNull(it) }
Logger.i(TAG, "_searchView.visible = ${client?.capabilities?.hasSearchChannelContents == true}")
_searchView?.visibility = if (client?.capabilities?.hasSearchChannelContents == true) View.VISIBLE else View.GONE
}
fun setQuery(query: String) {
_query = query
_taskLoadVideos.cancel()
_results.clear()
_adapterResults?.notifyDataSetChanged()
loadInitial()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_channel_videos, container, false); val view = inflater.inflate(R.layout.fragment_channel_videos, container, false);
_query = null
_recyclerResults = view.findViewById(R.id.recycler_videos); _recyclerResults = view.findViewById(R.id.recycler_videos);
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar).apply { val searchView = SearchView(requireContext()).apply { layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT) }.apply {
onEnter.subscribe {
setQuery(it)
}
}
_searchView = searchView
updateSearchViewVisibility()
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply {
this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit); this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit);
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit); this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit); this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
@@ -173,6 +225,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
_recyclerResults?.layoutManager = _glmVideo; _recyclerResults?.layoutManager = _glmVideo;
_recyclerResults?.addOnScrollListener(_scrollListener); _recyclerResults?.addOnScrollListener(_scrollListener);
return view; return view;
} }
@@ -181,6 +234,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
_recyclerResults?.removeOnScrollListener(_scrollListener); _recyclerResults?.removeOnScrollListener(_scrollListener);
_recyclerResults = null; _recyclerResults = null;
_pager = null; _pager = null;
_query = null
_searchView = null
_taskLoadVideos.cancel(); _taskLoadVideos.cancel();
_nextPageHandler.cancel(); _nextPageHandler.cancel();
@@ -303,6 +358,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
} }
private fun loadInitial() { private fun loadInitial() {
Logger.i(TAG, "loadInitial")
val channel: IPlatformChannel = _channel ?: return; val channel: IPlatformChannel = _channel ?: return;
setLoading(true); setLoading(true);
_taskLoadVideos.run(channel); _taskLoadVideos.run(channel);
@@ -16,12 +16,12 @@ import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.viewholders.CreatorViewHolder import com.futo.platformplayer.views.adapters.viewholders.CreatorViewHolder
import com.futo.polycentric.core.PolycentricProfile
class ChannelListFragment : Fragment, IChannelTabFragment { class ChannelListFragment : Fragment, IChannelTabFragment {
private var _channels: ArrayList<IPlatformChannel> = arrayListOf(); private var _channels: ArrayList<IPlatformChannel> = arrayListOf();
@@ -8,8 +8,8 @@ import android.widget.TextView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.views.SupportView import com.futo.platformplayer.views.SupportView
import com.futo.polycentric.core.PolycentricProfile
class ChannelMonetizationFragment : Fragment, IChannelTabFragment { class ChannelMonetizationFragment : Fragment, IChannelTabFragment {
@@ -1,7 +1,7 @@
package com.futo.platformplayer.fragment.channel.tab package com.futo.platformplayer.fragment.channel.tab
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.polycentric.core.PolycentricProfile
interface IChannelTabFragment { interface IChannelTabFragment {
fun setChannel(channel: IPlatformChannel) fun setChannel(channel: IPlatformChannel)
@@ -23,7 +23,7 @@ open class MainActivityFragment : Fragment() {
fun navigate(frag: MainFragment, parameter: Any? = null, withHistory: Boolean = true) { fun navigate(frag: MainFragment, parameter: Any? = null, withHistory: Boolean = true) {
val a = activity val a = activity
if (a is MainActivity) if (a is MainActivity)
(activity as MainActivity).navigate(frag, parameter, withHistory) (activity as MainActivity).navigate(frag, parameter, withHistory, false)
else else
Log.e(TAG, "Failed to navigate due to activity not being a main activity.") Log.e(TAG, "Failed to navigate due to activity not being a main activity.")
} }
@@ -330,7 +330,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
} }
if (!StatePayment.instance.hasPaid) { if (!StatePayment.instance.hasPaid) {
newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid_filled, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate<BuyFragment>() })) newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid_filled, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate<BuyFragment>(withHistory = true) }))
} }
//Add conditional buttons here, when you add a conditional button, be sure to add the register and unregister events for when the button needs to be updated //Add conditional buttons here, when you add a conditional button, be sure to add the register and unregister events for when the button needs to be updated
@@ -383,20 +383,20 @@ class MenuBottomBarFragment : MainActivityFragment() {
currentMain.scrollToTop(false) currentMain.scrollToTop(false)
currentMain.reloadFeed() currentMain.reloadFeed()
} else { } else {
it.navigate<HomeFragment>() it.navigate<HomeFragment>(withHistory = false)
} }
}), }),
ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate<SubscriptionsFeedFragment>() }), ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate<SubscriptionsFeedFragment>(withHistory = false) }),
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>() }), ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>(withHistory = false) }),
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>() }), ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>(withHistory = false) }),
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>() }), ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>(withHistory = false) }),
ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>() }), ButtonDefinition(5, R.drawable.ic_history, R.drawable.ic_history, R.string.history, canToggle = false, { it.currentMain is HistoryFragment }, { it.navigate<HistoryFragment>(withHistory = false) }),
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>() }), ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>(withHistory = false) }),
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>() }), ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>(withHistory = false) }),
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>() }), ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>(withHistory = false) }),
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>() }), ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>(withHistory = false) }),
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, { ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
val c = it.context ?: return@ButtonDefinition; val c = it.context ?: return@ButtonDefinition;
Logger.i(TAG, "settings preventPictureInPicture()"); Logger.i(TAG, "settings preventPictureInPicture()");
it.requireFragment<VideoDetailFragment>().preventPictureInPicture(); it.requireFragment<VideoDetailFragment>().preventPictureInPicture();
val intent = Intent(c, SettingsActivity::class.java); val intent = Intent(c, SettingsActivity::class.java);
@@ -416,7 +416,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
}, UIDialogs.ActionStyle.PRIMARY)); }, UIDialogs.ActionStyle.PRIMARY));
}), }),
ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, { ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, {
it.navigate<BrowserFragment>(Settings.URL_FAQ); it.navigate<BrowserFragment>(Settings.URL_FAQ, withHistory = false);
}) })
//96 is reserved for privacy button //96 is reserved for privacy button
//98 is reserved for buy button //98 is reserved for buy button
@@ -0,0 +1,814 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.Animatable
import android.os.Bundle
import android.text.Html
import android.text.method.ScrollingMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewPropertyAnimator
import android.widget.Button
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.children
import androidx.core.view.isVisible
import androidx.core.view.setPadding
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails
import com.futo.platformplayer.api.media.models.post.TextType
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.models.JSArticleDetails
import com.futo.platformplayer.api.media.platforms.js.models.JSHeaderSegment
import com.futo.platformplayer.api.media.platforms.js.models.JSImagesSegment
import com.futo.platformplayer.api.media.platforms.js.models.JSNestedSegment
import com.futo.platformplayer.api.media.platforms.js.models.JSTextSegment
import com.futo.platformplayer.api.media.platforms.js.models.SegmentType
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.fixHtmlWhitespace
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
import com.futo.platformplayer.sp
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.feedtypes.PreviewLockedView
import com.futo.platformplayer.views.adapters.feedtypes.PreviewNestedVideoView
import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView
import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoView
import com.futo.platformplayer.views.comments.AddCommentView
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.RepliesOverlay
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.segments.CommentsList
import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Models
import com.futo.polycentric.core.Opinion
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import com.google.android.flexbox.FlexboxLayout
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.ShapeAppearanceModel
import com.google.protobuf.ByteString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import userpackage.Protocol
import java.lang.Integer.min
class ArticleDetailFragment : MainFragment {
override val isMainView: Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var _viewDetail: ArticleDetailView? = null;
constructor() : super() { }
override fun onBackPressed(): Boolean {
return false;
}
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = ArticleDetailView(inflater.context).applyFragment(this);
_viewDetail = view;
return view;
}
override fun onDestroyMainView() {
super.onDestroyMainView();
_viewDetail?.onDestroy();
_viewDetail = null;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
if (parameter is IPlatformArticleDetails) {
_viewDetail?.clear();
_viewDetail?.setArticleDetails(parameter);
} else if (parameter is IPlatformArticle) {
_viewDetail?.setArticleOverview(parameter);
} else if(parameter is String) {
_viewDetail?.setPostUrl(parameter);
}
}
private class ArticleDetailView : ConstraintLayout {
private lateinit var _fragment: ArticleDetailFragment;
private var _url: String? = null;
private var _isLoading = false;
private var _article: IPlatformArticleDetails? = null;
private var _articleOverview: IPlatformArticle? = null;
private var _polycentricProfile: PolycentricProfile? = null;
private var _version = 0;
private var _isRepliesVisible: Boolean = false;
private var _repliesAnimator: ViewPropertyAnimator? = null;
private val _creatorThumbnail: CreatorThumbnail;
private val _buttonSubscribe: SubscribeButton;
private val _channelName: TextView;
private val _channelMeta: TextView;
private val _textTitle: TextView;
private val _textMeta: TextView;
private val _textSummary: TextView;
private val _containerSegments: LinearLayout;
private val _platformIndicator: PlatformIndicator;
private val _buttonShare: ImageButton;
private val _layoutRating: LinearLayout;
private val _imageLikeIcon: ImageView;
private val _textLikes: TextView;
private val _imageDislikeIcon: ImageView;
private val _textDislikes: TextView;
private val _addCommentView: AddCommentView;
private val _rating: PillRatingLikesDislikes;
private val _layoutLoadingOverlay: FrameLayout;
private val _imageLoader: ImageView;
private var _overlayContainer: FrameLayout
private val _repliesOverlay: RepliesOverlay;
private val _commentsList: CommentsList;
private var _commentType: Boolean? = null;
private val _buttonPolycentric: Button
private val _buttonPlatform: Button
private val _taskLoadPost = if(!isInEditMode) TaskHandler<String, IPlatformArticleDetails>(
StateApp.instance.scopeGetter,
{
val result = StatePlatform.instance.getContentDetails(it).await();
if(result !is IPlatformArticleDetails)
throw IllegalStateException(context.getString(R.string.expected_media_content_found) + " ${result.contentType}");
return@TaskHandler result;
})
.success { setArticleDetails(it) }
.exception<Throwable> {
Logger.w(ChannelFragment.TAG, context.getString(R.string.failed_to_load_post), it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment);
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, {
if (!StatePolycentric.instance.enabled)
return@TaskHandler null
ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!)
})
.success { it -> setPolycentricProfile(it, animate = true) }
.exception<Throwable> {
Logger.w(TAG, "Failed to load claims.", it);
};
constructor(context: Context) : super(context) {
inflate(context, R.layout.fragview_article_detail, this);
val root = findViewById<FrameLayout>(R.id.root);
_creatorThumbnail = findViewById(R.id.creator_thumbnail);
_buttonSubscribe = findViewById(R.id.button_subscribe);
_channelName = findViewById(R.id.text_channel_name);
_channelMeta = findViewById(R.id.text_channel_meta);
_textTitle = findViewById(R.id.text_title);
_textMeta = findViewById(R.id.text_meta);
_textSummary = findViewById(R.id.text_summary);
_containerSegments = findViewById(R.id.container_segments);
_platformIndicator = findViewById(R.id.platform_indicator);
_buttonShare = findViewById(R.id.button_share);
_overlayContainer = findViewById(R.id.overlay_container);
_layoutRating = findViewById(R.id.layout_rating);
_imageLikeIcon = findViewById(R.id.image_like_icon);
_textLikes = findViewById(R.id.text_likes);
_imageDislikeIcon = findViewById(R.id.image_dislike_icon);
_textDislikes = findViewById(R.id.text_dislikes);
_commentsList = findViewById(R.id.comments_list);
_addCommentView = findViewById(R.id.add_comment_view);
_rating = findViewById(R.id.rating);
_layoutLoadingOverlay = findViewById(R.id.layout_loading_overlay);
_imageLoader = findViewById(R.id.image_loader);
_repliesOverlay = findViewById(R.id.replies_overlay);
_buttonPolycentric = findViewById(R.id.button_polycentric)
_buttonPlatform = findViewById(R.id.button_platform)
_buttonSubscribe.onSubscribed.subscribe {
//TODO: add overlay to layout
//UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
};
val layoutTop: LinearLayout = findViewById(R.id.layout_top);
root.removeView(layoutTop);
_commentsList.setPrependedView(layoutTop);
/*TODO: Why is this here?
_commentsList.onCommentsLoaded.subscribe {
updateCommentType(false);
};*/
_commentsList.onRepliesClick.subscribe { c ->
val replyCount = c.replyCount ?: 0;
var metadata = "";
if (replyCount > 0) {
metadata += "$replyCount " + context.getString(R.string.replies);
}
if (c is PolycentricPlatformComment) {
var parentComment: PolycentricPlatformComment = c;
_repliesOverlay.load(_commentType!!, metadata, c.contextUrl, c.reference, c,
{ StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
{
val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1);
_commentsList.replaceComment(parentComment, newComment);
parentComment = newComment;
});
} else {
_repliesOverlay.load(_commentType!!, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
}
setRepliesOverlayVisible(isVisible = true, animate = true);
};
if (StatePolycentric.instance.enabled) {
_buttonPolycentric.setOnClickListener {
updateCommentType(false)
}
} else {
_buttonPolycentric.visibility = View.GONE
}
_buttonPlatform.setOnClickListener {
updateCommentType(true)
}
_addCommentView.onCommentAdded.subscribe {
_commentsList.addComment(it);
};
_repliesOverlay.onClose.subscribe { setRepliesOverlayVisible(isVisible = false, animate = true); };
_buttonShare.setOnClickListener { share() };
_creatorThumbnail.onClick.subscribe { openChannel() };
_channelName.setOnClickListener { openChannel() };
_channelMeta.setOnClickListener { openChannel() };
}
private fun openChannel() {
val author = _article?.author ?: _articleOverview?.author ?: return;
_fragment.navigate<ChannelFragment>(author);
}
private fun share() {
try {
Logger.i(PreviewPostView.TAG, "sharePost")
val url = _article?.shareUrl ?: _articleOverview?.shareUrl ?: _url;
_fragment.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND;
putExtra(Intent.EXTRA_TEXT, url);
type = "text/plain"; //TODO: Determine alt types?
}, null));
} catch (e: Throwable) {
//Ignored
Logger.e(PreviewPostView.TAG, "Failed to share.", e);
}
}
private fun updatePolycentricRating() {
_rating.visibility = View.GONE;
val ref = Models.referenceFromBuffer((_article?.url ?: _articleOverview?.url)?.toByteArray() ?: return)
val extraBytesRef = (_article?.id?.value ?: _articleOverview?.id?.value)?.let { if (it.isNotEmpty()) it.toByteArray() else null }
val version = _version;
_rating.onLikeDislikeUpdated.remove(this);
if (!StatePolycentric.instance.enabled)
return
_fragment.lifecycleScope.launch(Dispatchers.IO) {
if (version != _version) {
return@launch;
}
try {
val queryReferencesResponse = ApiMethods.getQueryReferences(ApiMethods.SERVER, ref, null,null,
arrayListOf(
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(
ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.like.data)).build(),
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(
ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.dislike.data)).build()
),
extraByteReferences = listOfNotNull(extraBytesRef)
);
if (version != _version) {
return@launch;
}
val likes = queryReferencesResponse.countsList[0];
val dislikes = queryReferencesResponse.countsList[1];
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
withContext(Dispatchers.Main) {
if (version != _version) {
return@withContext;
}
_rating.visibility = VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) {
args.processHandle.opinion(ref, Opinion.dislike);
} else {
args.processHandle.opinion(ref, Opinion.neutral);
}
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e)
}
}
StatePolycentric.instance.updateLikeMap(ref, args.hasLiked, args.hasDisliked)
};
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
_rating.visibility = View.GONE;
}
}
}
private fun setPlatformRating(rating: IRating?) {
if (rating == null) {
_layoutRating.visibility = View.GONE;
return;
}
_layoutRating.visibility = View.VISIBLE;
when (rating) {
is RatingLikeDislikes -> {
_textLikes.visibility = View.VISIBLE;
_imageLikeIcon.visibility = View.VISIBLE;
_textLikes.text = rating.likes.toHumanNumber();
_imageDislikeIcon.visibility = View.VISIBLE;
_textDislikes.visibility = View.VISIBLE;
_textDislikes.text = rating.dislikes.toHumanNumber();
}
is RatingLikes -> {
_textLikes.visibility = View.VISIBLE;
_imageLikeIcon.visibility = View.VISIBLE;
_textLikes.text = rating.likes.toHumanNumber();
_imageDislikeIcon.visibility = View.GONE;
_textDislikes.visibility = View.GONE;
}
else -> {
_textLikes.visibility = View.GONE;
_imageLikeIcon.visibility = View.GONE;
_imageDislikeIcon.visibility = View.GONE;
_textDislikes.visibility = View.GONE;
}
}
}
fun applyFragment(frag: ArticleDetailFragment): ArticleDetailView {
_fragment = frag;
return this;
}
fun clear() {
_commentsList.cancel();
_taskLoadPost.cancel();
_taskLoadPolycentricProfile.cancel();
_version++;
updateCommentType(null)
_url = null;
_article = null;
_articleOverview = null;
_creatorThumbnail.clear();
//_buttonSubscribe.setSubscribeChannel(null); TODO: clear button
_channelName.text = "";
setChannelMeta(null);
_textTitle.text = "";
_textMeta.text = "";
setPlatformRating(null);
_polycentricProfile = null;
_rating.visibility = View.GONE;
updatePolycentricRating();
setRepliesOverlayVisible(isVisible = false, animate = false);
_containerSegments.removeAllViews();
_addCommentView.setContext(null, null);
_platformIndicator.clearPlatform();
}
fun setArticleDetails(value: IPlatformArticleDetails) {
_url = value.url;
_article = value;
_creatorThumbnail.setThumbnail(value.author.thumbnail, false);
_buttonSubscribe.setSubscribeChannel(value.author.url);
_channelName.text = value.author.name;
setChannelMeta(value);
_textTitle.text = value.name;
_textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count?
_textSummary.text = value.summary
_textSummary.isVisible = !value.summary.isNullOrEmpty()
_platformIndicator.setPlatformFromClientID(value.id.pluginId);
setPlatformRating(value.rating);
for(seg in value.segments) {
when(seg.type) {
SegmentType.HEADER -> {
if(seg is JSHeaderSegment) {
_containerSegments.addView(ArticleHeaderBlock(context, seg.content, seg.level))
}
}
SegmentType.TEXT -> {
if(seg is JSTextSegment) {
_containerSegments.addView(ArticleTextBlock(context, seg.content, seg.textType))
}
}
SegmentType.IMAGES -> {
if(seg is JSImagesSegment) {
if(seg.images.size > 0)
_containerSegments.addView(ArticleImageBlock(context, seg.images[0], seg.caption))
}
}
SegmentType.NESTED -> {
if(seg is JSNestedSegment) {
_containerSegments.addView(ArticleContentBlock(context, seg.nested, _fragment, _overlayContainer));
}
}
else ->{}
}
}
//Fetch only when not already called in setPostOverview
if (_articleOverview == null) {
fetchPolycentricProfile();
updatePolycentricRating();
_addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray()));
}
val commentType = !Settings.instance.other.polycentricEnabled || Settings.instance.comments.defaultCommentSection == 1
updateCommentType(commentType, true);
setLoading(false);
}
fun setArticleOverview(value: IPlatformArticle) {
clear();
_url = value.url;
_articleOverview = value;
_creatorThumbnail.setThumbnail(value.author.thumbnail, false);
_buttonSubscribe.setSubscribeChannel(value.author.url);
_channelName.text = value.author.name;
setChannelMeta(value);
_textTitle.text = value.name;
_textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count?
_platformIndicator.setPlatformFromClientID(value.id.pluginId);
_addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray()));
updatePolycentricRating();
fetchPolycentricProfile();
fetchPost();
}
private fun setRepliesOverlayVisible(isVisible: Boolean, animate: Boolean) {
if (_isRepliesVisible == isVisible) {
return;
}
_isRepliesVisible = isVisible;
_repliesAnimator?.cancel();
if (isVisible) {
_repliesOverlay.visibility = View.VISIBLE;
if (animate) {
_repliesOverlay.translationY = _repliesOverlay.height.toFloat();
_repliesAnimator = _repliesOverlay.animate()
.setDuration(300)
.translationY(0f)
.withEndAction {
_repliesAnimator = null;
}.apply { start() };
}
} else {
if (animate) {
_repliesOverlay.translationY = 0f;
_repliesAnimator = _repliesOverlay.animate()
.setDuration(300)
.translationY(_repliesOverlay.height.toFloat())
.withEndAction {
_repliesOverlay.visibility = GONE;
_repliesAnimator = null;
}.apply { start(); }
} else {
_repliesOverlay.visibility = View.GONE;
_repliesOverlay.translationY = _repliesOverlay.height.toFloat();
}
}
}
private fun fetchPolycentricProfile() {
val author = _article?.author ?: _articleOverview?.author ?: return;
setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(author.id);
}
private fun setChannelMeta(value: IPlatformArticle?) {
val subscribers = value?.author?.subscribers;
if(subscribers != null && subscribers > 0) {
_channelMeta.visibility = View.VISIBLE;
_channelMeta.text = if((value.author.subscribers ?: 0) > 0) value.author.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers) else "";
} else {
_channelMeta.visibility = View.GONE;
_channelMeta.text = "";
}
}
fun setPostUrl(url: String) {
clear();
_url = url;
fetchPost();
}
fun onDestroy() {
_commentsList.cancel();
_taskLoadPost.cancel();
_repliesOverlay.cleanup();
}
private fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) {
_polycentricProfile = polycentricProfile;
val pp = _polycentricProfile;
if (pp == null) {
_creatorThumbnail.setHarborAvailable(false, animate, null);
return;
}
_creatorThumbnail.setHarborAvailable(true, animate, pp.system.toProto());
}
private fun fetchPost() {
Logger.i(TAG, "fetchVideo")
_article = null;
val url = _url;
if (!url.isNullOrBlank()) {
setLoading(true);
_taskLoadPost.run(url);
}
}
private fun fetchComments() {
Logger.i(TAG, "fetchComments")
_article?.let {
_commentsList.load(true) { StatePlatform.instance.getComments(it); };
}
}
private fun fetchPolycentricComments() {
Logger.i(TAG, "fetchPolycentricComments")
val post = _article;
val ref = (_article?.url ?: _articleOverview?.url)?.toByteArray()?.let { Models.referenceFromBuffer(it) }
val extraBytesRef = (_article?.id?.value ?: _articleOverview?.id?.value)?.let { if (it.isNotEmpty()) it.toByteArray() else null }
if (ref == null) {
Logger.w(TAG, "Failed to fetch polycentric comments because url was not set null")
_commentsList.clear();
return
}
_commentsList.load(false) { StatePolycentric.instance.getCommentPager(post!!.url, ref, listOfNotNull(extraBytesRef)); };
}
private fun updateCommentType(commentType: Boolean?, forceReload: Boolean = false) {
val changed = commentType != _commentType
_commentType = commentType
if (commentType == null) {
_buttonPlatform.setTextColor(resources.getColor(R.color.gray_ac))
_buttonPolycentric.setTextColor(resources.getColor(R.color.gray_ac))
} else {
_buttonPlatform.setTextColor(resources.getColor(if (commentType) R.color.white else R.color.gray_ac))
_buttonPolycentric.setTextColor(resources.getColor(if (!commentType) R.color.white else R.color.gray_ac))
if (commentType) {
_addCommentView.visibility = View.GONE;
if (forceReload || changed) {
fetchComments();
}
} else {
_addCommentView.visibility = View.VISIBLE;
if (forceReload || changed) {
fetchPolycentricComments()
}
}
}
}
private fun setLoading(isLoading : Boolean) {
if (_isLoading == isLoading) {
return;
}
_isLoading = isLoading;
if(isLoading) {
(_imageLoader.drawable as Animatable?)?.start()
_layoutLoadingOverlay.visibility = View.VISIBLE;
}
else {
_layoutLoadingOverlay.visibility = View.GONE;
(_imageLoader.drawable as Animatable?)?.stop()
}
}
class ArticleHeaderBlock : LinearLayout {
constructor(context: Context?, content: String, level: Int) : super(context){
inflate(context, R.layout.view_segment_text, this);
findViewById<TextView>(R.id.text_content)?.let {
it.text = content;
val sp = when(level) {
1 -> 6.sp(resources);
2 -> 8.sp(resources);
3 -> 10.sp(resources);
4 -> 12.sp(resources);
5 -> 14.sp(resources);
else -> 6.sp(resources);
}
it.setTextColor(Color.WHITE);
it.setTypeface(Typeface.create(null, 600, false));
it.textSize = sp.toFloat();
}
}
}
class ArticleTextBlock : LinearLayout {
constructor(context: Context?, content: String, textType: TextType) : super(context){
inflate(context, R.layout.view_segment_text, this);
findViewById<TextView>(R.id.text_content)?.let {
if(textType == TextType.HTML)
it.text = Html.fromHtml(content, Html.FROM_HTML_MODE_COMPACT);
else if(textType == TextType.CODE) {
it.text = content;
it.setPadding(15.dp(resources));
it.setHorizontallyScrolling(true);
it.movementMethod = ScrollingMovementMethod();
it.setTypeface(Typeface.MONOSPACE);
it.setBackgroundResource(R.drawable.background_videodetail_description)
}
else
it.text = content;
}
}
}
class ArticleImageBlock: LinearLayout {
constructor(context: Context?, image: String, caption: String? = null) : super(context){
inflate(context, R.layout.view_segment_image, this);
findViewById<ImageView>(R.id.image_content)?.let {
Glide.with(it)
.load(image)
.crossfade()
.into(it);
}
findViewById<TextView>(R.id.text_content)?.let {
if(caption?.isNullOrEmpty() == true)
it.isVisible = false;
else
it.text = caption;
}
}
}
class ArticleContentBlock: LinearLayout {
constructor(context: Context, content: IPlatformContent?, fragment: ArticleDetailFragment? = null, overlayContainer: FrameLayout? = null): super(context) {
if(content != null) {
var view: View? = null;
if(content is IPlatformNestedContent) {
view = PreviewNestedVideoView(context, FeedStyle.THUMBNAIL, null);
view.bind(content);
view.onContentUrlClicked.subscribe { a,b -> }
}
else if(content is IPlatformVideo) {
view = PreviewVideoView(context, FeedStyle.THUMBNAIL, null, true);
view.bind(content);
view.onVideoClicked.subscribe { a,b -> fragment?.navigate<VideoDetailFragment>(a) }
view.onChannelClicked.subscribe { a -> fragment?.navigate<ChannelFragment>(a) }
if(overlayContainer != null) {
view.onAddToClicked.subscribe { a -> UISlideOverlays.showVideoOptionsOverlay(a, overlayContainer) };
}
view.onAddToQueueClicked.subscribe { a -> StatePlayer.instance.addToQueue(a) }
view.onAddToWatchLaterClicked.subscribe { a ->
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true))
UIDialogs.toast("Added to watch later\n[${content.name}]")
}
}
else if(content is IPlatformPost) {
view = PreviewPostView(context, FeedStyle.THUMBNAIL);
view.bind(content);
view.onContentClicked.subscribe { a -> fragment?.navigate<PostDetailFragment>(a) }
view.onChannelClicked.subscribe { a -> fragment?.navigate<ChannelFragment>(a) }
}
else if(content is IPlatformArticle) {
view = PreviewPostView(context, FeedStyle.THUMBNAIL);
view.bind(content);
view.onContentClicked.subscribe { a -> fragment?.navigate<ArticleDetailFragment>(a) }
view.onChannelClicked.subscribe { a -> fragment?.navigate<ChannelFragment>(a) }
}
else if(content is IPlatformLockedContent) {
view = PreviewLockedView(context, FeedStyle.THUMBNAIL);
view.bind(content);
}
if(view != null)
addView(view);
}
}
}
companion object {
const val TAG = "PostDetailFragment"
}
}
companion object {
fun newInstance() = ArticleDetailFragment().apply {}
}
}
@@ -1,6 +1,8 @@
package com.futo.platformplayer.fragment.mainactivity.main package com.futo.platformplayer.fragment.mainactivity.main
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@@ -66,8 +68,7 @@ class BuyFragment : MainFragment() {
_paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, _, exception -> _paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, _, exception ->
if(success) { if(success) {
UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0, UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0, UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY));
UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY));
_fragment.close(true); _fragment.close(true);
} }
else { else {
@@ -115,11 +116,14 @@ class BuyFragment : MainFragment() {
val licenseInput = SlideUpMenuTextInput(context, context.getString(R.string.license)); val licenseInput = SlideUpMenuTextInput(context, context.getString(R.string.license));
val productLicenseDialog = SlideUpMenuOverlay(context, findViewById<FrameLayout>(R.id.overlay_paid), context.getString(R.string.enter_license_key), context.getString(R.string.ok), true, licenseInput); val productLicenseDialog = SlideUpMenuOverlay(context, findViewById<FrameLayout>(R.id.overlay_paid), context.getString(R.string.enter_license_key), context.getString(R.string.ok), true, licenseInput);
productLicenseDialog.onOK.subscribe { productLicenseDialog.onOK.subscribe {
licenseInput.deactivate();
val licenseText = licenseInput.text; val licenseText = licenseInput.text;
if (licenseText.isNullOrEmpty()) { if (licenseText.isNullOrEmpty()) {
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.invalid_license_key)); UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.invalid_license_key));
return@subscribe; return@subscribe;
} }
licenseInput.clear();
productLicenseDialog.hide(true);
_fragment.lifecycleScope.launch(Dispatchers.IO) { _fragment.lifecycleScope.launch(Dispatchers.IO) {
@@ -127,17 +131,18 @@ class BuyFragment : MainFragment() {
val activationResult = StatePayment.instance.setPaymentLicense(licenseText); val activationResult = StatePayment.instance.setPaymentLicense(licenseText);
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if(activationResult) { try {
licenseInput.deactivate(); if(activationResult) {
licenseInput.clear(); UIDialogs.showDialogOk(context, R.drawable.ic_check, context.getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required)) {
productLicenseDialog.hide(true); _fragment.close(true)
}
UIDialogs.showDialogOk(context, R.drawable.ic_check, context.getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required)); }
_fragment.close(true); else
} {
else UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.invalid_license_key));
{ }
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.invalid_license_key)); } catch (e: Throwable) {
Logger.e(TAG, "Failed to update UI after buy complete", e)
} }
} }
} }
@@ -158,5 +163,6 @@ class BuyFragment : MainFragment() {
companion object { companion object {
fun newInstance() = BuyFragment().apply {} fun newInstance() = BuyFragment().apply {}
private val TAG = "BuyFragment"
} }
} }
@@ -42,12 +42,12 @@ import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SearchType import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.selectHighestResolutionImage import com.futo.platformplayer.selectHighestResolutionImage
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.adapters.ChannelTab import com.futo.platformplayer.views.adapters.ChannelTab
@@ -55,29 +55,14 @@ import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter
import com.futo.platformplayer.views.others.CreatorThumbnail import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.futo.platformplayer.views.subscriptions.SubscribeButton import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.polycentric.core.OwnedClaim import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.PublicKey import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
@Serializable
data class PolycentricProfile(
val system: PublicKey, val systemState: SystemState, val ownedClaims: List<OwnedClaim>
) {
fun getHarborUrl(context: Context): String{
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(system));
val url = system.systemToURLInfoSystemLinkUrl(systemState.servers.asIterable());
return "https://harbor.social/" + url.substring("polycentric://".length);
}
}
class ChannelFragment : MainFragment() { class ChannelFragment : MainFragment() {
override val isMainView: Boolean = true override val isMainView: Boolean = true
@@ -144,15 +129,16 @@ class ChannelFragment : MainFragment() {
private val _onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {} private val _onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {}
private val _taskLoadPolycentricProfile: TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?> private val _taskLoadPolycentricProfile: TaskHandler<PlatformID, PolycentricProfile?>
private val _taskGetChannel: TaskHandler<String, IPlatformChannel> private val _taskGetChannel: TaskHandler<String, IPlatformChannel>
init { init {
inflater.inflate(R.layout.fragment_channel, this) inflater.inflate(R.layout.fragment_channel, this)
_taskLoadPolycentricProfile = _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>({ fragment.lifecycleScope },
TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>({ fragment.lifecycleScope },
{ id -> { id ->
return@TaskHandler PolycentricCache.instance.getProfileAsync(id) if (!StatePolycentric.instance.enabled)
return@TaskHandler null
return@TaskHandler ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, id.claimFieldType.toLong(), id.claimType.toLong(), id.value!!)
}).success { setPolycentricProfile(it, animate = true) }.exception<Throwable> { }).success { setPolycentricProfile(it, animate = true) }.exception<Throwable> {
Logger.w(TAG, "Failed to load polycentric profile.", it) Logger.w(TAG, "Failed to load polycentric profile.", it)
} }
@@ -328,7 +314,7 @@ class ChannelFragment : MainFragment() {
_creatorThumbnail.setThumbnail(parameter.thumbnail, true) _creatorThumbnail.setThumbnail(parameter.thumbnail, true)
Glide.with(_imageBanner).clear(_imageBanner) Glide.with(_imageBanner).clear(_imageBanner)
loadPolycentricProfile(parameter.id, parameter.url) loadPolycentricProfile(parameter.id)
} }
_url = parameter.url _url = parameter.url
@@ -342,7 +328,7 @@ class ChannelFragment : MainFragment() {
_creatorThumbnail.setThumbnail(parameter.channel.thumbnail, true) _creatorThumbnail.setThumbnail(parameter.channel.thumbnail, true)
Glide.with(_imageBanner).clear(_imageBanner) Glide.with(_imageBanner).clear(_imageBanner)
loadPolycentricProfile(parameter.channel.id, parameter.channel.url) loadPolycentricProfile(parameter.channel.id)
} }
_url = parameter.channel.url _url = parameter.channel.url
@@ -359,16 +345,8 @@ class ChannelFragment : MainFragment() {
_tabs.selectTab(_tabs.getTabAt(selectedTabIndex)) _tabs.selectTab(_tabs.getTabAt(selectedTabIndex))
} }
private fun loadPolycentricProfile(id: PlatformID, url: String) { private fun loadPolycentricProfile(id: PlatformID) {
val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(url, true) _taskLoadPolycentricProfile.run(id)
if (cachedPolycentricProfile != null) {
setPolycentricProfile(cachedPolycentricProfile, animate = true)
if (cachedPolycentricProfile.expired) {
_taskLoadPolycentricProfile.run(id)
}
} else {
_taskLoadPolycentricProfile.run(id)
}
} }
private fun setLoading(isLoading: Boolean) { private fun setLoading(isLoading: Boolean) {
@@ -447,17 +425,15 @@ class ChannelFragment : MainFragment() {
_fragment.lifecycleScope.launch(Dispatchers.IO) { _fragment.lifecycleScope.launch(Dispatchers.IO) {
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url) val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (plugin != null && plugin.capabilities.hasSearchChannelContents) { buttons.add(Pair(R.drawable.ic_search) {
buttons.add(Pair(R.drawable.ic_search) { _fragment.navigate<SuggestionsFragment>(
_fragment.navigate<SuggestionsFragment>( SuggestionsFragmentData(
SuggestionsFragmentData( "", SearchType.VIDEO
"", SearchType.VIDEO, channel.url
)
) )
}) )
})
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
}
if(plugin != null && plugin.capabilities.hasGetChannelCapabilities) { if(plugin != null && plugin.capabilities.hasGetChannelCapabilities) {
if(plugin.getChannelCapabilities()?.types?.contains(ResultCapabilities.TYPE_SHORTS) ?: false && if(plugin.getChannelCapabilities()?.types?.contains(ResultCapabilities.TYPE_SHORTS) ?: false &&
!(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(ChannelTab.SHORTS.ordinal.toLong())) { !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(ChannelTab.SHORTS.ordinal.toLong())) {
@@ -533,20 +509,13 @@ class ChannelFragment : MainFragment() {
private fun setPolycentricProfileOr(url: String, or: () -> Unit) { private fun setPolycentricProfileOr(url: String, or: () -> Unit) {
setPolycentricProfile(null, animate = false) setPolycentricProfile(null, animate = false)
or()
val cachedProfile = channel?.let { PolycentricCache.instance.getCachedProfile(url) }
if (cachedProfile != null) {
setPolycentricProfile(cachedProfile, animate = false)
} else {
or()
}
} }
private fun setPolycentricProfile( private fun setPolycentricProfile(
cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean profile: PolycentricProfile?, animate: Boolean
) { ) {
val dp35 = 35.dp(resources) val dp35 = 35.dp(resources)
val profile = cachedPolycentricProfile?.profile
val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35)?.let { val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35)?.let {
it.toURLInfoSystemLinkUrl( it.toURLInfoSystemLinkUrl(
profile.system.toProto(), it.process, profile.systemState.servers.toList() profile.system.toProto(), it.process, profile.systemState.servers.toList()
@@ -23,7 +23,6 @@ import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
@@ -32,6 +31,7 @@ import com.futo.platformplayer.views.adapters.CommentWithReferenceViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.overlays.RepliesOverlay import com.futo.platformplayer.views.overlays.RepliesOverlay
import com.futo.polycentric.core.PublicKey import com.futo.polycentric.core.PublicKey
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.UnknownHostException import java.net.UnknownHostException
@@ -10,12 +10,14 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.post.IPlatformPost import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.models.JSWeb
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StateMeta
@@ -196,16 +198,24 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
fragment.navigate<RemotePlaylistFragment>(content); fragment.navigate<RemotePlaylistFragment>(content);
} else if (content is IPlatformPost) { } else if (content is IPlatformPost) {
fragment.navigate<PostDetailFragment>(content); fragment.navigate<PostDetailFragment>(content);
} else if(content is IPlatformArticle) {
fragment.navigate<ArticleDetailFragment>(content);
} }
else if(content is JSWeb) {
fragment.navigate<WebDetailFragment>(content);
}
else
UIDialogs.appToast("Unknown content type [" + content.contentType.name + "]");
} }
protected open fun onContentUrlClicked(url: String, contentType: ContentType) { protected open fun onContentUrlClicked(url: String, contentType: ContentType) {
when(contentType) { when(contentType) {
ContentType.MEDIA -> { ContentType.MEDIA -> {
StatePlayer.instance.clearQueue(); StatePlayer.instance.clearQueue()
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail(); fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
}; }
ContentType.PLAYLIST -> fragment.navigate<RemotePlaylistFragment>(url); ContentType.PLAYLIST -> fragment.navigate<RemotePlaylistFragment>(url)
ContentType.URL -> fragment.navigate<BrowserFragment>(url); ContentType.URL -> fragment.navigate<BrowserFragment>(url)
ContentType.CHANNEL -> fragment.navigate<ChannelFragment>(url)
else -> {}; else -> {};
} }
} }
@@ -2,14 +2,18 @@ package com.futo.platformplayer.fragment.mainactivity.main
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.allViews
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
@@ -17,9 +21,12 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.isHttpUrl import com.futo.platformplayer.isHttpUrl
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.ToggleBar
import com.futo.platformplayer.views.others.RadioGroupView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -82,7 +89,7 @@ class ContentSearchResultsFragment : MainFragment() {
private var _sortBy: String? = null; private var _sortBy: String? = null;
private var _filterValues: HashMap<String, List<String>> = hashMapOf(); private var _filterValues: HashMap<String, List<String>> = hashMapOf();
private var _enabledClientIds: List<String>? = null; private var _enabledClientIds: List<String>? = null;
private var _channelUrl: String? = null; private var _searchType: SearchType? = null;
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>; private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar
@@ -90,11 +97,12 @@ class ContentSearchResultsFragment : MainFragment() {
constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) { constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
_taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query -> _taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query ->
Logger.i(TAG, "Searching for: $query") Logger.i(TAG, "Searching for: $query")
val channelUrl = _channelUrl; when (_searchType)
if (channelUrl != null) { {
StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds) SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
} else { SearchType.CREATOR -> StatePlatform.instance.searchChannelsAsContent(query)
StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds) SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query)
else -> throw Exception("Search type must be specified")
} }
}) })
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { } .success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
@@ -104,6 +112,25 @@ class ContentSearchResultsFragment : MainFragment() {
} }
setPreviewsEnabled(Settings.instance.search.previewFeedItems); setPreviewsEnabled(Settings.instance.search.previewFeedItems);
initializeToolbar();
}
fun initializeToolbar(){
if(_toolbarContentView.allViews.any { it is RadioGroupView })
_toolbarContentView.removeView(_toolbarContentView.allViews.find { it is RadioGroupView });
val radioGroup = RadioGroupView(context);
radioGroup.onSelectedChange.subscribe {
if (it.size != 1)
setSearchType(SearchType.VIDEO);
else
setSearchType((it[0] ?: SearchType.VIDEO) as SearchType);
}
radioGroup?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true)
_toolbarContentView.addView(radioGroup);
} }
override fun cleanup() { override fun cleanup() {
@@ -114,7 +141,7 @@ class ContentSearchResultsFragment : MainFragment() {
fun onShown(parameter: Any?) { fun onShown(parameter: Any?) {
if(parameter is SuggestionsFragmentData) { if(parameter is SuggestionsFragmentData) {
setQuery(parameter.query, false); setQuery(parameter.query, false);
setChannelUrl(parameter.channelUrl, false); setSearchType(parameter.searchType, false)
fragment.topBar?.apply { fragment.topBar?.apply {
if (this is SearchTopBarFragment) { if (this is SearchTopBarFragment) {
@@ -130,7 +157,7 @@ class ContentSearchResultsFragment : MainFragment() {
onFilterClick.subscribe(this) { onFilterClick.subscribe(this) {
_overlayContainer.let { _overlayContainer.let {
val filterValuesCopy = HashMap(_filterValues); val filterValuesCopy = HashMap(_filterValues);
val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy, _channelUrl != null); val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy);
filtersOverlay.onOK.subscribe { enabledClientIds, changed -> filtersOverlay.onOK.subscribe { enabledClientIds, changed ->
if (changed) { if (changed) {
setFilterValues(filtersOverlay.commonCapabilities, filterValuesCopy); setFilterValues(filtersOverlay.commonCapabilities, filterValuesCopy);
@@ -160,8 +187,14 @@ class ContentSearchResultsFragment : MainFragment() {
navigate<RemotePlaylistFragment>(it); navigate<RemotePlaylistFragment>(it);
else if(StatePlatform.instance.hasEnabledChannelClient(it)) else if(StatePlatform.instance.hasEnabledChannelClient(it))
navigate<ChannelFragment>(it); navigate<ChannelFragment>(it);
else else {
navigate<VideoDetailFragment>(it); val url = it;
activity?.let {
close()
if(it is MainActivity)
it.navigate(it.getFragment<VideoDetailFragment>(), url);
}
}
} }
else else
setQuery(it, true); setQuery(it, true);
@@ -171,11 +204,7 @@ class ContentSearchResultsFragment : MainFragment() {
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val commonCapabilities = val commonCapabilities = StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
if(_channelUrl == null)
StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
else
StatePlatform.instance.getCommonSearchChannelContentsCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
val sorts = commonCapabilities?.sorts ?: listOf(); val sorts = commonCapabilities?.sorts ?: listOf();
if (sorts.size > 1) { if (sorts.size > 1) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -242,8 +271,8 @@ class ContentSearchResultsFragment : MainFragment() {
} }
} }
private fun setChannelUrl(channelUrl: String?, updateResults: Boolean = true) { private fun setSearchType(searchType: SearchType, updateResults: Boolean = true) {
_channelUrl = channelUrl; _searchType = searchType
if (updateResults) { if (updateResults) {
clearResults(); clearResults();
@@ -16,6 +16,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.views.adapters.SubscriptionAdapter import com.futo.platformplayer.views.adapters.SubscriptionAdapter
class CreatorsFragment : MainFragment() { class CreatorsFragment : MainFragment() {
@@ -29,6 +31,8 @@ class CreatorsFragment : MainFragment() {
private var _editSearch: EditText? = null; private var _editSearch: EditText? = null;
private var _textMeta: TextView? = null; private var _textMeta: TextView? = null;
private var _buttonClearSearch: ImageButton? = null private var _buttonClearSearch: ImageButton? = null
private var _ordering = FragmentedStorage.get<StringStorage>("creators_ordering")
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = inflater.inflate(R.layout.fragment_creators, container, false); val view = inflater.inflate(R.layout.fragment_creators, container, false);
@@ -44,7 +48,7 @@ class CreatorsFragment : MainFragment() {
_buttonClearSearch?.visibility = View.INVISIBLE; _buttonClearSearch?.visibility = View.INVISIBLE;
} }
val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription)) { subs -> val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription), _ordering?.value?.toIntOrNull() ?: 5) { subs ->
_textMeta?.let { _textMeta?.let {
it.text = "${subs.size} creator${if(subs.size > 1) "s" else ""}"; it.text = "${subs.size} creator${if(subs.size > 1) "s" else ""}";
} }
@@ -61,6 +65,7 @@ class CreatorsFragment : MainFragment() {
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
adapter.sortBy = pos; adapter.sortBy = pos;
_ordering.setAndSave(pos.toString())
} }
override fun onNothingSelected(parent: AdapterView<*>?) = Unit override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}; };
@@ -14,13 +14,19 @@ import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.downloads.VideoDownload import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.downloads.VideoLocal import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.services.DownloadService
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.toHumanBytesSize import com.futo.platformplayer.toHumanBytesSize
import com.futo.platformplayer.toHumanDuration import com.futo.platformplayer.toHumanDuration
import com.futo.platformplayer.views.AnyInsertedAdapterView import com.futo.platformplayer.views.AnyInsertedAdapterView
@@ -52,6 +58,15 @@ class DownloadsFragment : MainFragment() {
super.onResume() super.onResume()
_view?.reloadUI(); _view?.reloadUI();
if(StateDownloads.instance.getDownloading().any { it.state == VideoDownload.State.QUEUED } &&
!StateDownloads.instance.getDownloading().any { it.state == VideoDownload.State.DOWNLOADING } &&
Settings.instance.downloads.shouldDownload()) {
Logger.w(TAG, "Detected queued download, while not downloading, attempt recreating service");
StateApp.withContext {
DownloadService.getOrCreateService(it);
}
}
StateDownloads.instance.onDownloadsChanged.subscribe(this) { StateDownloads.instance.onDownloadsChanged.subscribe(this) {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
try { try {
@@ -103,12 +118,15 @@ class DownloadsFragment : MainFragment() {
private val _listDownloaded: AnyInsertedAdapterView<VideoLocal, VideoDownloadViewHolder>; private val _listDownloaded: AnyInsertedAdapterView<VideoLocal, VideoDownloadViewHolder>;
private var lastDownloads: List<VideoLocal>? = null; private var lastDownloads: List<VideoLocal>? = null;
private var ordering: String? = "nameAsc"; private var ordering = FragmentedStorage.get<StringStorage>("downloads_ordering")
constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) { constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) {
inflater.inflate(R.layout.fragment_downloads, this); inflater.inflate(R.layout.fragment_downloads, this);
_frag = frag; _frag = frag;
if(ordering.value.isNullOrBlank())
ordering.value = "nameAsc";
_usageUsed = findViewById(R.id.downloads_usage_used); _usageUsed = findViewById(R.id.downloads_usage_used);
_usageAvailable = findViewById(R.id.downloads_usage_available); _usageAvailable = findViewById(R.id.downloads_usage_available);
_usageProgress = findViewById(R.id.downloads_usage_progress); _usageProgress = findViewById(R.id.downloads_usage_progress);
@@ -132,22 +150,25 @@ class DownloadsFragment : MainFragment() {
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also { spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
}; };
spinnerSortBy.setSelection(0); val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc", "sizeAsc", "sizeDesc");
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
when(pos) { when(pos) {
0 -> ordering = "nameAsc" 0 -> ordering.setAndSave("nameAsc")
1 -> ordering = "nameDesc" 1 -> ordering.setAndSave("nameDesc")
2 -> ordering = "downloadDateAsc" 2 -> ordering.setAndSave("downloadDateAsc")
3 -> ordering = "downloadDateDesc" 3 -> ordering.setAndSave("downloadDateDesc")
4 -> ordering = "releasedAsc" 4 -> ordering.setAndSave("releasedAsc")
5 -> ordering = "releasedDesc" 5 -> ordering.setAndSave("releasedDesc")
else -> ordering = null 6 -> ordering.setAndSave("sizeAsc")
7 -> ordering.setAndSave("sizeDesc")
else -> ordering.setAndSave("")
} }
updateContentFilters() updateContentFilters()
} }
override fun onNothingSelected(parent: AdapterView<*>?) = Unit override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}; };
spinnerSortBy.setSelection(Math.max(0, options.indexOf(ordering.value)));
_listDownloaded = findViewById<RecyclerView>(R.id.list_downloaded) _listDownloaded = findViewById<RecyclerView>(R.id.list_downloaded)
.asAnyWithTop(findViewById(R.id.downloads_top)) { .asAnyWithTop(findViewById(R.id.downloads_top)) {
@@ -229,15 +250,17 @@ class DownloadsFragment : MainFragment() {
fun filterDownloads(vids: List<VideoLocal>): List<VideoLocal>{ fun filterDownloads(vids: List<VideoLocal>): List<VideoLocal>{
var vidsToReturn = vids; var vidsToReturn = vids;
if(!_listDownloadSearch.text.isNullOrEmpty()) if(!_listDownloadSearch.text.isNullOrEmpty())
vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) }; vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) || it.author.name.contains(_listDownloadSearch.text, true) };
if(!ordering.isNullOrEmpty()) { if(!ordering.value.isNullOrEmpty()) {
vidsToReturn = when(ordering){ vidsToReturn = when(ordering.value){
"downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX }; "downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX };
"downloadDateDesc" -> vidsToReturn.sortedByDescending { it.downloadDate ?: OffsetDateTime.MIN }; "downloadDateDesc" -> vidsToReturn.sortedByDescending { it.downloadDate ?: OffsetDateTime.MIN };
"nameAsc" -> vidsToReturn.sortedBy { it.name.lowercase() } "nameAsc" -> vidsToReturn.sortedBy { it.name.lowercase() }
"nameDesc" -> vidsToReturn.sortedByDescending { it.name.lowercase() } "nameDesc" -> vidsToReturn.sortedByDescending { it.name.lowercase() }
"releasedAsc" -> vidsToReturn.sortedBy { it.datetime ?: OffsetDateTime.MAX } "releasedAsc" -> vidsToReturn.sortedBy { it.datetime ?: OffsetDateTime.MAX }
"releasedDesc" -> vidsToReturn.sortedByDescending { it.datetime ?: OffsetDateTime.MIN } "releasedDesc" -> vidsToReturn.sortedByDescending { it.datetime ?: OffsetDateTime.MIN }
"sizeAsc" -> vidsToReturn.sortedBy { it.videoSource.sumOf { it.fileSize } + it.audioSource.sumOf { it.fileSize } }
"sizeDesc" -> vidsToReturn.sortedByDescending { it.videoSource.sumOf { it.fileSize } + it.audioSource.sumOf { it.fileSize } }
else -> vidsToReturn else -> vidsToReturn
} }
} }
@@ -3,12 +3,15 @@ package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Color import android.graphics.Color
import android.util.DisplayMetrics
import android.view.Display
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.* import android.widget.*
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.LayoutManager import androidx.recyclerview.widget.RecyclerView.LayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@@ -20,6 +23,7 @@ import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.others.ProgressBar
import com.futo.platformplayer.views.others.TagsView import com.futo.platformplayer.views.others.TagsView
@@ -28,7 +32,9 @@ import com.futo.platformplayer.views.adapters.InsertedViewHolder
import com.futo.platformplayer.views.announcements.AnnouncementView import com.futo.platformplayer.views.announcements.AnnouncementView
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.OffsetDateTime import java.time.OffsetDateTime
import kotlin.math.max import kotlin.math.max
@@ -68,6 +74,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
private val _scrollListener: RecyclerView.OnScrollListener; private val _scrollListener: RecyclerView.OnScrollListener;
private var _automaticNextPageCounter = 0; private var _automaticNextPageCounter = 0;
private val _automaticBackoff = arrayOf(0, 500, 1000, 1000, 2000, 5000, 5000, 5000);
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<TViewHolder>, GridLayoutManager, TPager, TResult, TConverted, InsertedViewHolder<TViewHolder>>? = null) : super(inflater.context) { constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<TViewHolder>, GridLayoutManager, TPager, TResult, TConverted, InsertedViewHolder<TViewHolder>>? = null) : super(inflater.context) {
this.fragment = fragment; this.fragment = fragment;
@@ -182,29 +189,61 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) { private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
val canScroll = if (recyclerData.results.isEmpty()) false else { val canScroll = if (recyclerData.results.isEmpty()) false else {
val height = resources.displayMetrics.heightPixels;
val layoutManager = recyclerData.layoutManager val layoutManager = recyclerData.layoutManager
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null;
if (firstVisibleItemPosition != RecyclerView.NO_POSITION) { val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition();
val firstVisibleView = layoutManager.findViewByPosition(firstVisibleItemPosition) val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null;
val itemHeight = firstVisibleView?.height ?: 0 val rows = if(recyclerData.layoutManager is GridLayoutManager) Math.max(1, recyclerData.results.size / recyclerData.layoutManager.spanCount) else 1;
val occupiedSpace = recyclerData.results.size / recyclerData.layoutManager.spanCount * itemHeight val rowsHeight = (firstVisibleItemView?.height ?: 0) * rows;
val recyclerViewHeight = _recyclerResults.height if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) {
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage occupiedSpace=$occupiedSpace recyclerViewHeight=$recyclerViewHeight") false;
occupiedSpace >= recyclerViewHeight }
else if (firstVisibleItemView != null && height != null && rowsHeight < height) {
false;
} else { } else {
false true;
} }
} }
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter") Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter")
if (!canScroll || filteredResults.isEmpty()) { if (!canScroll || filteredResults.isEmpty()) {
_automaticNextPageCounter++ _automaticNextPageCounter++
if(_automaticNextPageCounter <= 4) if(_automaticNextPageCounter < _automaticBackoff.size) {
loadNextPage() if(_automaticNextPageCounter > 0) {
val automaticNextPageCounterSaved = _automaticNextPageCounter;
fragment.lifecycleScope.launch(Dispatchers.Default) {
val backoff = _automaticBackoff[Math.min(_automaticBackoff.size - 1, _automaticNextPageCounter)];
withContext(Dispatchers.Main) {
setLoading(true);
}
delay(backoff.toLong());
if(automaticNextPageCounterSaved == _automaticNextPageCounter) {
withContext(Dispatchers.Main) {
loadNextPage();
}
}
else {
withContext(Dispatchers.Main) {
setLoading(false);
}
}
}
}
else
loadNextPage();
}
} else { } else {
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
_automaticNextPageCounter = 0; _automaticNextPageCounter = 0;
} }
} }
fun resetAutomaticNextPageCounter(){
_automaticNextPageCounter = 0;
}
protected fun setTextCentered(text: String?) { protected fun setTextCentered(text: String?) {
_textCentered.text = text; _textCentered.text = text;
@@ -15,6 +15,8 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.structures.IAsyncPager import com.futo.platformplayer.api.media.structures.IAsyncPager
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
@@ -22,10 +24,14 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFrag
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.views.ToggleBar
import com.futo.platformplayer.views.adapters.HistoryListViewHolder import com.futo.platformplayer.views.adapters.HistoryListViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.others.TagsView import com.futo.platformplayer.views.others.TagsView
import com.futo.platformplayer.views.others.Toggle
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -68,6 +74,8 @@ class HistoryFragment : MainFragment() {
private var _pager: IPager<HistoryVideo>? = null; private var _pager: IPager<HistoryVideo>? = null;
private val _results = arrayListOf<HistoryVideo>(); private val _results = arrayListOf<HistoryVideo>();
private var _loading = false; private var _loading = false;
private val _toggleBar: ToggleBar
private var _togglePluginsDisabled = hashSetOf<String>()
private var _automaticNextPageCounter = 0; private var _automaticNextPageCounter = 0;
@@ -79,6 +87,7 @@ class HistoryFragment : MainFragment() {
_clearSearch = findViewById(R.id.button_clear_search); _clearSearch = findViewById(R.id.button_clear_search);
_editSearch = findViewById(R.id.edit_search); _editSearch = findViewById(R.id.edit_search);
_tagsView = findViewById(R.id.tags_text); _tagsView = findViewById(R.id.tags_text);
_toggleBar = findViewById(R.id.toggle_bar)
_tagsView.setPairs(listOf( _tagsView.setPairs(listOf(
Pair(context.getString(R.string.last_hour), 60L), Pair(context.getString(R.string.last_hour), 60L),
Pair(context.getString(R.string.last_24_hours), 24L * 60L), Pair(context.getString(R.string.last_24_hours), 24L * 60L),
@@ -88,6 +97,22 @@ class HistoryFragment : MainFragment() {
Pair(context.getString(R.string.all_time), -1L) Pair(context.getString(R.string.all_time), -1L)
)); ));
val toggles = StatePlatform.instance.getEnabledClients()
.filter { it is JSClient }
.map { plugin ->
val pluginName = plugin.name.lowercase()
ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) pluginName else "", plugin.icon, !_togglePluginsDisabled.contains(plugin.id), { view, active ->
if (active) {
_togglePluginsDisabled.remove(plugin.id)
} else {
_togglePluginsDisabled.add(plugin.id)
}
filtersChanged()
}).withTag("plugins")
}.toTypedArray()
_toggleBar.setToggles(*toggles)
_adapter = InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(), _adapter = InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
{ _results.size }, { _results.size },
{ view, _ -> { view, _ ->
@@ -162,14 +187,15 @@ class HistoryFragment : MainFragment() {
else else
it.nextPage(); it.nextPage();
return@TaskHandler it.getResults(); return@TaskHandler filterResults(it.getResults());
}).success { }).success {
setLoading(false); setLoading(false);
val posBefore = _results.size; val posBefore = _results.size;
_results.addAll(it); val res = filterResults(it)
_adapter.notifyItemRangeInserted(_adapter.childToParentPosition(posBefore), it.size); _results.addAll(res);
ensureEnoughContentVisible(it) _adapter.notifyItemRangeInserted(_adapter.childToParentPosition(posBefore), res.size);
ensureEnoughContentVisible(res)
}.exception<Throwable> { }.exception<Throwable> {
Logger.w(TAG, "Failed to load next page.", it); Logger.w(TAG, "Failed to load next page.", it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, { UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
@@ -178,6 +204,10 @@ class HistoryFragment : MainFragment() {
}; };
} }
private fun filtersChanged() {
updatePager()
}
private fun updatePager() { private fun updatePager() {
val query = _editSearch.text.toString(); val query = _editSearch.text.toString();
if (_editSearch.text.isNotEmpty()) { if (_editSearch.text.isNotEmpty()) {
@@ -246,11 +276,22 @@ class HistoryFragment : MainFragment() {
_adapter.setLoading(loading); _adapter.setLoading(loading);
} }
private fun filterResults(a: List<HistoryVideo>): List<HistoryVideo> {
val enabledPluginIds = StatePlatform.instance.getEnabledClients().map { it.id }.toHashSet()
val disabledPluginIds = _togglePluginsDisabled.toHashSet()
return a.filter {
val pluginId = it.video.id.pluginId ?: StatePlatform.instance.getContentClientOrNull(it.video.url)?.id ?: return@filter false
if (!enabledPluginIds.contains(pluginId))
return@filter false
return@filter !disabledPluginIds.contains(pluginId)
};
}
private fun loadPagerInternal(pager: IPager<HistoryVideo>) { private fun loadPagerInternal(pager: IPager<HistoryVideo>) {
Logger.i(TAG, "Setting new internal pager on feed"); Logger.i(TAG, "Setting new internal pager on feed");
_results.clear(); _results.clear();
val toAdd = pager.getResults(); val toAdd = filterResults(pager.getResults())
_results.addAll(toAdd); _results.addAll(toAdd);
_adapter.notifyDataSetChanged(); _adapter.notifyDataSetChanged();
ensureEnoughContentVisible(toAdd) ensureEnoughContentVisible(toAdd)
@@ -5,29 +5,38 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.allViews
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.UISlideOverlays.Companion.showOrderOverlay
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.IRefreshPager
import com.futo.platformplayer.api.media.structures.IReusablePager
import com.futo.platformplayer.api.media.structures.ReusablePager
import com.futo.platformplayer.api.media.structures.ReusableRefreshPager
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.NoResultsView import com.futo.platformplayer.views.NoResultsView
import com.futo.platformplayer.views.ToggleBar import com.futo.platformplayer.views.ToggleBar
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.InsertedViewHolder import com.futo.platformplayer.views.adapters.InsertedViewHolder
import com.futo.platformplayer.views.announcements.AnnouncementView
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import java.time.OffsetDateTime import java.time.OffsetDateTime
@@ -39,6 +48,12 @@ class HomeFragment : MainFragment() {
private var _view: HomeView? = null; private var _view: HomeView? = null;
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null; private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
private var _cachedLastPager: IReusablePager<IPlatformContent>? = null
private var _toggleRecent = false;
private var _toggleWatched = false;
private var _togglePluginsDisabled = mutableListOf<String>();
fun reloadFeed() { fun reloadFeed() {
_view?.reloadFeed() _view?.reloadFeed()
@@ -64,7 +79,7 @@ class HomeFragment : MainFragment() {
} }
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = HomeView(this, inflater, _cachedRecyclerData); val view = HomeView(this, inflater, _cachedRecyclerData, _cachedLastPager);
_view = view; _view = view;
return view; return view;
} }
@@ -82,6 +97,7 @@ class HomeFragment : MainFragment() {
val view = _view; val view = _view;
if (view != null) { if (view != null) {
_cachedRecyclerData = view.recyclerData; _cachedRecyclerData = view.recyclerData;
_cachedLastPager = view.lastPager;
view.cleanup(); view.cleanup();
_view = null; _view = null;
} }
@@ -91,6 +107,7 @@ class HomeFragment : MainFragment() {
_view?.setPreviewsEnabled(previewsEnabled && Settings.instance.home.previewFeedItems); _view?.setPreviewsEnabled(previewsEnabled && Settings.instance.home.previewFeedItems);
} }
@SuppressLint("ViewConstructor") @SuppressLint("ViewConstructor")
class HomeView : ContentFeedView<HomeFragment> { class HomeView : ContentFeedView<HomeFragment> {
override val feedStyle: FeedStyle get() = Settings.instance.home.getHomeFeedStyle(); override val feedStyle: FeedStyle get() = Settings.instance.home.getHomeFeedStyle();
@@ -100,11 +117,22 @@ class HomeFragment : MainFragment() {
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>; private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) { var lastPager: IReusablePager<IPlatformContent>? = null;
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, GridLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null, cachedLastPager: IReusablePager<IPlatformContent>? = null) : super(fragment, inflater, cachedRecyclerData) {
lastPager = cachedLastPager
_taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({ fragment.lifecycleScope }, { _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({ fragment.lifecycleScope }, {
StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope) StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope)
}) })
.success { loadedResult(it); } .success {
val wrappedPager = if(it is IRefreshPager)
ReusableRefreshPager(it);
else
ReusablePager(it);
lastPager = wrappedPager;
resetAutomaticNextPageCounter();
loadedResult(wrappedPager.getWindow());
}
.exception<ScriptCaptchaRequiredException> { } .exception<ScriptCaptchaRequiredException> { }
.exception<ScriptExecutionException> { .exception<ScriptExecutionException> {
Logger.w(ChannelFragment.TAG, "Plugin failure.", it); Logger.w(ChannelFragment.TAG, "Plugin failure.", it);
@@ -207,22 +235,94 @@ class HomeFragment : MainFragment() {
} }
private val _filterLock = Object(); private val _filterLock = Object();
private var _toggleRecent = false; private var _togglesConfig = FragmentedStorage.get<StringArrayStorage>("home_toggles");
fun initializeToolbarContent() { fun initializeToolbarContent() {
//Not stable enough with current viewport paging, doesn't work with less results, and reloads content instead of just re-filtering existing if(_toolbarContentView.allViews.any { it is ToggleBar })
/* _toolbarContentView.removeView(_toolbarContentView.allViews.find { it is ToggleBar });
_toggleBar = ToggleBar(context).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
}
synchronized(_filterLock) {
_toggleBar?.setToggles(
//TODO: loadResults needs to be replaced with an internal reload of the current content
ToggleBar.Toggle("Recent", _toggleRecent) { _toggleRecent = it; loadResults(false) }
)
}
_toolbarContentView.addView(_toggleBar, 0); if(Settings.instance.home.showHomeFilters) {
*/
if (!_togglesConfig.any()) {
_togglesConfig.set("today", "watched", "plugins");
_togglesConfig.save();
}
_toggleBar = ToggleBar(context).apply {
layoutParams =
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
}
synchronized(_filterLock) {
var buttonsPlugins: List<ToggleBar.Toggle> = listOf()
buttonsPlugins = (if (_togglesConfig.contains("plugins"))
(StatePlatform.instance.getEnabledClients()
.filter { it is JSClient && it.enableInHome }
.map { plugin ->
ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) plugin.name else "", plugin.icon, !fragment._togglePluginsDisabled.contains(plugin.id), { view, active ->
var dontSwap = false;
if (active) {
if (fragment._togglePluginsDisabled.contains(plugin.id))
fragment._togglePluginsDisabled.remove(plugin.id);
} else {
if (!fragment._togglePluginsDisabled.contains(plugin.id)) {
val enabledClients = StatePlatform.instance.getEnabledClients();
val availableAfterDisable = enabledClients.count { !fragment._togglePluginsDisabled.contains(it.id) && it.id != plugin.id };
if(availableAfterDisable > 0)
fragment._togglePluginsDisabled.add(plugin.id);
else {
UIDialogs.appToast("Home needs atleast 1 plugin active");
dontSwap = true;
}
}
}
if(!dontSwap)
reloadForFilters();
else {
view.setToggle(!active);
}
}).withTag("plugins")
})
else listOf())
val buttons = (listOf<ToggleBar.Toggle?>(
(if (_togglesConfig.contains("today"))
ToggleBar.Toggle("Today", fragment._toggleRecent) { view, active ->
fragment._toggleRecent = active; reloadForFilters()
}
.withTag("today") else null),
(if (_togglesConfig.contains("watched"))
ToggleBar.Toggle("Unwatched", fragment._toggleWatched) { view, active ->
fragment._toggleWatched = active; reloadForFilters()
}
.withTag("watched") else null),
).filterNotNull() + buttonsPlugins)
.sortedBy { _togglesConfig.indexOf(it.tag ?: "") } ?: listOf()
val buttonSettings = ToggleBar.Toggle("", R.drawable.ic_settings, true, { view, active ->
showOrderOverlay(_overlayContainer,
"Visible home filters",
listOf(
Pair("Plugins", "plugins"),
Pair("Today", "today"),
Pair("Watched", "watched")
),
{
val newArray = it.map { it.toString() }.toTypedArray();
_togglesConfig.set(*(if (newArray.any()) newArray else arrayOf("none")));
_togglesConfig.save();
initializeToolbarContent();
},
"Select which toggles you want to see in order. You can also choose to hide filters in the Grayjay Settings"
);
}).asButton();
val buttonsOrder = (buttons + listOf(buttonSettings)).toTypedArray();
_toggleBar?.setToggles(*buttonsOrder);
}
_toolbarContentView.addView(_toggleBar, 0);
}
}
fun reloadForFilters() {
lastPager?.let { loadedResult(it.getWindow()) };
} }
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> { override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
@@ -232,7 +332,11 @@ class HomeFragment : MainFragment() {
if(StateMeta.instance.isCreatorHidden(it.author.url)) if(StateMeta.instance.isCreatorHidden(it.author.url))
return@filter false; return@filter false;
if(_toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 23) { if(fragment._toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 25)
return@filter false;
if(fragment._toggleWatched && StateHistory.instance.isHistoryWatched(it.url, 0))
return@filter false;
if(fragment._togglePluginsDisabled.any() && it.id.pluginId != null && fragment._togglePluginsDisabled.contains(it.id.pluginId)) {
return@filter false; return@filter false;
} }
@@ -10,7 +10,6 @@ import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
@@ -165,14 +164,24 @@ class PlaylistFragment : MainFragment() {
}; };
} }
private fun copyPlaylist(playlist: Playlist) { private fun savePlaylist(playlist: Playlist) {
StatePlaylists.instance.playlistStore.save(playlist) StatePlaylists.instance.playlistStore.save(playlist)
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(
arrayListOf()
)
UIDialogs.toast("Playlist saved") UIDialogs.toast("Playlist saved")
} }
private fun copyPlaylist(playlist: Playlist) {
var copyNumber = 1
var newName = "${playlist.name} (Copy)"
val playlists = StatePlaylists.instance.playlistStore.getItems()
while (playlists.any { it.name == newName }) {
copyNumber += 1
newName = "${playlist.name} (Copy $copyNumber)"
}
StatePlaylists.instance.playlistStore.save(playlist.makeCopy(newName))
_fragment.navigate<PlaylistsFragment>(withHistory = false)
UIDialogs.toast("Playlist copied")
}
fun onShown(parameter: Any?) { fun onShown(parameter: Any?) {
_taskLoadPlaylist.cancel() _taskLoadPlaylist.cancel()
@@ -188,12 +197,14 @@ class PlaylistFragment : MainFragment() {
setButtonExportVisible(false) setButtonExportVisible(false)
setButtonEditVisible(true) setButtonEditVisible(true)
if (!StatePlaylists.instance.playlistStore.hasItem { it.id == parameter.id }) { _fragment.topBar?.assume<NavigationTopBarFragment>()
_fragment.topBar?.assume<NavigationTopBarFragment>() ?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) {
?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) { if (StatePlaylists.instance.playlistStore.hasItem { it.id == parameter.id }) {
copyPlaylist(parameter) copyPlaylist(parameter)
})) } else {
} savePlaylist(parameter)
}
}))
} else { } else {
setName(null) setName(null)
setVideos(null, false) setVideos(null, false)
@@ -259,7 +270,7 @@ class PlaylistFragment : MainFragment() {
val playlist = _playlist ?: return val playlist = _playlist ?: return
if (!StatePlaylists.instance.playlistStore.hasItem { it.id == playlist.id }) { if (!StatePlaylists.instance.playlistStore.hasItem { it.id == playlist.id }) {
UIDialogs.showConfirmationDialog(context, "Playlist must be saved to download", { UIDialogs.showConfirmationDialog(context, "Playlist must be saved to download", {
copyPlaylist(playlist) savePlaylist(playlist)
download() download()
}) })
return return
@@ -292,7 +303,7 @@ class PlaylistFragment : MainFragment() {
val playlist = _playlist ?: return val playlist = _playlist ?: return
if (!StatePlaylists.instance.playlistStore.hasItem { it.id == playlist.id }) { if (!StatePlaylists.instance.playlistStore.hasItem { it.id == playlist.id }) {
UIDialogs.showConfirmationDialog(context, "Playlist must be saved to edit the name", { UIDialogs.showConfirmationDialog(context, "Playlist must be saved to edit the name", {
copyPlaylist(playlist) savePlaylist(playlist)
onEditClick() onEditClick()
}) })
return return
@@ -326,6 +337,10 @@ class PlaylistFragment : MainFragment() {
playlist.videos = ArrayList(playlist.videos.filter { it != video }); playlist.videos = ArrayList(playlist.videos.filter { it != video });
StatePlaylists.instance.createOrUpdatePlaylist(playlist); StatePlaylists.instance.createOrUpdatePlaylist(playlist);
} }
override fun onVideoOptions(video: IPlatformVideo) {
UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer);
}
override fun onVideoClicked(video: IPlatformVideo) { override fun onVideoClicked(video: IPlatformVideo) {
val playlist = _playlist; val playlist = _playlist;
if (playlist != null) { if (playlist != null) {
@@ -6,12 +6,17 @@ import android.util.TypedValue
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.Spinner
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@@ -21,11 +26,15 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.views.SearchView
import com.futo.platformplayer.views.adapters.* import com.futo.platformplayer.views.adapters.*
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.OffsetDateTime
class PlaylistsFragment : MainFragment() { class PlaylistsFragment : MainFragment() {
@@ -65,6 +74,7 @@ class PlaylistsFragment : MainFragment() {
private val _fragment: PlaylistsFragment; private val _fragment: PlaylistsFragment;
var watchLater: ArrayList<IPlatformVideo> = arrayListOf(); var watchLater: ArrayList<IPlatformVideo> = arrayListOf();
var allPlaylists: ArrayList<Playlist> = arrayListOf();
var playlists: ArrayList<Playlist> = arrayListOf(); var playlists: ArrayList<Playlist> = arrayListOf();
private var _appBar: AppBarLayout; private var _appBar: AppBarLayout;
private var _adapterWatchLater: VideoListHorizontalAdapter; private var _adapterWatchLater: VideoListHorizontalAdapter;
@@ -72,12 +82,20 @@ class PlaylistsFragment : MainFragment() {
private var _layoutWatchlist: ConstraintLayout; private var _layoutWatchlist: ConstraintLayout;
private var _slideUpOverlay: SlideUpMenuOverlay? = null; private var _slideUpOverlay: SlideUpMenuOverlay? = null;
private var _listPlaylistsSearch: EditText;
private var _ordering = FragmentedStorage.get<StringStorage>("playlists_ordering")
constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) { constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) {
_fragment = fragment; _fragment = fragment;
inflater.inflate(R.layout.fragment_playlists, this); inflater.inflate(R.layout.fragment_playlists, this);
_listPlaylistsSearch = findViewById(R.id.playlists_search);
watchLater = ArrayList(); watchLater = ArrayList();
playlists = ArrayList(); playlists = ArrayList();
allPlaylists = ArrayList();
val recyclerWatchLater = findViewById<RecyclerView>(R.id.recycler_watch_later); val recyclerWatchLater = findViewById<RecyclerView>(R.id.recycler_watch_later);
@@ -105,6 +123,7 @@ class PlaylistsFragment : MainFragment() {
buttonCreatePlaylist.setOnClickListener { buttonCreatePlaylist.setOnClickListener {
_slideUpOverlay = UISlideOverlays.showCreatePlaylistOverlay(findViewById<FrameLayout>(R.id.overlay_create_playlist)) { _slideUpOverlay = UISlideOverlays.showCreatePlaylistOverlay(findViewById<FrameLayout>(R.id.overlay_create_playlist)) {
val playlist = Playlist(it, arrayListOf()); val playlist = Playlist(it, arrayListOf());
allPlaylists.add(0, playlist);
playlists.add(0, playlist); playlists.add(0, playlist);
StatePlaylists.instance.createOrUpdatePlaylist(playlist); StatePlaylists.instance.createOrUpdatePlaylist(playlist);
@@ -120,6 +139,35 @@ class PlaylistsFragment : MainFragment() {
_appBar = findViewById(R.id.app_bar); _appBar = findViewById(R.id.app_bar);
_layoutWatchlist = findViewById(R.id.layout_watchlist); _layoutWatchlist = findViewById(R.id.layout_watchlist);
_listPlaylistsSearch.addTextChangedListener {
updatePlaylistsFiltering();
}
val spinnerSortBy: Spinner = findViewById(R.id.spinner_sortby);
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.playlists_sortby_array)).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
};
val options = listOf("nameAsc", "nameDesc", "dateEditAsc", "dateEditDesc", "dateCreateAsc", "dateCreateDesc", "datePlayAsc", "datePlayDesc");
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
when(pos) {
0 -> _ordering.setAndSave("nameAsc")
1 -> _ordering.setAndSave("nameDesc")
2 -> _ordering.setAndSave("dateEditAsc")
3 -> _ordering.setAndSave("dateEditDesc")
4 -> _ordering.setAndSave("dateCreateAsc")
5 -> _ordering.setAndSave("dateCreateDesc")
6 -> _ordering.setAndSave("datePlayAsc")
7 -> _ordering.setAndSave("datePlayDesc")
else -> _ordering.setAndSave("")
}
updatePlaylistsFiltering()
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
};
spinnerSortBy.setSelection(Math.max(0, options.indexOf(_ordering.value)));
findViewById<TextView>(R.id.text_view_all).setOnClickListener { _fragment.navigate<WatchLaterFragment>(context.getString(R.string.watch_later)); }; findViewById<TextView>(R.id.text_view_all).setOnClickListener { _fragment.navigate<WatchLaterFragment>(context.getString(R.string.watch_later)); };
StatePlaylists.instance.onWatchLaterChanged.subscribe(this) { StatePlaylists.instance.onWatchLaterChanged.subscribe(this) {
fragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
@@ -134,10 +182,12 @@ class PlaylistsFragment : MainFragment() {
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun onShown() { fun onShown() {
allPlaylists.clear();
playlists.clear() playlists.clear()
playlists.addAll( allPlaylists.addAll(
StatePlaylists.instance.getPlaylists().sortedByDescending { maxOf(it.datePlayed, it.dateUpdate, it.dateCreation) } StatePlaylists.instance.getPlaylists().sortedByDescending { maxOf(it.datePlayed, it.dateUpdate, it.dateCreation) }
); );
playlists.addAll(filterPlaylists(allPlaylists));
_adapterPlaylist.notifyDataSetChanged(); _adapterPlaylist.notifyDataSetChanged();
updateWatchLater(); updateWatchLater();
@@ -157,6 +207,32 @@ class PlaylistsFragment : MainFragment() {
return false; return false;
} }
private fun updatePlaylistsFiltering() {
val toFilter = allPlaylists ?: return;
playlists.clear();
playlists.addAll(filterPlaylists(toFilter));
_adapterPlaylist.notifyDataSetChanged();
}
private fun filterPlaylists(pls: List<Playlist>): List<Playlist> {
var playlistsToReturn = pls;
if(!_listPlaylistsSearch.text.isNullOrEmpty())
playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) };
if(!_ordering.value.isNullOrEmpty()) {
playlistsToReturn = when(_ordering.value){
"nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() }
"nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() };
"dateEditAsc" -> playlistsToReturn.sortedBy { it.dateUpdate ?: OffsetDateTime.MAX };
"dateEditDesc" -> playlistsToReturn.sortedByDescending { it.dateUpdate ?: OffsetDateTime.MIN }
"dateCreateAsc" -> playlistsToReturn.sortedBy { it.dateCreation ?: OffsetDateTime.MAX };
"dateCreateDesc" -> playlistsToReturn.sortedByDescending { it.dateCreation ?: OffsetDateTime.MIN }
"datePlayAsc" -> playlistsToReturn.sortedBy { it.datePlayed ?: OffsetDateTime.MAX };
"datePlayDesc" -> playlistsToReturn.sortedByDescending { it.datePlayed ?: OffsetDateTime.MIN }
else -> playlistsToReturn
}
}
return playlistsToReturn;
}
private fun updateWatchLater() { private fun updateWatchLater() {
val watchList = StatePlaylists.instance.getWatchLater(); val watchList = StatePlaylists.instance.getWatchLater();
if (watchList.isNotEmpty()) { if (watchList.isNotEmpty()) {
@@ -164,7 +240,7 @@ class PlaylistsFragment : MainFragment() {
_appBar.let { appBar -> _appBar.let { appBar ->
val layoutParams: CoordinatorLayout.LayoutParams = appBar.layoutParams as CoordinatorLayout.LayoutParams; val layoutParams: CoordinatorLayout.LayoutParams = appBar.layoutParams as CoordinatorLayout.LayoutParams;
layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 230.0f, resources.displayMetrics).toInt(); layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 315.0f, resources.displayMetrics).toInt();
appBar.layoutParams = layoutParams; appBar.layoutParams = layoutParams;
} }
} else { } else {
@@ -172,7 +248,7 @@ class PlaylistsFragment : MainFragment() {
_appBar.let { appBar -> _appBar.let { appBar ->
val layoutParams: CoordinatorLayout.LayoutParams = appBar.layoutParams as CoordinatorLayout.LayoutParams; val layoutParams: CoordinatorLayout.LayoutParams = appBar.layoutParams as CoordinatorLayout.LayoutParams;
layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 25.0f, resources.displayMetrics).toInt(); layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 110.0f, resources.displayMetrics).toInt();
appBar.layoutParams = layoutParams; appBar.layoutParams = layoutParams;
}; };
} }

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