Compare commits

...

507 Commits

Author SHA1 Message Date
Koen J 64938dba6c Changed default setting on clear cookies on login. 2026-04-27 12:14:52 +02:00
Koen 8b7d51cd70 Merge branch 'possiblebusyfix' into 'master'
Possible busy fix

See merge request videostreaming/grayjay!173
2026-04-27 10:14:01 +00:00
Koen ace7ca1551 Possible busy fix 2026-04-27 10:14:01 +00:00
Koen J 22b5adc4b8 Fix for #2614 2026-04-22 15:09:51 +02:00
Koen J 0f7fb9059b Made sub exchange default and fixed #2930. Also fixed unrelated export issues. 2026-04-22 15:02:22 +02:00
Koen J 05afa12274 Fix for #2068 2026-04-22 12:47:15 +02:00
Koen J b4a280cee8 Fixed up translation 2026-04-22 11:17:57 +02:00
koen-futo ac5d7eab2a Merge pull request #2135 from ddrews-de/patch-1
Update strings.xml for German Language
2026-04-22 11:14:07 +02:00
koen-futo b624d45ab6 Merge pull request #3132 from jraleman/master
feat: add support to sort downloads by type
2026-04-22 11:12:39 +02:00
koen-futo 5340088ada Merge pull request #3133 from GorlovDanila/patch-1
fixed bug: Playlist title blocks text/element below it
2026-04-22 11:09:53 +02:00
Koen J fcab0f5ee5 Potential fix for #3247 2026-04-22 10:23:40 +02:00
Koen J 80c9b27d48 Build fix 2026-04-17 15:33:15 +02:00
Koen J f54216d52f Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2026-04-16 16:42:36 +02:00
Koen J fea69d265a Article open bug. 2026-04-16 16:42:13 +02:00
Koen 030086e769 Merge branch 'marcus/cast-button-sync' into 'master'
casting: set cast button to inactive color when device is disconnected

See merge request videostreaming/grayjay!172
2026-04-16 14:40:04 +00:00
Koen 81516c31fb Merge branch 'marcus/casting-device-remove-comment' into 'master'
casting: remove old commented out code

See merge request videostreaming/grayjay!170
2026-04-16 14:39:55 +00:00
Marcus Hanestad 3d13a21700 casting: set cast button to inactive color when device is disconnected 2026-04-16 10:12:16 +02:00
Koen J c14d2580ee Update dependencies. 2026-04-01 13:40:35 +02:00
Marcus Hanestad 795259564d casting: remove old commented out code 2026-03-27 14:46:40 +01:00
Kelvin 81d0b08306 Merge branch 'tlg/opinion-dupe-bug' into 'master'
Opinion Dupe Bug

See merge request videostreaming/grayjay!169
2026-03-26 21:42:44 +00:00
Tim Giroux 9a97a901fb dupe opinion bug 2026-03-26 13:10:29 -05:00
Koen J d9b23eff62 Build fix. 2026-03-24 13:02:06 +01:00
Koen 8591deaf86 Merge branch 'plugin-init-failures' into 'master'
Handle plugin init failures per source

See merge request videostreaming/grayjay!168
2026-03-20 11:53:19 +00:00
Stefan 22c5581d00 Handle plugin init failures per source 2026-03-20 11:53:19 +00:00
Koen 6e815dc868 Merge branch 'new-plugins' into 'master'
Add Radio Browser, Red Bull TV and FOSDEM plugins

See merge request videostreaming/grayjay!167
2026-03-20 11:52:53 +00:00
Stefan 1ac409561c Add Radio Browser, Red Bull TV and FOSDEM plugins 2026-03-20 11:52:53 +00:00
Koen J 897ba8a560 Added error handling to onConsoleMessage. 2026-03-20 11:35:16 +01:00
Koen J 8982ea2289 Fix for Furilabs phone (thanks @Aleks K) 2026-03-16 15:52:31 +01:00
Koen f693f1e6b3 Edit deploy-stable.sh 2026-03-12 18:09:21 +00:00
Koen e118bc09b9 Edit deploy-unstable.sh 2026-03-12 18:07:57 +00:00
Kelvin 5ba77b60c8 Merge branch 'marcus/fcast-fix' into 'master'
fix fcast sender sdk

See merge request videostreaming/grayjay!166
2026-03-12 17:42:35 +00:00
Kelvin K 19b63ba372 Remove transient from plugin settings, submods 2026-03-12 12:34:04 -05:00
Marcus Hanestad 5fc39d3bb3 fix fcast sender sdk 2026-03-11 12:51:17 -05:00
Koen 1d046538f8 Merge branch 'marcus/fcast-sdk-0.4.1' into 'master'
upgrade fcast sdk to 0.4.1

See merge request videostreaming/grayjay!165
2026-03-10 13:30:43 +00:00
Marcus Hanestad 9f10b86861 upgrade fcast sdk to 0.4.1 2026-03-10 08:28:52 -05:00
Koen J d1336c711a Changed over deploy location to Cloudflare R2. 2026-03-03 10:37:25 +01:00
jraleman 837ee76bdc fix: sorting by download type 2026-02-28 21:46:12 -05:00
Koen 2a2ed08a3c Merge branch 'captcha-improvements' into 'master'
feat: propagate WebView user agent to plugins via bridge.captchaUserAgent and bridge.authUserAgent

See merge request videostreaming/grayjay!164
2026-02-27 06:59:36 +00:00
Stefan 8a0e49232e feat: propagate WebView user agent to plugins via bridge.captchaUserAgent and bridge.authUserAgent 2026-02-27 06:59:35 +00:00
Danila Gorlov 624ef3c6e9 fixed bug: Playlist title blocks text/element below it 2026-02-26 17:59:31 +03:00
jraleman 3d5b9a94fb feat: add support to sort downloads by type 2026-02-24 23:35:02 -05:00
Koen J a8decdb0d9 Updated FDroid pipeline. 2026-02-19 14:15:05 +01:00
Koen J 2609929780 FDroid automation in pipeline. 2026-02-19 14:06:53 +01:00
Koen J 2bcfbf89d3 Added proper automation for playstore builds. 2026-02-19 11:13:27 +01:00
Koen J fa1954ceef Fixes. 2026-02-16 11:35:29 +01:00
Koen J 13aa49726a Improved WaitTillLoaded. 2026-02-15 14:46:03 +01:00
Koen J 20bab7d056 Updated submodules. 2026-02-15 11:34:25 +01:00
Koen J cbf7ca0181 Fixes to make it less detectable. 2026-02-15 11:26:10 +01:00
Koen J b7477080d2 Add scripts on load. 2026-02-13 14:05:37 +01:00
Koen J ac5bc27581 Package browser wip 2026-02-13 13:40:00 +01:00
Koen J 748551af2a Added support for injecting scripts on bootup. 2026-02-13 12:20:30 +01:00
Koen J 9ce41bc8d0 Fixed issue where media session was not properly restarted after reopening the app after closing pip. 2026-02-11 11:10:57 +01:00
Koen J 8cf542e201 Improved auto backup flow. 2026-02-10 15:15:32 +01:00
Koen J 88950843b3 Fixed artwork not updating when in audio only. 2026-02-10 15:08:43 +01:00
Koen J 4a08058322 Run import on IO. 2026-02-10 14:48:03 +01:00
Koen J 7b76ba1539 Fixed resume after non manual pause. 2026-02-10 13:24:21 +01:00
Koen J 6492278e7d Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2026-02-10 12:16:04 +01:00
Koen J 9de9440160 Reworked automatic backups. 2026-02-10 12:15:34 +01:00
Koen 372af6cf47 Merge branch 'jf/futopay-2.0' into 'master'
Jf/futopay 2.0

See merge request videostreaming/grayjay!147
2026-02-09 16:02:55 +00:00
Justin Fowler 29d08c8554 Jf/futopay 2.0 2026-02-09 16:02:55 +00:00
Koen J cfeceabe5b Added italian to audio_languages. 2026-02-09 10:18:27 +01:00
koen-futo a51f609a92 Merge pull request #3028 from goodness-from-me-forks/goodness-from-me-patch-1
Add www.twitch.tv and m.twitch.tv to intent urls
2026-02-09 10:12:17 +01:00
Koen 15a655f196 Edit StateAnnouncement.kt 2026-02-07 09:45:35 +00:00
Kelvin c6525f1caa Clear cookies after login 2026-02-06 17:20:10 +01:00
Kelvin e147fdd77e Empty view for notifs, back on toggle off 2026-02-02 20:12:30 +01:00
Kelvin 6a8ac0bfaa Refs 2026-02-02 18:46:01 +01:00
Kelvin 772bff6bc0 Browser package fixes, advanced settings for plugin support 2026-02-02 18:41:51 +01:00
Kelvin b6b04054b9 Clear cookies on startup & after login 2026-01-31 21:20:31 +01:00
Kelvin 1ea794459c refs 2026-01-31 19:27:57 +01:00
Kelvin c27f5e4096 Cleanup fixes, v8 locking 2026-01-31 19:23:32 +01:00
Kelvin 8469f17b4c Fix threading for callbacks from browser 2026-01-31 13:15:09 +01:00
Kelvin 067abc415b Submods 2026-01-30 16:40:49 +01:00
Kelvin d692533f20 Merge branch 'package-browser' into 'master'
New Notification UI & PackageBrowser support

See merge request videostreaming/grayjay!163
2026-01-30 15:38:48 +00:00
Kelvin 31a6ea0f39 Browser support 2026-01-30 16:17:06 +01:00
Kelvin 5ba2f2be75 Package Browser support for testing 2026-01-27 05:13:15 +01:00
goodness-from-me 8e4ad54de1 Apply the same changes to unstable 2026-01-22 16:31:10 +00:00
goodness-from-me 6139696714 Add www.twitch.tv and m.twitch.tv to intent urls
Streams tend to post www.twitch.tv link in Telegram channels when stream is live. Also m.twitch.tv is a valid link too.
2026-01-22 16:29:42 +00:00
Kelvin 8536861e09 Update dialogs 2026-01-05 23:56:53 +01:00
Kelvin 71262da3c2 New notification ui 2026-01-02 20:38:43 +01:00
Koen J 60cd5976cc Updated ExoPlayer. 2025-12-31 13:11:16 +01:00
Koen 3ca6a1fd70 Merge branch 'marcus/remove-legacy-casting' into 'master'
casting: remove legacy backend

See merge request videostreaming/grayjay!162
2025-12-26 08:52:13 +00:00
Marcus Hanestad 0d8c8de450 casting: remove legacy backend 2025-12-25 23:04:10 +01:00
Koen J 8ba2fe9972 getOrNull should be used for original everywhere. 2025-12-23 15:52:15 +01:00
koen-futo 7a7ef533cc Merge pull request #2336 from realchrisolin/master
update configChanges so bluetooth keyboards don't recreate activity
2025-12-22 14:28:18 +01:00
Koen 5385549a43 Merge branch 'b23tv-intent-filter' into 'master'
Add b23.tv (BiliBili) to intent filters in AndroidManifest.xml

See merge request videostreaming/grayjay!161
2025-12-20 14:06:36 +00:00
Stefan 04deffc66e Add b23.tv (BiliBili) to intent filters in AndroidManifest.xml
related with https://github.com/futo-org/grayjay-android/issues/2537
2025-12-20 12:08:52 +00:00
Koen J 852f563c9a Renamed subtitles-1 2025-12-18 15:23:16 +01:00
Koen J c84cea9ea1 Remove animation for quality selector. 2025-12-18 14:37:44 +01:00
Koen J 5c162083d5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-18 08:23:26 +01:00
Koen J 3230e7c0b4 Draft fix for cast subtitles UMP. 2025-12-18 08:23:13 +01:00
Kelvin 8437825dd1 apply language filters to downloads 2025-12-17 20:29:45 +01:00
Kelvin 0fbe0bb438 Add filters for video languages to resolve excessive sources 2025-12-17 19:43:56 +01:00
Kelvin 34d2e62314 sub mods 2025-12-17 16:27:12 +01:00
Kelvin 1075ded170 Language for video support, original for video support, deduplication fix for languages on videos, submods 2025-12-17 15:32:37 +01:00
Koen J 80bb15f3fb Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-15 10:03:34 +01:00
Koen J 27a86a67f0 Updated submodules and fixed casting for combined request executor. 2025-12-15 10:03:18 +01:00
Koen 284b2a24f8 Merge branch 'marcus/casting-sdk-updates' into 'master'
casting: subscribe to and handle MediaItemEnd events

See merge request videostreaming/grayjay!158
2025-12-15 09:01:31 +00:00
Kelvin K 854d1506a6 Compile fix 2025-12-11 17:17:42 -06:00
Kelvin K 811fd4e73e Improved dl 2025-12-11 17:16:31 -06:00
Kelvin K 335988aa67 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-11 14:16:07 -06:00
Kelvin K 29a54fbed4 Download support combined 2025-12-11 14:15:55 -06:00
Koen J 3a11d0d9d1 Fixed HLS downloading for Twitch, DialyMotion, Nebula. 2025-12-05 15:31:31 +01:00
Koen J bda534e485 Various updates to bg update flow:
- Throttled progress updates in notifications resolving the notifications not showing under some conditions.
- Properly cancel notifications when interacting with in-app dialogs.
- Added install failed notification.
- Added install success notification.
- Added default behavior for tapping on notifications.
- Fixed crash in install receiver.
2025-12-04 11:18:00 +01:00
Kelvin K 09fd4c0881 Fix it asking for background updating when not required 2025-12-03 18:37:06 -06:00
Kelvin K 1667866a35 Hotfix invalid closed state 2025-12-03 18:08:36 -06:00
Kelvin K 035125d0f8 Hotfix invalid closed state 2025-12-03 18:06:38 -06:00
Kelvin K 1bb0cdc405 Add exception handling for background updater 2025-12-03 12:49:08 -06:00
Kelvin K 86019c80a1 Fix in-video login flow 2025-12-03 11:58:00 -06:00
Kelvin K 8c640d3def Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-03 11:44:58 -06:00
Kelvin K 7ed1e8a28b NEw install dialog, incognito dont show fix, crash fix old android search library 2025-12-03 11:44:27 -06:00
Koen J 3dcfe8c340 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-03 18:19:26 +01:00
Koen J 042ced81ef Fix for update when app is fully killed. 2025-12-03 18:18:43 +01:00
Kelvin K b37f48380b Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-03 11:10:35 -06:00
Kelvin K 0a02169782 Fix PiP for back button 2025-12-03 11:10:22 -06:00
Koen J f12e4390f3 Changed the order of buttons. 2025-12-03 17:48:47 +01:00
Koen J 82ab45d04e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-03 17:35:06 +01:00
Koen J 7f77c39296 Made notifications for update silent. 2025-12-03 17:33:41 +01:00
Kelvin K 99eee4f6ee Disable misbehaving thumbnail rendering 2025-12-03 10:27:40 -06:00
Kelvin K 68886502d1 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-03 09:58:16 -06:00
Kelvin K 26461c21c4 move to background on back with video 2025-12-03 09:58:08 -06:00
Koen J 300466f722 Update dialogs should nicely be hidden when interacting with notifications. 2025-12-03 16:33:51 +01:00
Koen J 961710cc8b Fixed thumbnails acting up and added support for library content thumbnail when casting. 2025-12-03 15:37:53 +01:00
Koen J eba995f87d Added support for casting local media content. 2025-12-03 14:09:33 +01:00
Koen a67244e79a Merge branch 'marcus/cast-dev-connection-state-fix' into 'master'
casting: set connectionState to correct value when disconnected

See merge request videostreaming/grayjay!160
2025-12-03 11:16:08 +00:00
Marcus Hanestad 70502a7651 casting: set connectionState to correct value when disconnected 2025-12-03 12:12:15 +01:00
Koen J 36b4f5b41d Potential fix for issue where cast icon doesn't properly turn blue at the right moment. 2025-12-03 12:04:54 +01:00
Kelvin K def39ba397 Diff loop icon, allow loop1 in playlist, fix queue clear on opening video on a channel page 2025-12-02 17:11:12 -06:00
Kelvin K 49d59f4466 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-02 15:21:36 -06:00
Kelvin K 1c9becc2ba Replace finalize with manual close for jsrequestexecutor, incognito icon change, show explanation for incognito 2025-12-02 15:21:22 -06:00
Koen 1cde591061 Merge branch 'bgupdate' into 'master'
BG update initial impl.

See merge request videostreaming/grayjay!159
2025-12-02 17:31:23 +00:00
Koen 8ac18f053c BG update initial impl. 2025-12-02 17:31:23 +00:00
Koen J 56bdae9ff1 Fixed crash related to ShapeLayout for BigButton. 2025-12-01 16:25:31 +01:00
Koen J 74ddfe9f0e 2 crash fixes. 2025-12-01 14:41:35 +01:00
Koen J acb9500e2a Re-enabled some logging. 2025-12-01 14:30:35 +01:00
Koen J 45f621763a Fixed thumbnail to consider max size 1920 and fitcenter and implemented fixes for pagination of library. 2025-12-01 14:20:41 +01:00
Koen J 0abc65a9bd Downsample if larger than 1080x1080 to prevent crashing. 2025-12-01 11:51:26 +01:00
Koen J 6d6309973e Fixed crash on devices that don't support android.content.pm.action.CONFIRM_INSTALL 2025-12-01 10:58:04 +01:00
Koen J 92ec085d25 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-12-01 09:31:24 +01:00
Koen J 767a8befaa Fixed crash happening due to recycled bitmap in notification. 2025-12-01 09:31:08 +01:00
Kelvin K 09763320dd login from devportal fix 2025-11-28 16:59:15 -06:00
Kelvin K 27fb2997f9 Minimize and maximize for prompted login 2025-11-28 15:30:03 -06:00
Kelvin K 0f46bc5888 improved motionlayout responsiveness 2025-11-28 12:15:08 -06:00
Kelvin K dccf4fcf3c more reliable motion event triggesr 2025-11-28 11:58:17 -06:00
Kelvin K da7fef1ecd Detect settings as a active tab 2025-11-28 11:37:52 -06:00
Kelvin K 58a89a00ef Make motionlayout transition coverage smaller 2025-11-28 11:24:03 -06:00
Kelvin K f2efc603ba Legacy text rendering for subtitles 2025-11-27 11:12:40 -06:00
Kelvin K efe074d272 menu bar contrast removal 2025-11-27 10:38:19 -06:00
Kelvin K 8a9efd3a0f Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-27 10:30:09 -06:00
Kelvin K 251302b9c3 Fix back behavior for Android 16 2025-11-27 10:29:55 -06:00
Koen J 5cdac1405e Reverted javet for compat with android 9. 2025-11-27 17:08:27 +01:00
Marcus Hanestad 894e400819 casting: subscribe to and handle MediaItemEnd events 2025-11-27 16:56:43 +01:00
Koen J 565ea7cb8b Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-27 13:41:55 +01:00
Koen J 9fa3e22d2e Crash fixes related to remoteLast. 2025-11-27 13:41:21 +01:00
Kelvin 5548783337 Merge branch 'revert-d902306f' into 'master'
Revert "Revert old ffmpeg"

See merge request videostreaming/grayjay!157
2025-11-26 20:06:29 +00:00
Kelvin 0dca8798cb Revert "Revert old ffmpeg"
This reverts commit d902306fe4
2025-11-26 20:06:16 +00:00
Kelvin K d902306fe4 Revert old ffmpeg 2025-11-26 13:35:09 -06:00
Kelvin K baa2a4fcf3 Deps 2025-11-26 12:05:24 -06:00
Kelvin K 8be7ad9f68 Alignment for more menu 2025-11-26 10:12:52 -06:00
Kelvin K 992cbcb3a0 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-26 09:38:03 -06:00
Kelvin K e0857aea9b Window pan for keyboard 2025-11-26 09:37:35 -06:00
Koen J 50cd0723c9 JNI fixes. 2025-11-26 13:45:01 +01:00
Koen J 4c4b322682 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-26 11:50:33 +01:00
Koen J 7cff8568c0 Reverted to use FFMPEG for combining HLS segments. 2025-11-26 11:33:46 +01:00
Kelvin K 801c646a09 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-25 11:30:02 -06:00
Kelvin K df4ec87613 Possible crash fix for attempted access to fragment before available, fix loader showing on no results 2025-11-25 11:29:47 -06:00
Koen J b08a79b7cb Fixed plugin config missing from httpclient. 2025-11-25 14:13:06 +01:00
Koen J 396e9f9f43 Implemented support for isUrlAllowed in HttpImp. 2025-11-25 12:44:57 +01:00
Koen J 0e5a87a911 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-25 12:33:16 +01:00
Koen J 64d72f6d10 Implemented cookie support for httpimp. 2025-11-25 12:29:18 +01:00
Kelvin K 5b40da109b Artist thumbnail now fall back to album thumbnail 2025-11-25 00:40:38 +01:00
Kelvin K 949294952f Show alpha warning only once 2025-11-25 00:09:17 +01:00
Kelvin K 40a058e369 Fix empty library clear, comment ui change 2025-11-24 23:36:12 +01:00
Kelvin K a070d78dd9 Crash fix on polycentric failure 2025-11-24 22:19:41 +01:00
Kelvin K 105ac538bb Diff android version check 2025-11-24 21:59:19 +01:00
Kelvin K ce2029774e Deal with older Android versions 2025-11-24 21:57:03 +01:00
Kelvin 50c63d7e8d Merge branch 'new-menu' into 'master'
New Grid-style menu

See merge request videostreaming/grayjay!156
2025-11-24 18:00:01 +00:00
Kelvin K d3534080d7 abstract out a request proccessor 2025-11-24 17:09:53 +01:00
Koen J b5025193a5 Added www.odysee.com in AndroidManifest. 2025-11-24 11:14:43 +01:00
Koen J 3f85b7ed78 Further work on http imp. 2025-11-24 10:33:18 +01:00
Kelvin K 98d008ef6c new menu polished, toggles, etc 2025-11-22 01:02:20 +01:00
Kelvin K 20eb53fc38 New menu system 2025-11-21 19:27:28 +01:00
Koen J 1ea7b307fa Removed spotify from plugin config. 2025-11-21 14:20:40 +01:00
Koen J f18571e0b2 Removed spotify from Android. 2025-11-21 14:19:32 +01:00
Koen J 70872d429a Fixed loop video in the case where you switched to cast. 2025-11-21 10:42:26 +01:00
Koen J cbf3db6e30 Fixed loop and autoplay while casting. 2025-11-21 10:05:33 +01:00
Koen J 0be0dcfadc More fixes to CastView sizing. 2025-11-20 19:34:29 +01:00
Koen J abd226c33d Implemented fix for castview becoming too tall. 2025-11-20 19:19:51 +01:00
Koen J 89dbdc99a0 Fixed issue where audio mode toggle wouldn't work properly when coming back into app while in a playlist. 2025-11-20 15:24:41 +01:00
Koen J f89ed18a49 CChanged copy comment to button. 2025-11-20 14:29:36 +01:00
Koen J 77ac2b537c Fixed get last queue. 2025-11-20 13:50:25 +01:00
Koen J 8ab03b6b66 Adjusted center area. 2025-11-20 13:41:33 +01:00
Koen J dad70e57c6 Implemented center double tap to play/pause. 2025-11-20 12:25:52 +01:00
Koen J eb9c6c8330 Implemented support for 3x default playback speed. 2025-11-20 12:07:07 +01:00
Koen J 68da797f4d Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-19 14:48:44 +01:00
Koen J 25948dd296 More robust HLS downloading. 2025-11-19 14:48:29 +01:00
Koen 10d39d6ed1 Merge branch 'aw/polycentric-profiles' into 'master'
Fix QR code generation for large polycentric export bundles

See merge request videostreaming/grayjay!155
2025-11-19 12:01:34 +00:00
Koen 85e8e674dd Edit Settings.kt 2025-11-19 08:45:22 +00:00
Kelvin 0d70392bf0 Minor fixes 2025-11-18 23:55:29 +01:00
Kelvin f89b074d28 Various improvements to library and other fixes 2025-11-18 23:35:34 +01:00
Kelvin ee2af411aa Request modifier download fixes. 2025-11-18 15:23:35 +01:00
Kelvin 9ffbe6dd03 Merge branch 'download-request-modifier' into 'master'
download request modifier

See merge request videostreaming/grayjay!141
2025-11-18 13:15:32 +00:00
Kelvin 0ae6ac2fac Always update dialog option, login as fragment fixes, ongoing cursor crash fix 2025-11-18 01:03:21 +01:00
Kelvin fd835cc54e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-17 21:28:21 +01:00
Kelvin 07f3140038 Add timeout for plugin updates and better texts, bottom menu lighter disable shade, library cursor cleanup 2025-11-17 21:28:07 +01:00
Koen J 3e753d70de Last queue playlist fix. 2025-11-17 16:47:11 +01:00
Koen J d578c47975 Properly propagated request modifier in casting. 2025-11-17 13:47:16 +01:00
Koen J b7a61425ca Implemented history position playlist id tracking. 2025-11-15 15:22:52 +01:00
Koen J 727f977672 Implemented last queue saving. 2025-11-15 12:41:22 +01:00
Koen J fc9d5eeb27 Fixing export download video progress viewing. 2025-11-15 12:01:52 +01:00
Kelvin f17e147b4e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-15 03:42:17 +01:00
Kelvin 1c569b465b Possible crash fix 2025-11-15 03:42:04 +01:00
Koen J 6289c85bd5 Devportal fix with settings. 2025-11-14 13:20:21 +01:00
Koen J 098599853b Fixed issue where a pending video would not be added to queue when using add to queue feature. 2025-11-14 12:26:38 +01:00
Koen J 68d11f6d58 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-14 09:39:55 +01:00
Koen J 74f6b9aa62 Language should be optional. 2025-11-14 09:39:19 +01:00
austin fc2aba0120 import identity from file 2025-11-13 18:40:30 -06:00
austin e4f51bb130 export profile to file 2025-11-13 18:22:58 -06:00
austin 9e9d26c752 Full screen QR code viewer 2025-11-13 18:22:30 -06:00
austin 5c5dd3af44 Merge branch 'master' into aw/polycentric-profiles 2025-11-13 17:40:53 -06:00
Kelvin 4433364cd8 Fix build error 2025-11-13 23:46:57 +01:00
Kelvin 2c957d7188 Submods 2025-11-13 15:01:15 +01:00
Kelvin f229f4ed1f Merge branch 'wip-library' into 'master'
Library Support (On-device music & videos)

See merge request videostreaming/grayjay!154
2025-11-13 13:53:14 +00:00
Kelvin e8d1f73e29 Bottom bar highlighting change 2025-11-13 14:52:16 +01:00
Koen J dd2cf18cb2 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-13 14:49:50 +01:00
Koen J 5355602577 Implemented httpimp. 2025-11-13 14:49:05 +01:00
Kelvin 8cc82e4d16 Possible fix for dropping live playback 2025-11-13 04:05:08 +01:00
austin d6468ba283 Merge branch 'master' into aw/polycentric-profiles 2025-11-12 19:23:30 -06:00
Kelvin 4b5ed38175 Reset settings and share settings buttons 2025-11-13 01:23:43 +01:00
Kelvin 75eb7359de Fix various ref to old activity settings 2025-11-12 23:55:44 +01:00
Kelvin fd519d48cf Settings as fragments instead 2025-11-12 23:01:41 +01:00
Koen 6f1866ac27 Merge branch 'marcus/casting-stop-playback-before-disconnect' into 'master'
Casting: stop video playback before disconnecting from the active device

See merge request videostreaming/grayjay!152
2025-11-12 12:41:10 +00:00
Kelvin 0dc0f07785 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into wip-library 2025-11-11 23:16:36 +01:00
Kelvin bae8cb7bc4 Library search support 2025-11-11 23:16:16 +01:00
Kelvin d5a696289b Even more library work 2025-11-11 01:13:35 +01:00
Kelvin 75ef7085eb Library UI, artists listing, new album layout, etc 2025-11-09 23:43:13 +01:00
Kelvin 347ef855b3 Library continuation, disable auto backup ask, minor tweaks. 2025-11-08 19:02:38 +01:00
Koen J aac19aef86 Long press moved to layout comment. 2025-11-06 14:26:11 +01:00
Koen J 33efc5c21d Upgraded all dependencies and changed double tap to long press on comment text. 2025-11-06 14:25:27 +01:00
Koen J fc7001c295 Added double click to copy button on comments. 2025-11-06 11:29:24 +01:00
Koen J 9b68394f70 Added setting for persisting subtitles across multiple videos when the same language exists. 2025-11-05 15:02:33 +01:00
Koen J e2ef8c2593 Shorts player keep screen on interaction. 2025-11-05 12:18:47 +01:00
Koen J 551bfe44ac Loader game visible now allows going into pip automatically. 2025-11-05 09:04:01 +01:00
Koen J 6fbfa98ad3 Made the resume more persistent and not visible when loader game is visible. 2025-11-03 17:58:03 +01:00
Kelvin 9b97e05e3b File browser support 2025-10-30 21:19:47 +01:00
austin 62a2f42d68 Fix QR code generation for large polycentric export bundles
- Add GZIP compression for large export data (>2000 chars)
- Implement fallback QR generation with different error correction levels
- Add automatic decompression support in import functionality
- Improve error handling with fallback to text display
- Add localized error messages for QR code failures
- Add compression ratio logging for debugging

This fixes the 'Data too big' error when generating QR codes for
polycentric profile exports by automatically compressing large data
and providing multiple fallback mechanisms.
2025-10-28 18:26:36 -05:00
Kelvin da44e86163 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into wip-library 2025-10-23 01:59:30 +02:00
Kelvin 682b86330e Library work 2025-10-23 01:59:03 +02:00
Marcus Hanestad c9ba8a09e2 casting: stop video playback before disconnecting from device 2025-10-22 13:26:08 +02:00
Koen 7d19c2357c Merge branch 'aw/polycentric-moderation' into 'master'
Polycentric Moderation

See merge request videostreaming/grayjay!151
2025-10-16 11:13:27 +00:00
austin 64030a038c Polycentric Moderation 2025-10-16 11:13:27 +00:00
Kelvin 87d93c2ed8 WIP library support, albums, artists, videos 2025-10-15 01:03:47 +02:00
Koen 9d9ad52535 Merge branch 'marcus/exp-casting-device-pinning-fix' into 'master'
casting(experimental): ignore devices that are unsupported or fails to parse

See merge request videostreaming/grayjay!150
2025-10-14 07:10:45 +00:00
Marcus Hanestad b10cf6a323 casting(experimental): ignore devices that are unsupported or fails to parse 2025-10-09 09:41:32 +02:00
Koen J 4407e82d8a Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-10-08 16:39:14 +02:00
Koen J 3113dc53a6 fix: Session not authorized showing when it shouldn't 2025-10-08 16:38:10 +02:00
Koen bd25276720 Merge branch 'zvonimir-dev' into 'master'
fix: Session not authorized showing when it shouldn't

See merge request videostreaming/grayjay!149
2025-10-08 13:28:38 +00:00
z2rec 29d3a9986e fix: Session not authorized showing when it shouldn't 2025-10-08 15:27:34 +02:00
Koen J bce93b8e0f Changed intent codes for pip to match with intent codes from notification. 2025-10-07 16:21:37 +02:00
Koen 9a950958f9 Merge branch 'possible-ui-fixes' into 'master'
Possible ui fixes

See merge request videostreaming/grayjay!148
2025-10-06 17:40:35 +00:00
Kelvin b6676e7763 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-10-06 19:14:27 +02:00
Kelvin 35fe093e5c Convert promise cancel exceptions to conventional exceptions 2025-10-06 19:13:52 +02:00
Koen J 7cad4fbe07 Fixed crash in TextView drag drop. 2025-10-06 12:58:10 +02:00
Koen J 240772790d Possible fixes for other activities. 2025-10-06 11:51:04 +02:00
Koen J d659ecc518 Possible fixes for DownloadService issues. 2025-10-06 11:00:47 +02:00
Koen J 7d8bb20b71 Possible fixes for DownloadService issues. 2025-10-06 11:00:36 +02:00
Koen J 1cf5f776d5 Trial 1 2025-10-03 19:22:46 +02:00
Koen J 137ba85538 Sync pairing will now always happen in parallel for direct and relayed and reduced amount of occupied threads. 2025-10-03 14:11:48 +02:00
Kelvin 642d218c54 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-09-29 18:45:56 +02:00
Kelvin 26b5470200 Fix crash fix on async promise handling 2025-09-29 18:45:42 +02:00
Koen J 547fe7bc13 Updated target SDK. 2025-09-29 15:46:45 +02:00
Koen J 678305e366 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-09-29 15:28:31 +02:00
Koen J 9f07673d85 Updated compile SDK. 2025-09-29 15:27:51 +02:00
Kelvin 19429263a9 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-09-29 15:01:19 +02:00
Kelvin 986652adab Refs 2025-09-29 15:01:04 +02:00
Koen J 4d93a58d5d Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-09-29 13:15:58 +02:00
Koen J 817c90f3af Translation fix. 2025-09-29 13:15:06 +02:00
Kelvin 77348b3787 Refs 2025-09-29 13:06:42 +02:00
Kelvin 31e26d03c6 Merge 2025-09-29 12:45:00 +02:00
Kelvin 1ef566ab16 Async fixes, local file playback support 2025-09-29 12:31:17 +02:00
Koen J 7597f5136c Fix Android getting stuck. 2025-09-26 13:46:43 +02:00
Koen 9a2a70622f Merge branch 'marcus/fcast-casting-sdk' into 'master'
Experimental casting backend

See merge request videostreaming/grayjay!145
2025-09-10 15:26:16 +00:00
Marcus Hanestad 4fc33411fd Experimental casting backend 2025-09-10 15:26:16 +00:00
Kelvin a9bb900994 Change when plugins are disabled on reload and listing reloads 2025-09-08 19:00:41 +02:00
Koen J 8c1a18d8b4 Build fixes. 2025-08-27 09:58:54 +02:00
Koen J 14ae5f1572 Fixed translations to align. 2025-08-26 21:32:13 +02:00
koen-futo ed40994600 Merge pull request #2357 from 0xrxL/master
Italian localization
2025-08-26 21:14:14 +02:00
koen-futo 90e8c35b19 Merge pull request #2096 from alpqn/master
Added Turkish Translations
2025-08-26 21:13:47 +02:00
Kelvin 4d017ad357 Refs 2025-08-24 21:41:07 +02:00
Kelvin 2ca2a9db23 Workaround for global lifetime scope unavailable 2025-08-24 19:35:15 +02:00
Kelvin 713d46c781 Refs 2025-08-21 22:23:07 +02:00
Kelvin 0429665173 Fix title for relay server 2025-08-21 22:18:00 +02:00
Kelvin ac05edca77 Setting to disable short filling 2025-08-21 22:14:30 +02:00
Kelvin ad3dacf68f Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-08-21 22:07:15 +02:00
Kelvin 91a8996c11 Shorts fix video size scaling for some aspect ratios, long press support for tags, home plugin filters now support long press to only select that one 2025-08-21 22:06:52 +02:00
Kelvin 40c4a51a2b Dialog input support, configurable relay server, radio views select/deselect all long press 2025-08-21 20:41:18 +02:00
Kelvin f8e0aaf4d2 Merge branch 'fix-logincall' into 'master'
fix: login prompt looping on search video

See merge request videostreaming/grayjay!144
2025-08-21 16:00:35 +00:00
zvonimir ad97b5a406 fix: login prompt looping on search video 2025-08-21 17:59:08 +02:00
Kelvin b0e0c1b75f Merge branch 'PiP-play-pause-fix' into 'master'
PiP Play Pause Fix

See merge request videostreaming/grayjay!143
2025-08-20 13:41:43 +00:00
Kai b1fce443e9 fix pause and play buttons not working correctly in PiP
Changelog: changed
2025-08-20 08:39:12 -04:00
Kelvin 66f8711055 Fix login warnings working on redirects 2025-08-19 17:52:14 +02:00
Kelvin b7c123c281 Refs 2025-08-19 16:45:57 +02:00
Kelvin 9481bbf3f1 Vod chat button fix, default settings in devportal 2025-08-19 16:42:17 +02:00
Kelvin 43ec7e821b Refs 2025-08-18 21:31:49 +02:00
Kelvin ca3454afbe Login warning fixes, uimod (disabled) 2025-08-18 19:35:12 +02:00
Kai 7c70e58129 use request modifier when downloading url sources
Changelog: changed
2025-08-18 10:40:47 -04:00
Kelvin 1edc8aabf8 Fix login dialog 2025-08-15 21:20:23 +02:00
Kelvin 91060faac9 VOD chat 2025-08-15 16:36:38 +02:00
Kelvin 17027ba364 Remote history sync on toggle 2025-08-14 21:03:39 +02:00
Kelvin 8569eaa5db Hide DevSubmit filter 2025-08-14 20:36:56 +02:00
Kelvin d32d817e0a Merge branch 'shorts-improv' into 'master'
Fix background play, disable artwork on background till improved, renamed...

See merge request videostreaming/grayjay!140
2025-08-14 11:26:47 +00:00
Kelvin a0f4cc760c Fix background play, disable artwork on background till improved, renamed variable that caused confusion 2025-08-14 12:35:46 +02:00
Kelvin 5247997ea5 Set plugin install request timeouts, fix messaging surrounding downloading icons 2025-08-13 19:36:26 +02:00
Kelvin 453030d561 Merge branch 'shorts-improv' into 'master'
Various shorts improvements, login warnings support, etc

See merge request videostreaming/grayjay!138
2025-08-13 16:11:30 +00:00
Kelvin e080702a52 Fix dislike color 2025-08-13 17:56:27 +02:00
Kelvin 3909343adc Pre-generate support shorts, subtitle size, short like/dislike color 2025-08-13 00:23:54 +02:00
Kelvin dc76934d0e Add explicit long type for dash dwonload length 2025-08-12 17:05:54 +02:00
Kelvin 6cf47d592a Various shorts improvements, login warnings support, etc 2025-08-12 02:03:04 +02:00
Kai 1507c70729 fix https://github.com/futo-org/grayjay-android/issues/2585
Changelog: changed
2025-08-11 16:45:03 -04:00
Kai d6a23ac0de fix PiP issue
reproduction steps

- play a video
- swipe home to enter PiP
- minimize the video and then close it with the X
- swipe home (PiP will launch even though it shouldn't because nothing is playing)

Changelog: changed
2025-08-11 14:48:23 -05:00
koen-futo 17df396672 Merge pull request #2597 from alpqn/patch-1
Fix typos
2025-08-11 10:56:28 +02:00
quonverbat 0c5ba0cd39 Fix typos 2025-08-10 16:22:56 +03:00
Koen 183aeb18a0 Merge branch 'plugin-add-mixcloud' into 'master'
Add mixcloud plugin

See merge request videostreaming/grayjay!137
2025-08-07 08:00:16 +00:00
Stefan 8d08e19cd2 Add mixcloud plugin 2025-08-07 08:00:17 +01:00
Kelvin a882d04d26 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-08-01 21:56:00 +02:00
Kelvin c4d06c1ba2 Hide sync ui, thumbnails nullable 2025-08-01 21:55:47 +02:00
Kelvin 4dfcd47901 Merge branch 'fix-null-thumbnails-error' into 'master'
fix: erroring out when thumbnails are null which causes sync to reset

See merge request videostreaming/grayjay!136
2025-07-31 16:37:29 +00:00
zvonimir 4c0c1abb4b fix: erroring out when thumbnails are null which causes sync to reset 2025-07-31 18:34:24 +02:00
Kelvin 6f44071186 Merge branch 'update-docs' into 'master'
docs: add section for Request Modifiers in Content types document

See merge request videostreaming/grayjay!134
2025-07-31 14:00:17 +00:00
Stefan 29910a2698 docs: add section for Request Modifiers in Content types document 2025-07-31 14:00:17 +00:00
Kelvin b5da0d4462 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-07-30 18:22:27 +02:00
Kelvin 99fb9b3462 VOD chat support 2025-07-30 18:22:15 +02:00
Koen J 5f0a89d13b Implemented URLs open outside of live chat webview. 2025-07-30 13:46:46 +02:00
Koen f311561e6f Merge branch 'fix-android-anr-swap-sources' into 'master'
Fix Android ANR in SwapSources.

See merge request videostreaming/grayjay!135
2025-07-29 09:51:15 +00:00
Koen J 2fc944ddd9 Cleanup. 2025-07-29 11:15:49 +02:00
Koen J a2970b86ee Fixed issue where Scan QR button vanishes due to missing owner activity and fixed issue where remembered devices do not show until at least one normal device is found. 2025-07-29 10:58:41 +02:00
Kelvin ac9a51f105 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-07-29 01:39:48 +02:00
Kelvin 90dca2537a getUserHistory support 2025-07-29 01:39:32 +02:00
Koen J 4df227147c Fix Android ANR in SwapSources. 2025-07-28 15:44:37 +02:00
Koen J 1fb55dca0a On casting device disconnect, only set play when ready to true if it was also playing on the TV device. 2025-07-24 13:44:04 +02:00
Koen J 3d7b347e49 Do not call playVideo on reconnects, but instead check MEDIA_STATUS. 2025-07-24 13:05:28 +02:00
Koen J 769ec9f59a Only auto relaunch player the first time ChromeCast is started, do not reset time to 0 if player is not found, stop casting if ChromeCast player is disconnected. 2025-07-24 12:23:23 +02:00
Koen J dee310de3d Potential crashfix for downloads. 2025-07-24 11:16:48 +02:00
Koen 0af4bad906 Merge branch 'hls-url-redirect-fix' into 'master'
Nebula Download Fix

See merge request videostreaming/grayjay!129
2025-07-22 11:54:41 +00:00
Koen 4731673ba3 Merge branch 'fix-fullscreen-ui-offset' into 'master'
Fullscreen UI Fix

See merge request videostreaming/grayjay!116
2025-07-22 09:41:42 +00:00
Koen J 8745221cbd Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into fix-fullscreen-ui-offset 2025-07-22 11:39:20 +02:00
Koen 742d95440e Merge branch 'pip-improvement' into 'master'
implement the quick PiP feature

See merge request videostreaming/grayjay!107
2025-07-22 09:38:39 +00:00
Koen J 180b320cd7 Merge branch 'pip-improvement' of gitlab.futo.org:videostreaming/grayjay into pip-improvement 2025-07-22 11:31:27 +02:00
Koen J cc8dffc485 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into pip-improvement 2025-07-22 11:30:35 +02:00
Koen a64fd2cf35 Merge branch 'align-manifests' into 'master'
Align Manifests

See merge request videostreaming/grayjay!123
2025-07-21 15:07:27 +00:00
Koen 4aceb364d9 Edit AndroidManifest.xml 2025-07-21 15:06:57 +00:00
Koen J 76d9bac0ec Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-07-21 17:03:14 +02:00
Koen J 2b8dc41d0d Artwork should only show when audio mode is not transitioning out. 2025-07-21 17:03:00 +02:00
Koen 33430c538c Merge branch 'preview-feed-fix-constraints' into 'master'
Feed Preview Graphical Bug

See merge request videostreaming/grayjay!121
2025-07-21 14:01:37 +00:00
Koen J 03e9cb398b Made isLimitedVersion check more specific. 2025-07-21 15:54:48 +02:00
Koen J 2aef2ebec1 Background playback fixes for limited version and artwork now shows while in background playback. 2025-07-21 15:43:43 +02:00
Koen J 5e5fffbf97 loadPager should not be called on init. Small fix for restoring brightness when exiting app. 2025-07-21 14:57:03 +02:00
Koen J 51ac604e31 Various crash fixes. 2025-07-21 14:41:36 +02:00
Koen 4e49b5bc63 Merge branch 'shorts-tab' into 'master'
shorts tab

See merge request videostreaming/grayjay!92
2025-07-21 12:33:14 +00:00
Koen 658cbc5e00 Edit FCastCastingDevice.kt 2025-07-18 08:34:22 +00:00
Koen J 2ceb4c5644 Fixed issue where streams are not proxied when a request modifier is present. 2025-07-17 11:15:38 +02:00
quonverbat 940bed2cee Merge branch 'futo-org:master' into master 2025-07-12 22:12:21 +03:00
Kai 2738954af7 add hard coded padding to compensate for this bug
https://gitlab.futo.org/videostreaming/grayjay/-/merge_requests/133

Changelog: changed
2025-07-11 15:40:01 -05:00
Kai db5aaf0b84 fix delay when opening quality overlay
Changelog: changed
2025-07-11 13:50:32 -05:00
Kai e1abb7f8ae prevent scroll to top from showing when it shouldn't
fix zero state

Changelog: changed
2025-07-11 12:18:02 -05:00
Koen J 3310ac6008 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into shorts-tab 2025-07-11 10:16:21 +02:00
Kai 09879c83e9 fix sources button click
Changelog: changed
2025-07-10 10:45:24 -05:00
Kai 7aa8b6bc14 add zero state for shorts tab
Changelog: changed
2025-07-10 10:40:52 -05:00
Kai cac8a8fde4 add back button when in channel shorts
hide refresh button when in channel shorts

prevent main player being open when viewing shorts

show info toast when long pressing refresh button

switch bottom bar button ids back to their original values

Changelog: changed
2025-07-10 09:30:42 -05:00
Kelvin 01cb544dfd Dont lock clients when disabling 2025-07-07 16:42:37 +02:00
Kelvin b9239b6177 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-07-07 16:18:35 +02:00
Kelvin 96ca3f62a2 Missing invokev8 wrappers 2025-07-07 16:18:23 +02:00
Koen J 73ad783881 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-07-07 16:12:33 +02:00
Koen J 3bfcf65535 Fixes to reload required exception handling for casting. 2025-07-07 16:12:03 +02:00
Kelvin 8b3b27a2a8 Stop pagers silently if the underlying object is closed 2025-07-07 16:04:19 +02:00
Kelvin a4d4835a89 Reduice font size 2025-07-07 14:26:17 +02:00
Kelvin 56c0f7bfaf Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-07-07 14:14:51 +02:00
Kelvin 736424ae35 Refs 2025-07-07 14:14:39 +02:00
Koen J 37dc778009 Fixed casting. 2025-07-07 12:45:45 +02:00
Koen J cd3cea58a4 Fixed race condition when awaiting and changing video source.. 2025-07-07 10:52:42 +02:00
Koen J 8b53e9e5e3 Processed last feedback on minigame. 2025-07-05 18:09:49 +02:00
Koen J 08e98b089c Improvements to target tap loader game. 2025-07-05 17:32:31 +02:00
Koen J 5528d71da8 Show score toast. 2025-07-05 14:07:49 +02:00
Koen J 83f520ca44 Further fixes to TargetTApLoaderView. 2025-07-05 13:47:48 +02:00
Koen J cc247ce634 Attempt at a loader game. 2025-07-05 12:58:33 +02:00
Kelvin c6caa59a90 Refs 2025-07-04 18:00:48 +02:00
Kelvin 00e28b9ce0 Better raid messaging, loader autochange to indeterminate, refs 2025-07-04 17:27:35 +02:00
Kelvin 334f58979a Add missing file 2025-07-04 16:13:11 +02:00
Kelvin 940bf163da Progress bar color, refs 2025-07-04 15:58:46 +02:00
Kelvin 2bbe0e6133 Substitute v8 object calls to wrapper function 2025-07-04 15:24:19 +02:00
Kelvin 861f34a287 Refs 2025-07-04 15:02:01 +02:00
Koen J 82f214f155 Merge branch 'shorts-tab' of gitlab.futo.org:videostreaming/grayjay into shorts-tab 2025-07-04 08:16:34 +02:00
Koen J 4ee127fe13 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into shorts-tab 2025-07-04 08:16:12 +02:00
Koen J 86a4cf8d84 Implemented FutoVideoPlayer loader. 2025-07-04 08:09:24 +02:00
Kelvin 2c463dd5a1 Merge branch 'wip-async' into 'master'
Basic Async support

See merge request videostreaming/grayjay!132
2025-07-03 17:19:27 +00:00
Kelvin ed3820bec0 Finish async generates 2025-07-03 19:15:14 +02:00
Kelvin 542a7f212d Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into wip-async 2025-07-03 19:05:38 +02:00
Kelvin 8fb0826d69 WIP rewrite generate to async 2025-07-03 19:05:13 +02:00
Koen J deeaa55f56 Changed default behavior for isOutgoing. 2025-07-03 14:40:27 +02:00
Koen J 5b954727a1 Implemented incoming and outgoing raids. 2025-07-03 14:30:15 +02:00
Koen fae77c1a63 Merge branch 'rgba-colors' into 'master'
RGBA colors.

See merge request videostreaming/grayjay!131
2025-07-03 07:44:19 +00:00
Kelvin b69402dfe9 WIP Async support for Android 2025-07-03 00:44:54 +02:00
Koen J 1f3e306a59 RGBA colors. 2025-07-02 17:59:13 +02:00
Koen J a9605118fb Clip to outline does not make sense for a ShapeableImageView. 2025-07-01 10:02:06 +02:00
Kelvin d22e918273 Missing catches 2025-06-26 16:17:17 +02:00
Kelvin bdcb94055a Refs 2025-06-26 15:13:41 +02:00
Kelvin d0644d39da Theoretical fix for networked file import 2025-06-26 15:01:18 +02:00
Kelvin 8f3f776e22 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-26 14:35:30 +02:00
Kelvin 548752e240 missing lock 2025-06-26 14:35:00 +02:00
Koen J 7f20250951 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-24 11:43:59 +02:00
Koen J 4d720b1d81 Fixed app freezing when exporting Polycentric Identity #2405 2025-06-24 11:43:40 +02:00
Kai 1e4aefb7d5 fix https://github.com/futo-org/grayjay-android/issues/2386
Changelog: changed
2025-06-20 12:00:56 -05:00
Kai 2a825a9f83 platform icon
fix text shadow

Changelog: changed
2025-06-20 09:55:39 -05:00
Kelvin K a8921a1aba Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-20 15:35:30 +02:00
Kelvin K edb9eda0a9 Improved locking 2025-06-20 15:35:02 +02:00
Koen J 3a81676447 Fixed crash #2389. 2025-06-20 10:47:10 +02:00
Kai 6695774037 icon updates
Changelog: changed
2025-06-19 11:14:00 -05:00
Koen J 03132ff77b Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-19 11:23:28 +02:00
Koen J 49ddecdea4 Potential crashfix #2382. 2025-06-19 11:21:46 +02:00
Kai a10bc8c7de fix very wide screen videos enter PiP mode
Changelog: changed
2025-06-18 12:12:39 -05:00
Kai c1e6e401cc formatting
Changelog: changed
2025-06-18 09:36:31 -05:00
Kelvin K 44ff951ec6 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-18 16:22:51 +02:00
Kelvin K 11319e0ec5 Refs 2025-06-18 16:22:28 +02:00
Koen J 100e98a960 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-18 15:43:45 +02:00
Koen J c6100ede70 Added disable for hold playback rate increase. 2025-06-18 15:43:12 +02:00
Kelvin K a2986a72bd Refs 2025-06-18 14:43:20 +02:00
Kelvin K e0e90c5f74 submodules 2025-06-18 14:33:07 +02:00
Kelvin K 11992af81b Hide duration if unknown 2025-06-18 14:27:20 +02:00
Koen J 15d771f7fc Fixed channel loader not being animated. 2025-06-18 13:43:50 +02:00
Kelvin K 5ede474253 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-18 12:41:43 +02:00
Kelvin K 7922aa6f80 Log on busy on main 2025-06-18 12:41:21 +02:00
Kelvin K 0c1333fa15 Downgrade v8, revert comments on diff thread 2025-06-18 12:40:25 +02:00
Koen J 53b9ba0368 Reverted changes. 2025-06-18 10:29:12 +02:00
Koen J c3a8877796 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-18 10:07:29 +02:00
Koen J a464ae9df5 Added missing loader causing crash. 2025-06-18 10:07:02 +02:00
Kai 98b6213886 remove layout changed listener
Changelog: changed
2025-06-17 15:26:27 -05:00
Kai b6671c653c add text shadow
add HQ icon for quality settings

Changelog: changed
2025-06-17 13:45:15 -05:00
Kai 55d042bee3 add platform logo
Changelog: changed
2025-06-17 12:07:29 -05:00
Kelvin K 0d16dd0006 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-06-17 17:28:17 +02:00
Kelvin K 48a96140a7 isBusy checks and locking improvements 2025-06-17 17:28:10 +02:00
Kelvin 603ef8f295 Merge branch 'fix-timeout-locking' into 'master'
fix: timeoutMap being deadlocked

See merge request videostreaming/grayjay!126
2025-06-17 15:26:51 +00:00
zvonimir ab07288ba0 fix: timeoutMap being deadlocked 2025-06-17 17:25:34 +02:00
Kelvin K c0bbe5d491 Additional locking 2025-06-17 15:21:46 +02:00
Kelvin K b953ff21e7 Lock on subtitle fetch 2025-06-17 11:52:26 +02:00
Kelvin K c14378b534 Improved V8 locking, comment section on diff thread than video, global mapping of v8runtimes to plugins 2025-06-17 11:45:02 +02:00
Kai 80034ad131 update refresh text
Changelog: changed
2025-06-16 13:25:02 -05:00
Kelvin K 33d3d9a29c Improved locking 2025-06-16 19:30:52 +02:00
Kelvin K 7e83793586 Submods 2025-06-16 18:34:37 +02:00
Kelvin K 6ba9ec8bc2 Clearer name setting 2025-06-16 17:56:04 +02:00
Kelvin 0b02ab0e2d Merge branch 'plugin-fixes' into 'master'
V8 Update, V8 interaction locking, Package fixes, ReloadRequiredException support

See merge request videostreaming/grayjay!125
2025-06-16 15:48:01 +00:00
Kelvin K ff531b5e77 Cleanup, fixes, clearCookies support on httpClients 2025-06-16 17:46:00 +02:00
Kelvin K b3f9de3b83 edgecase fix 2025-06-16 14:23:34 +02:00
Kelvin K 86bd71b89c Fix edgecase 2025-06-16 14:19:23 +02:00
Kelvin K 2fca7e9a01 Locking of most known v8 interactions, fix returning previously returned jvm objects, Related fixes 2025-06-16 14:13:47 +02:00
Koen 2cc873ef60 Merge branch 'quality-selector-fix' into 'master'
fix graphical glitches with quality selector

See merge request videostreaming/grayjay!109
2025-06-16 10:07:16 +00:00
Koen 7a66ce6bcd Merge branch 'sources-tab-scrolling-fix' into 'master'
Sources Scrolling Fix

See merge request videostreaming/grayjay!114
2025-06-16 10:01:45 +00:00
Koen 2730569b6b Merge branch 'tablet-landscape-fix' into 'master'
Tablet Landscape Fix

See merge request videostreaming/grayjay!115
2025-06-16 09:57:57 +00:00
Koen ede5c4409c Merge branch 'watch-later-add-feature' into 'master'
Water Later Add Feature

See merge request videostreaming/grayjay!117
2025-06-16 09:54:43 +00:00
Koen 0dbe398435 Merge branch 'hls-quality-sort' into 'master'
Adaptive Quality Sort

See merge request videostreaming/grayjay!118
2025-06-16 09:41:22 +00:00
Koen J bcab3bccbc Fixed crash when signature fields are wrongly populated. 2025-06-16 10:43:57 +02:00
Kelvin K 58c9aeb1a2 WIP: V8 update, package http fixes, ReloadRequiredException support, other fixes. Currently broken in situations where setTimeout is used 2025-06-14 15:51:31 +02:00
0xrxL 4eb20a1843 Typo 2025-06-14 15:29:49 +02:00
0xrxL 98c6378148 Fix spacing 2025-06-14 09:04:40 +02:00
0xrxL bb066a7a31 Added italian localization 2025-06-14 09:02:25 +02:00
0xrxL b5d3261f03 Add files via upload 2025-06-14 09:01:41 +02:00
Kelvin K 4702787784 WIP 2025-06-13 17:47:22 +02:00
Kai 30c41044da Merge branch 'master' into pip-improvement
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt
2025-06-13 08:45:09 -05:00
Kai e369676808 remove spotify.com because those are not handled by the spotify plugin
Changelog: changed
2025-06-12 14:17:56 -05:00
Kai 2fa9e65bee align stable and unstable manifests
Changelog: changed
2025-06-12 14:13:08 -05:00
Kai cf96bd1ec0 Add functionality to open channel shorts in shorts fragment
Changelog: changed
2025-06-12 14:01:11 -05:00
Kai 1f5a069877 Fix like button checked when not logged into polycentric
Open video details when tapping title

Changelog: changed
2025-06-12 10:42:08 -05:00
Koen J 13100dc38d Minor fix in playback speed setting. 2025-06-12 11:21:00 +02:00
Koen J 5227041398 Added setting for hold playback speed increase. Implemented chromecast playback rate adjustment in range [1, 2]. Implemented hold playback speed increase pill. 2025-06-12 10:33:05 +02:00
Kelvin 8491d4da1a Merge branch 'fix-ump-downloads' into 'master'
Revert downloads patch which broke downloads

See merge request videostreaming/grayjay!122
2025-06-11 16:41:20 +00:00
zvonimir 9bea1563ca Revert downloads patch which broke downloads 2025-06-11 18:36:05 +02:00
Kai adc5013ea4 Fix the constraints on the feed preview items
Changelog: changed
2025-06-11 10:30:48 -05:00
Koen J 9e7b936663 Implemented hold to play video at 2x speed gesture. 2025-06-11 17:03:53 +02:00
Kelvin 19c84475db Hotfix playback speed for non-dot locales 2025-06-10 23:27:01 +02:00
Kai 515c5e00e9 Fix live stream PiP mode
Prevent splash screen when opening PiP mode

Changelog: changed
2025-06-10 15:27:14 -05:00
Chris Olin 09bc180d4f update configChanges so bluetooth keyboards don't recreate activity 2025-06-10 13:25:45 -04: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
Kai 9944842a2f Change adaptive streaming (HLS and Dash) quality to sort in descending quality to align with YouTube and the rest of Grayjay
Changelog: changed
2025-06-09 17:02:55 -05:00
Kai 99dc50894c update text
Changelog: changed
2025-06-09 16:54:24 -05:00
Kai 6598dff6df add add to watch later setting
add https://github.com/futo-org/grayjay-android/issues/2173

Changelog: added
2025-06-06 23:35:59 -05:00
Kai ba9f843368 fix https://github.com/futo-org/grayjay-android/issues/2165
Changelog: changed
2025-06-06 15:45:12 -05:00
Kai 623c47fa2e fix https://github.com/futo-org/grayjay-android/issues/2210
Changelog: changed
2025-06-06 15:25:46 -05:00
Kai 19861fe812 fix https://github.com/futo-org/grayjay-android/issues/2316
Changelog: changed
2025-06-06 13:40:20 -05:00
Kai c333300906 fix graphical glitches with quality selector
Changelog: changed
2025-06-05 11:08:19 -05:00
Kai 0653f88c49 Merge branch 'master' into shorts-tab
# Conflicts:
#	app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt
#	app/src/main/java/com/futo/platformplayer/fragment/mainactivity/bottombar/MenuBottomBarFragment.kt
2025-06-04 13:03:40 -05:00
Kai 4ce9f64808 make code review changes
switch to TaskHandler
switch to XML

Changelog: changed
2025-06-04 12:59:48 -05:00
quonverbat 755bebaecb Merge branch 'futo-org:master' into master 2025-05-30 00:07:27 +03:00
Kai 4fa0229ccb implement the quick PiP feature
https://developer.android.com/develop/ui/views/picture-in-picture#setautoenterenabled

Changelog: changed
2025-05-29 12:38:37 -05:00
Kai 42dd8d6152 add TODO
Changelog: changed
2025-04-14 12:40:47 -05:00
Daniel Drews 76a42f5f6f Update strings.xml for German Language
some small translation correction, some extended translations, where sentences had just one word as translation and add missing translations from the english strings.xml
2025-04-13 10:41:29 +02:00
Kai 0a839b4814 fix crash
Changelog: changed
2025-04-11 10:43:33 -05:00
Kai 586db317dd catch exception remove UI lag
Changelog: changed
2025-04-10 10:17:10 -05:00
Kai ae36a24ad1 catch exception remove UI lag
Changelog: changed
2025-04-10 10:16:30 -05:00
Kai 9a435f8859 fix loading bar and app switching
Changelog: changed
2025-04-09 12:16:07 -05:00
Kai 81162c5df2 fix crash on early loading
Changelog: changed
2025-04-08 18:58:56 -05:00
Kai c7c3ddfc96 fix progress bar offset
Changelog: changed
2025-04-08 18:43:40 -05:00
Kai 830d3a9022 Merge branch 'master' into shorts-tab 2025-04-08 17:58:55 -05:00
Kai a1c2d19daf refactor shorts code
Changelog: changed
2025-04-08 17:58:31 -05:00
quonverbat 004e4be4d3 Added Turkish Translations 2025-04-02 00:32:10 +03:00
Kai bd87a47551 finished UI and interactions
Changelog: added
2025-04-01 11:25:07 -05:00
Kai DeLorenzo 76103a2a8c fix dialog missing 2025-03-19 15:12:48 -05:00
Kai f63f9dd6db initial POC shorts tab
Changelog: added
2025-03-07 14:27:18 -06:00
529 changed files with 29222 additions and 6365 deletions
+4
View File
@@ -1,2 +1,6 @@
aar/* filter=lfs diff=lfs merge=lfs -text aar/* filter=lfs diff=lfs merge=lfs -text
app/aar/* filter=lfs diff=lfs merge=lfs -text app/aar/* filter=lfs diff=lfs merge=lfs -text
app/src/main/jniLibs/arm64-v8a filter=lfs diff=lfs merge=lfs -text
app/src/main/jniLibs/armeabi-v7a filter=lfs diff=lfs merge=lfs -text
app/src/main/jniLibs/x86 filter=lfs diff=lfs merge=lfs -text
app/src/main/jniLibs/x86_64 filter=lfs diff=lfs merge=lfs -text
+2 -2
View File
@@ -26,7 +26,7 @@ body:
label: Reproduction steps label: Reproduction steps
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. 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: | placeholder: |
0. Play a Youtube video 0. Play a YouTube video
1. Press on Download button 1. Press on Download button
2. Select quality 1440p 2. Select quality 1440p
3. Grayjay crashes when attempting to download 3. Grayjay crashes when attempting to download
@@ -83,7 +83,7 @@ body:
- "Spotify" - "Spotify"
- "TedTalks" - "TedTalks"
- "Twitch" - "Twitch"
- "Youtube" - "YouTube"
- "Other" - "Other"
validations: validations:
required: true required: true
+39 -19
View File
@@ -1,37 +1,57 @@
variables:
GIT_SUBMODULE_STRATEGY: recursive
stages:
- buildAndDeployApkUnstable
- buildAndDeployApkStable
- buildAndDeployPlaystore
buildAndDeployApkUnstable: buildAndDeployApkUnstable:
stage: buildAndDeployApkUnstable stage: build
script: script:
- sh deploy-unstable.sh - sh deploy-unstable.sh
only: only:
- tags - tags
except:
- ^(dev)
when: manual when: manual
needs: []
allow_failure: true
artifacts:
when: always
expire_in: 30 days
paths:
- app/build/outputs/apk/unstable/release/*.apk
buildAndDeployApkStable: buildAndDeployApkStable:
stage: buildAndDeployApkStable stage: build
script: script:
- sh deploy-stable.sh - sh deploy-stable.sh
only: only:
- tags - tags
except:
- branches
when: manual when: manual
needs: []
artifacts:
when: always
expire_in: 30 days
paths:
- app/build/outputs/apk/stable/release/*.apk
buildAndDeployPlaystore: buildAndDeployPlaystore:
stage: buildAndDeployPlaystore stage: deploy
script: script:
- sh deploy-playstore.sh - sh build-playstore.sh
- bash tools/venv_playstore.sh
- . .venv-playstore/bin/activate
- python publish_playstore.py --sa /root/grayjay.json --package com.futo.platformplayer.playstore --aab ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab --track production --status completed
only: only:
- tags - tags
except: when: on_success
- branches needs:
when: manual - buildAndDeployApkStable
artifacts:
when: always
expire_in: 30 days
paths:
- app/build/outputs/bundle/playstoreRelease/*.aab
updateFdroidRepo:
stage: deploy
only:
- tags
when: on_success
needs:
- job: buildAndDeployApkStable
artifacts: true
script:
- python3 update_fdroid_index.py
+24 -6
View File
@@ -64,12 +64,6 @@
[submodule "app/src/stable/assets/sources/bilibili"] [submodule "app/src/stable/assets/sources/bilibili"]
path = app/src/stable/assets/sources/bilibili path = app/src/stable/assets/sources/bilibili
url = ../plugins/bilibili.git url = ../plugins/bilibili.git
[submodule "app/src/stable/assets/sources/spotify"]
path = app/src/stable/assets/sources/spotify
url = ../plugins/spotify.git
[submodule "app/src/unstable/assets/sources/spotify"]
path = app/src/unstable/assets/sources/spotify
url = ../plugins/spotify.git
[submodule "app/src/stable/assets/sources/bitchute"] [submodule "app/src/stable/assets/sources/bitchute"]
path = app/src/stable/assets/sources/bitchute path = app/src/stable/assets/sources/bitchute
url = ../plugins/bitchute.git url = ../plugins/bitchute.git
@@ -106,3 +100,27 @@
[submodule "app/src/stable/assets/sources/crunchyroll"] [submodule "app/src/stable/assets/sources/crunchyroll"]
path = app/src/stable/assets/sources/crunchyroll path = app/src/stable/assets/sources/crunchyroll
url = ../plugins/crunchyroll.git url = ../plugins/crunchyroll.git
[submodule "app/src/stable/assets/sources/mixcloud"]
path = app/src/stable/assets/sources/mixcloud
url = ../plugins/mixcloud.git
[submodule "app/src/unstable/assets/sources/mixcloud"]
path = app/src/unstable/assets/sources/mixcloud
url = ../plugins/mixcloud.git
[submodule "app/src/unstable/assets/sources/radiobrowser"]
path = app/src/unstable/assets/sources/radiobrowser
url = ../plugins/radiobrowser.git
[submodule "app/src/stable/assets/sources/radiobrowser"]
path = app/src/stable/assets/sources/radiobrowser
url = ../plugins/radiobrowser.git
[submodule "app/src/stable/assets/sources/redbull-tv"]
path = app/src/stable/assets/sources/redbull-tv
url = ../plugins/redbull-tv.git
[submodule "app/src/unstable/assets/sources/redbull-tv"]
path = app/src/unstable/assets/sources/redbull-tv
url = ../plugins/redbull-tv.git
[submodule "app/src/unstable/assets/sources/fosdem"]
path = app/src/unstable/assets/sources/fosdem
url = ../plugins/fosdem.git
[submodule "app/src/stable/assets/sources/fosdem"]
path = app/src/stable/assets/sources/fosdem
url = ../plugins/fosdem.git
-3
View File
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ea10d3c5562c9f449a4e89e9c3dfcf881ed79a952f3409bc005bcc62c2cf4b81
size 65512557
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:22c06ca0d1a5808b2fc0a12227d5915b3126bc0b9b1305cf6bab855f2ec6fcbb
size 36133152
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:50b53a5d297d0c3008b3359a452a5c9e7e9f530533ec197e3dd1e9f08f9e84ad
size 6342128
+47 -45
View File
@@ -1,8 +1,8 @@
plugins { plugins {
id 'com.android.application' id 'com.android.application'
id 'org.jetbrains.kotlin.android' id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.21' id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.21'
id 'org.ajoberstar.grgit' version '5.2.2' id 'org.ajoberstar.grgit' version '5.3.3'
id 'com.google.protobuf' id 'com.google.protobuf'
id 'kotlin-parcelize' id 'kotlin-parcelize'
id 'com.google.devtools.ksp' id 'com.google.devtools.ksp'
@@ -39,7 +39,7 @@ protobuf {
android { android {
namespace 'com.futo.platformplayer' namespace 'com.futo.platformplayer'
compileSdk 34 compileSdk 36
flavorDimensions "buildType" flavorDimensions "buildType"
productFlavors { productFlavors {
stable { stable {
@@ -97,7 +97,7 @@ android {
defaultConfig { defaultConfig {
minSdk 28 minSdk 28
targetSdk 34 targetSdk 36
versionCode gitVersionCode versionCode gitVersionCode
versionName gitVersionName versionName gitVersionName
@@ -146,6 +146,7 @@ android {
} }
sourceSets { sourceSets {
main { main {
jniLibs.srcDirs = ['src/main/jniLibs']
assets { assets {
srcDirs 'src/main/assets', 'src/tests/assets', 'src/test/assets' srcDirs 'src/main/assets', 'src/tests/assets', 'src/test/assets'
} }
@@ -154,79 +155,80 @@ android {
} }
dependencies { dependencies {
implementation 'com.google.dagger:dagger:2.48' //implementation 'com.google.dagger:dagger:2.48'
implementation 'androidx.test:monitor:1.7.2' implementation 'androidx.test:monitor:1.8.0'
annotationProcessor 'com.google.dagger:dagger-compiler:2.48' implementation 'com.google.android.material:material:1.13.0'
//annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
//Core //Core
implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.core:core-ktx:1.17.0'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'com.google.android.material:material:1.11.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.documentfile:documentfile:1.1.0'
//Images //Images
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0' annotationProcessor 'com.github.bumptech.glide:compiler:5.0.5'
implementation 'com.github.bumptech.glide:glide:4.16.0' implementation 'com.github.bumptech.glide:glide:5.0.5'
//Async //Async
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
//HTTP //HTTP
implementation "com.squareup.okhttp3:okhttp:4.11.0" implementation "com.squareup.okhttp3:okhttp:5.3.0"
//JSON //JSON
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2" //Used for structured json implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" //Used for structured json
implementation 'com.google.code.gson:gson:2.10.1' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject) implementation 'com.google.code.gson:gson:2.13.2' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
//JS //JS
implementation("com.caoccao.javet:javet-android:3.0.2") implementation 'com.caoccao.javet:javet-v8-android:4.1.5'
//Exoplayer //Exoplayer
implementation 'androidx.media3:media3-exoplayer:1.2.1' implementation 'androidx.media3:media3-exoplayer:1.9.0'
implementation 'androidx.media3:media3-exoplayer-dash:1.2.1' implementation 'androidx.media3:media3-exoplayer-dash:1.9.0'
implementation 'androidx.media3:media3-ui:1.2.1' implementation 'androidx.media3:media3-ui:1.9.0'
implementation 'androidx.media3:media3-exoplayer-hls:1.2.1' implementation 'androidx.media3:media3-exoplayer-hls:1.9.0'
implementation 'androidx.media3:media3-exoplayer-rtsp:1.2.1' implementation 'androidx.media3:media3-exoplayer-rtsp:1.9.0'
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.2.1' implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.9.0'
implementation 'androidx.media3:media3-transformer:1.2.1' implementation 'androidx.media3:media3-transformer:1.9.0'
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.6' implementation 'androidx.navigation:navigation-fragment-ktx:2.9.6'
implementation 'androidx.navigation:navigation-ui-ktx:2.7.6' implementation 'androidx.navigation:navigation-ui-ktx:2.9.6'
implementation 'androidx.media:media:1.7.0' implementation 'androidx.media:media:1.7.1'
//Other //Other
implementation 'org.jsoup:jsoup:1.15.3' implementation 'org.jsoup:jsoup:1.21.2'
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 fileTree(dir: 'aar', include: ['*.aar']) implementation fileTree(dir: 'aar', include: ['*.aar'])
implementation 'com.arthenica:smart-exception-java:0.2.1' implementation 'com.arthenica:smart-exception-java:0.2.1'
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0' implementation 'org.jetbrains.kotlin:kotlin-reflect:2.2.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.5.3'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0' implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
implementation 'com.caverock:androidsvg-aar:1.4' implementation 'com.caverock:androidsvg-aar:1.4'
implementation 'androidx.webkit:webkit:1.15.0'
//Protobuf //Protobuf
implementation 'com.google.protobuf:protobuf-javalite:3.25.1' implementation 'com.google.protobuf:protobuf-javalite:4.33.0'
implementation 'com.polycentric.core:app:1.0' implementation 'com.polycentric.core:app:1.0'
implementation 'com.futo.futopay:app:1.0' implementation 'com.futo.futopay:app:1.0'
implementation 'androidx.work:work-runtime-ktx:2.9.0' implementation 'androidx.work:work-runtime-ktx:2.11.0'
implementation 'androidx.concurrent:concurrent-futures-ktx:1.1.0' implementation 'androidx.concurrent:concurrent-futures-ktx:1.3.0'
//Database //Database
implementation("androidx.room:room-runtime:2.6.1") implementation("androidx.room:room-runtime:2.8.3")
annotationProcessor("androidx.room:room-compiler:2.6.1") ksp("androidx.room:room-compiler:2.8.3")
ksp("androidx.room:room-compiler:2.6.1") implementation("androidx.room:room-ktx:2.8.3")
implementation("androidx.room:room-ktx:2.6.1")
//Payment //Payment
implementation 'com.stripe:stripe-android:20.35.1' implementation 'com.stripe:stripe-android:22.0.0'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2'
testImplementation "org.jetbrains.kotlin:kotlin-test:1.8.22" testImplementation "org.jetbrains.kotlin:kotlin-test:2.0.21"
testImplementation "org.xmlunit:xmlunit-core:2.9.1" testImplementation "org.xmlunit:xmlunit-core:2.11.0"
testImplementation "org.mockito:mockito-core:5.4.0" testImplementation "org.mockito:mockito-core:5.20.0"
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
} }
@@ -0,0 +1,38 @@
package com.futo.platformplayer
import android.graphics.Color
import org.junit.Assert.assertEquals
import org.junit.Test
import toAndroidColor
class CSSColorTests {
@Test
fun test1() {
val androidHex = "#80336699"
val androidColorInt = Color.parseColor(androidHex)
val cssHex = "#33669980"
val cssColor = CSSColor.parseColor(cssHex)
assertEquals(
"CSSColor($cssHex).toAndroidColor() should equal Color.parseColor($androidHex)",
androidColorInt,
cssColor.toAndroidColor(),
)
}
@Test
fun test2() {
val androidHex = "#123ABC"
val androidColorInt = Color.parseColor(androidHex)
val cssHex = "#123ABCFF"
val cssColor = CSSColor.parseColor(cssHex)
assertEquals(
"CSSColor($cssHex).toAndroidColor() should equal Color.parseColor($androidHex)",
androidColorInt,
cssColor.toAndroidColor()
)
}
}
@@ -1,111 +0,0 @@
package com.futo.platformplayer
import android.util.Base64
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.futo.platformplayer.casting.FCastCastingDevice
import com.futo.platformplayer.casting.Opcode
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
import com.futo.platformplayer.casting.models.FCastKeyExchangeMessage
import com.futo.platformplayer.casting.models.FCastPlayMessage
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import java.security.KeyFactory
import java.security.spec.PKCS8EncodedKeySpec
import javax.crypto.spec.SecretKeySpec
@RunWith(AndroidJUnit4::class)
class FCastEncryptionTests {
@Test
fun testDHEncryptionSelf() {
val keyPair1 = FCastCastingDevice.generateKeyPair()
val keyPair2 = FCastCastingDevice.generateKeyPair()
Log.i("testDHEncryptionSelf", "privates (1: ${Base64.encodeToString(keyPair1.private.encoded, Base64.NO_WRAP)}, 2: ${Base64.encodeToString(keyPair2.private.encoded, Base64.NO_WRAP)})")
val keyExchangeMessage1 = FCastCastingDevice.getKeyExchangeMessage(keyPair1)
val keyExchangeMessage2 = FCastCastingDevice.getKeyExchangeMessage(keyPair2)
Log.i("testDHEncryptionSelf", "publics (1: ${keyExchangeMessage1.publicKey}, 2: ${keyExchangeMessage2.publicKey})")
val aesKey1 = FCastCastingDevice.computeSharedSecret(keyPair1.private, keyExchangeMessage2)
val aesKey2 = FCastCastingDevice.computeSharedSecret(keyPair2.private, keyExchangeMessage1)
assertEquals(Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP), Base64.encodeToString(aesKey2.encoded, Base64.NO_WRAP))
Log.i("testDHEncryptionSelf", "aesKey ${Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP)}")
val message = FCastPlayMessage("text/html")
val serializedBody = Json.encodeToString(message)
val encryptedMessage = FCastCastingDevice.encryptMessage(aesKey1, FCastDecryptedMessage(Opcode.Play.value.toLong(), serializedBody))
Log.i("testDHEncryptionSelf", Json.encodeToString(encryptedMessage))
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey1, encryptedMessage)
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
assertEquals(serializedBody, decryptedMessage.message)
}
@Test
fun testAESKeyGeneration() {
val cases = listOf(
listOf(
//Public other
"MIIBHzCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECA4GEAAKBgEnOS0oHteVA+3kND3u4yXe7GGRohy1LkR9Q5tL4c4ylC5n4iSwWSoIhcSIvUMWth6KAhPhu05sMcPY74rFMSS2AGTNCdT/5KilediipuUMdFVvjGqfNMNH1edzW5mquIw3iXKdfQmfY/qxLTI2wccyDj4hHFhLCZL3Y+shsm3KF",
//Private self
"MIIBIQIBADCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECBIGDAoGAeo/ceIeH8Jt1ZRNKX5aTHkMi23GCV1LtcS2O6Tktn9k8DCv7gIoekysQUhMyWtR+MsZlq2mXjr1JFpAyxl89rqoEPU6QDsGe9q8R4O8eBZ2u+48mkUkGSh7xPGRQUBvmhH2yk4hIEA8aK4BcYi1OTsCZtmk7pQq+uaFkKovD/8M=",
//AES
"7dpl1/6KQTTooOrFf2VlUOSqgrFHi6IYxapX0IxFfwk="
),
listOf(
//Public other
"MIIBHzCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECA4GEAAKBgGvIlCP/S+xpAuNEHSn4cEDOL1esUf+uMuY2Kp5J10a7HGbwzNd+7eYsgEc4+adddgB7hJgTvjsGg7lXUhHQ7WbfbCGgt7dbkx8qkic6Rgq4f5eRYd1Cgidw4MhZt7mEIOKrHweqnV6B9rypbXjbqauc6nGgtwx+Gvl6iLpVATRK",
//Private self
"MIIBIQIBADCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECBIGDAoGAMXmiIgWyutbaO+f4UiMAb09iVVSCI6Lb6xzNyD2MpUZyk4/JOT04Daj4JeCKFkF1Fq79yKhrnFlXCrF4WFX00xUOXb8BpUUUH35XG5ApvolQQLL6N0om8/MYP4FK/3PUxuZAJz45TUsI/v3u6UqJelVTNL83ltcFbZDIfEVftRA=",
//AES
"a2tUSxnXifKohfNocAQHkAlPffDv6ReihJ7OojBGt0Q="
)
)
for (case in cases) {
val decodedPrivateKey1 = Base64.decode(case[1], Base64.NO_WRAP)
val keyExchangeMessage2 = FCastKeyExchangeMessage(1, case[0])
val keyFactory = KeyFactory.getInstance("DH")
val privateKeySpec = PKCS8EncodedKeySpec(decodedPrivateKey1)
val privateKey = keyFactory.generatePrivate(privateKeySpec)
val aesKey1 = FCastCastingDevice.computeSharedSecret(privateKey, keyExchangeMessage2)
assertEquals(case[2], Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP))
}
}
@Test
fun testDHEncryptionKnown() {
val decodedPrivateKey1 = Base64.decode("MIIDJwIBADCCAhgGCSqGSIb3DQEDATCCAgkCggEBAJVHXPXZPllsP80dkCrdAvQn9fPHIQMTu0X7TVuy5f4cvWeM1LvdhMmDa+HzHAd3clrrbC/Di4X0gHb6drzYFGzImm+y9wbdcZiYwgg9yNiW+EBi4snJTRN7BUqNgJatuNUZUjmO7KhSoK8S34Pkdapl1OwMOKlWDVZhGG/5i5/J62Du6LAwN2sja8c746zb10/WHB0kdfowd7jwgEZ4gf9+HKVv7gZteVBq3lHtu1RDpWOSfbxLpSAIZ0YXXIiFkl68ZMYUeQZ3NJaZDLcU7GZzBOJh+u4zs8vfAI4MP6kGUNl9OQnJJ1v0rIb/yz0D5t/IraWTQkLdbTvMoqQGywsCggEAQt67naWz2IzJVuCHh+w/Ogm7pfSLiJp0qvUxdKoPvn48W4/NelO+9WOw6YVgMolgqVF/QBTTMl/Hlivx4Ek3DXbRMUp2E355Lz8NuFnQleSluTICTweezy7wnHl0UrB3DhNQeC7Vfd95SXnc7yPLlvGDBhllxOvJPJxxxWuSWVWnX5TMzxRJrEPVhtC+7kMlGwsihzSdaN4NFEQD8T6AL0FG2ILgV68ZtvYnXGZ2yPoOPKJxOjJX/Rsn0GOfaV40fY0c+ayBmibKmwTLDrm3sDWYjRW7rGUhKlUjnPx+WPrjjXJQq5mR/7yXE0Al/ozgTEOZrZZWm+kaVG9JeGk8egSCAQQCggEAECNvEczf0y6IoX/IwhrPeWZ5IxrHcpwjcdVAuyZQLLlOq0iqnYMFcSD8QjMF8NKObfZZCDQUJlzGzRsG0oXsWiWtmoRvUZ9tQK0j28hDylpbyP00Bt9NlMgeHXkAy54P7Z2v/BPCd3o23kzjgXzYaSRuCFY7zQo1g1IQG8mfjYjdE4jjRVdVrlh8FS8x4OLPeglc+cp2/kuyxaVEfXAG84z/M8019mRSfdczi4z1iidPX6HgDEEWsN42Ud60mNKy5jsQpQYkRdOLmxR3+iQEtGFjdzbVhVCUr7S5EORU9B1MOl5gyPJpjfU3baOqrg6WXVyTvMDaA05YEnAHQNOOfA==", Base64.NO_WRAP)
val keyExchangeMessage2 = FCastKeyExchangeMessage(1, "MIIDJTCCAhgGCSqGSIb3DQEDATCCAgkCggEBAJVHXPXZPllsP80dkCrdAvQn9fPHIQMTu0X7TVuy5f4cvWeM1LvdhMmDa+HzHAd3clrrbC/Di4X0gHb6drzYFGzImm+y9wbdcZiYwgg9yNiW+EBi4snJTRN7BUqNgJatuNUZUjmO7KhSoK8S34Pkdapl1OwMOKlWDVZhGG/5i5/J62Du6LAwN2sja8c746zb10/WHB0kdfowd7jwgEZ4gf9+HKVv7gZteVBq3lHtu1RDpWOSfbxLpSAIZ0YXXIiFkl68ZMYUeQZ3NJaZDLcU7GZzBOJh+u4zs8vfAI4MP6kGUNl9OQnJJ1v0rIb/yz0D5t/IraWTQkLdbTvMoqQGywsCggEAQt67naWz2IzJVuCHh+w/Ogm7pfSLiJp0qvUxdKoPvn48W4/NelO+9WOw6YVgMolgqVF/QBTTMl/Hlivx4Ek3DXbRMUp2E355Lz8NuFnQleSluTICTweezy7wnHl0UrB3DhNQeC7Vfd95SXnc7yPLlvGDBhllxOvJPJxxxWuSWVWnX5TMzxRJrEPVhtC+7kMlGwsihzSdaN4NFEQD8T6AL0FG2ILgV68ZtvYnXGZ2yPoOPKJxOjJX/Rsn0GOfaV40fY0c+ayBmibKmwTLDrm3sDWYjRW7rGUhKlUjnPx+WPrjjXJQq5mR/7yXE0Al/ozgTEOZrZZWm+kaVG9JeGk8egOCAQUAAoIBAGlL9EYsrFz3I83NdlwhM241M+M7PA9P5WXgtdvS+pcalIaqN2IYdfzzCUfye7lchVkT9A2Y9eWQYX0OUhmjf8PPKkRkATLXrqO5HTsxV96aYNxMjz5ipQ6CaErTQaPLr3OPoauIMPVVI9zM+WT0KOGp49YMyx+B5rafT066vOVbF/0z1crq0ZXxyYBUv135rwFkIHxBMj5bhRLXKsZ2G5aLAZg0DsVam104mgN/v75f7Spg/n5hO7qxbNgbvSrvQ7Ag/rMk5T3sk7KoM23Qsjl08IZKs2jjx21MiOtyLqGuCW6GOTNK4yEEDF5gA0K13eXGwL5lPS0ilRw+Lrw7cJU=")
val keyFactory = KeyFactory.getInstance("DH")
val privateKeySpec = PKCS8EncodedKeySpec(decodedPrivateKey1)
val privateKey = keyFactory.generatePrivate(privateKeySpec)
val aesKey1 = FCastCastingDevice.computeSharedSecret(privateKey, keyExchangeMessage2)
assertEquals("vI5LGE625zGEG350ggkyBsIAXm2y4sNohiPcED1oAEE=", Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP))
val message = FCastPlayMessage("text/html")
val serializedBody = Json.encodeToString(message)
val encryptedMessage = FCastCastingDevice.encryptMessage(aesKey1, FCastDecryptedMessage(Opcode.Play.value.toLong(), serializedBody))
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey1, encryptedMessage)
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
assertEquals(serializedBody, decryptedMessage.message)
}
@Test
fun testDecryptMessageKnown() {
val encryptedMessage = Json.decodeFromString<FCastEncryptedMessage>("{\"version\":1,\"iv\":\"C4H70VC5FWrNtkty9/cLIA==\",\"blob\":\"K6/N7JMyi1PFwKhU0mFj7ZJmd/tPp3NCOMldmQUtDaQ7hSmPoIMI5QNMOj+NFEiP4qTgtYp5QmBPoQum6O88pA==\"}")
val aesKey = SecretKeySpec(Base64.decode("+hr9Jg8yre7S9WGUohv2AUSzHNQN514JPh6MoFAcFNU=", Base64.NO_WRAP), "AES")
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey, encryptedMessage)
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
assertEquals("{\"container\":\"text/html\"}", decryptedMessage.message)
}
}
@@ -11,7 +11,7 @@ import java.nio.ByteBuffer
import kotlin.random.Random import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
/*
class SyncServerTests { class SyncServerTests {
//private val relayHost = "relay.grayjay.app" //private val relayHost = "relay.grayjay.app"
@@ -335,4 +335,4 @@ class SyncServerTests {
class AlwaysAuthorized : IAuthorizable { class AlwaysAuthorized : IAuthorizable {
override val isAuthorized: Boolean get() = true override val isAuthorized: Boolean get() = true
} }*/
@@ -13,7 +13,7 @@ import kotlin.random.Random
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
/*
data class PipeStreams( data class PipeStreams(
val initiatorInput: LittleEndianDataInputStream, val initiatorInput: LittleEndianDataInputStream,
val initiatorOutput: LittleEndianDataOutputStream, val initiatorOutput: LittleEndianDataOutputStream,
@@ -509,4 +509,4 @@ class Authorized : IAuthorizable {
class Unauthorized : IAuthorizable { class Unauthorized : IAuthorizable {
override val isAuthorized: Boolean = false override val isAuthorized: Boolean = false
} }*/
+50 -20
View File
@@ -16,6 +16,9 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/> <uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
@@ -26,6 +29,8 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.FutoVideo" android:theme="@style/Theme.FutoVideo"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
tools:replace="android:enableOnBackInvokedCallback"
android:enableOnBackInvokedCallback="false"
tools:targetApi="31" tools:targetApi="31"
android:largeHeap="true"> android:largeHeap="true">
<provider <provider
@@ -55,9 +60,10 @@
<activity <activity
android:name=".activities.MainActivity" android:name=".activities.MainActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode" android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
android:exported="true" android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar" android:theme="@style/Theme.FutoVideo.NoActionBar"
android:windowSoftInputMode="adjustPan"
android:launchMode="singleInstance" android:launchMode="singleInstance"
android:resizeableActivity="true" android:resizeableActivity="true"
android:supportsPictureInPicture="true"> android:supportsPictureInPicture="true">
@@ -153,30 +159,30 @@
</activity> </activity>
<activity <activity
android:name=".activities.TestActivity" android:name=".activities.TestActivity"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.SettingsActivity" android:name=".activities.SettingsActivity"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.DeveloperActivity" android:name=".activities.DeveloperActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.ExceptionActivity" android:name=".activities.ExceptionActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.CaptchaActivity" android:name=".activities.CaptchaActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.LoginActivity" android:name=".activities.LoginActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.AddSourceActivity" android:name=".activities.AddSourceActivity"
android:exported="true" android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar"> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
@@ -189,54 +195,78 @@
<activity <activity
android:name=".activities.AddSourceOptionsActivity" android:name=".activities.AddSourceOptionsActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.PolycentricHomeActivity" android:name=".activities.PolycentricHomeActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.PolycentricBackupActivity" android:name=".activities.PolycentricBackupActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.PolycentricCreateProfileActivity" android:name=".activities.PolycentricCreateProfileActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.PolycentricProfileActivity" android:name=".activities.PolycentricProfileActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.PolycentricWhyActivity" android:name=".activities.PolycentricWhyActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.PolycentricImportProfileActivity" android:name=".activities.PolycentricImportProfileActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.ManageTabsActivity" android:name=".activities.ManageTabsActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.QRCaptureActivity" android:name=".activities.QRCaptureActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.FCastGuideActivity" android:name=".activities.FCastGuideActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.SyncHomeActivity" android:name=".activities.SyncHomeActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.SyncPairActivity" android:name=".activities.SyncPairActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity <activity
android:name=".activities.SyncShowPairingCodeActivity" android:name=".activities.SyncShowPairingCodeActivity"
android:screenOrientation="sensorPortrait" android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity
android:name=".activities.PolycentricModerationActivity"
android:exported="false"
android:screenOrientation="portrait" />
<activity
android:name=".activities.QRCodeFullscreenActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBar" />
<service
android:name=".UpdateDownloadService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<receiver
android:name=".UpdateActionReceiver"
android:exported="false" />
<activity
android:name=".activities.InstallUpdateActivity"
android:exported="false"
android:theme="@style/Theme.App.TransparentNoUi"
android:excludeFromRecents="true"
android:finishOnTaskLaunch="true" />
</application> </application>
</manifest> </manifest>
+25 -2
View File
@@ -1022,15 +1022,38 @@
return x.value return x.value
}); });
let settingsToUse = __DEV_SETTINGS ?? {};
if (true) {
const settings = this.Plugin?.currentPlugin?.settings;
if (settings) {
for (let setting of settings) {
if (typeof settingsToUse[setting.variable] == "undefined") {
switch (setting?.type?.toLowerCase()) {
case "boolean":
settingsToUse[setting.variable] = setting.default === 'true';
break;
case "dropdown":
let dropDownIndex = parseInt(setting.default);
if (dropDownIndex) {
settingsToUse[setting.variable] = setting.options[dropDownIndex];
}
break;
}
}
}
}
}
if(name == "enable") { if(name == "enable") {
if(parameterVals.length > 0) if(parameterVals.length > 0)
parameterVals[0] = this.Plugin.currentPlugin; parameterVals[0] = this.Plugin.currentPlugin;
else else
parameterVals.push(this.Plugin.currentPlugin); parameterVals.push(this.Plugin.currentPlugin);
if(parameterVals.length > 1) if(parameterVals.length > 1)
parameterVals[1] = __DEV_SETTINGS; parameterVals[1] = settingsToUse;
else else
parameterVals.push(__DEV_SETTINGS); parameterVals.push(settingsToUse);
} }
const func = source[name]; const func = source[name];
+29 -4
View File
@@ -67,6 +67,7 @@ class ScriptException extends Error {
super(arguments[0]); super(arguments[0]);
this.plugin_type = "ScriptException"; this.plugin_type = "ScriptException";
this.message = arguments[0]; this.message = arguments[0];
this.msg = arguments[0];
} }
else { else {
super(msg); super(msg);
@@ -103,6 +104,12 @@ class UnavailableException extends ScriptException {
super("UnavailableException", msg); super("UnavailableException", msg);
} }
} }
class ReloadRequiredException extends ScriptException {
constructor(msg, reloadData) {
super("ReloadRequiredException", msg);
this.reloadData = reloadData;
}
}
class AgeException extends ScriptException { class AgeException extends ScriptException {
constructor(msg) { constructor(msg) {
super("AgeException", msg); super("AgeException", msg);
@@ -245,6 +252,9 @@ class PlatformVideo extends PlatformContent {
this.duration = obj.duration ?? -1; //Long this.duration = obj.duration ?? -1; //Long
this.viewCount = obj.viewCount ?? -1; //Long this.viewCount = obj.viewCount ?? -1; //Long
this.playbackTime = obj.playbackTime ?? -1;
this.playbackDate = obj.playbackDate ?? undefined;
this.isLive = obj.isLive ?? false; //Boolean this.isLive = obj.isLive ?? false; //Boolean
this.isShort = !!obj.isShort ?? false; this.isShort = !!obj.isShort ?? false;
} }
@@ -405,6 +415,8 @@ class VideoUrlSource {
this.url = obj.url; this.url = obj.url;
if(obj.requestModifier) if(obj.requestModifier)
this.requestModifier = obj.requestModifier; this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
} }
} }
class VideoUrlWidevineSource extends VideoUrlSource { class VideoUrlWidevineSource extends VideoUrlSource {
@@ -458,14 +470,20 @@ class AudioUrlWidevineSource extends AudioUrlSource {
this.getLicenseRequestExecutor = () => { this.getLicenseRequestExecutor = () => {
return { return {
executeRequest: (url, _headers, _method, license_request_data) => { executeRequest: (url, _headers, _method, license_request_data) => {
return http.POST( const response = http.POST(
url, url,
license_request_data, license_request_data,
{ Authorization: `Bearer ${obj.bearerToken}` }, { Authorization: `Bearer ${obj.bearerToken}` },
false, false,
true true
).body );
}
if (!response.body) {
throw new ScriptException("Unable to acquire license key");
}
return response.body;
}
} }
} }
} }
@@ -496,6 +514,8 @@ class HLSSource {
this.language = obj.language; this.language = obj.language;
if(obj.requestModifier) if(obj.requestModifier)
this.requestModifier = obj.requestModifier; this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
} }
} }
class DashSource { class DashSource {
@@ -509,6 +529,8 @@ class DashSource {
this.language = obj.language; this.language = obj.language;
if(obj.requestModifier) if(obj.requestModifier)
this.requestModifier = obj.requestModifier; this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
} }
} }
class DashWidevineSource extends DashSource { class DashWidevineSource extends DashSource {
@@ -534,6 +556,7 @@ class DashManifestRawSource {
this.language = obj.language ?? Language.UNKNOWN; this.language = obj.language ?? Language.UNKNOWN;
if(obj.requestModifier) if(obj.requestModifier)
this.requestModifier = obj.requestModifier; this.requestModifier = obj.requestModifier;
this.original = obj?.original;
} }
} }
@@ -701,11 +724,12 @@ class LiveEventViewCount extends LiveEvent {
} }
} }
class LiveEventRaid extends LiveEvent { class LiveEventRaid extends LiveEvent {
constructor(targetUrl, targetName, targetThumbnail) { constructor(targetUrl, targetName, targetThumbnail, isOutgoing) {
super(100); super(100);
this.targetUrl = targetUrl; this.targetUrl = targetUrl;
this.targetName = targetName; this.targetName = targetName;
this.targetThumbnail = targetThumbnail; this.targetThumbnail = targetThumbnail;
this.isOutgoing = isOutgoing ?? true;
} }
} }
@@ -778,6 +802,7 @@ let plugin = {
//To override by plugin //To override by plugin
const source = { const source = {
getHome() { return new ContentPager([], false, {}); }, getHome() { return new ContentPager([], false, {}); },
getShorts() { return new VideoPager([], false, {}); },
enable(config){ }, enable(config){ },
disable() {}, disable() {},
@@ -0,0 +1,43 @@
package com.futo.platformplayer
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
object AppCaUpdater {
private const val CA_URL = "https://curl.se/ca/cacert.pem"
private const val CACHE_FILENAME = "curl-ca-bundle.pem"
private const val MAX_AGE_DAYS = 30
suspend fun ensureCaBundle(context: Context): File = withContext(Dispatchers.IO) {
val file = File(context.noBackupFilesDir, CACHE_FILENAME)
val needsUpdate = !file.exists() || isOlderThanDays(file, MAX_AGE_DAYS)
if (needsUpdate) {
downloadToFile(CA_URL, file)
}
return@withContext file
}
private fun isOlderThanDays(file: File, days: Int): Boolean {
val ageMs = System.currentTimeMillis() - file.lastModified()
return ageMs > days * 24L * 60L * 60L * 1000L
}
private fun downloadToFile(urlStr: String, dest: File) {
val conn = (URL(urlStr).openConnection() as HttpURLConnection).apply {
connectTimeout = 15000
readTimeout = 15000
instanceFollowRedirects = true
}
conn.inputStream.use { input ->
dest.parentFile?.mkdirs()
dest.outputStream().use { output ->
input.copyTo(output)
}
}
conn.disconnect()
}
}
@@ -0,0 +1,319 @@
import kotlin.math.*
class CSSColor(r: Float, g: Float, b: Float, a: Float = 1f) {
init {
require(r in 0f..1f && g in 0f..1f && b in 0f..1f && a in 0f..1f) {
"RGBA channels must be in [0,1]"
}
}
// -- RGB(A) channels stored 01 --
var r: Float = r.coerceIn(0f, 1f)
set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true }
var g: Float = g.coerceIn(0f, 1f)
set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true }
var b: Float = b.coerceIn(0f, 1f)
set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true }
var a: Float = a.coerceIn(0f, 1f)
set(v) { field = v.coerceIn(0f, 1f) }
// -- Int views of RGBA 0255 --
var red: Int
get() = (r * 255).roundToInt()
set(v) { r = (v.coerceIn(0, 255) / 255f) }
var green: Int
get() = (g * 255).roundToInt()
set(v) { g = (v.coerceIn(0, 255) / 255f) }
var blue: Int
get() = (b * 255).roundToInt()
set(v) { b = (v.coerceIn(0, 255) / 255f) }
var alpha: Int
get() = (a * 255).roundToInt()
set(v) { a = (v.coerceIn(0, 255) / 255f) }
// -- HSLA storage & lazy recompute flags --
private var _h: Float = 0f
private var _s: Float = 0f
private var _l: Float = 0f
private var _hslDirty = true
/** Hue [0...360) */
var hue: Float
get() { computeHslIfNeeded(); return _h }
set(v) { setHsl(v, saturation, lightness) }
/** Saturation [0...1] */
var saturation: Float
get() { computeHslIfNeeded(); return _s }
set(v) { setHsl(hue, v, lightness) }
/** Lightness [0...1] */
var lightness: Float
get() { computeHslIfNeeded(); return _l }
set(v) { setHsl(hue, saturation, v) }
private fun computeHslIfNeeded() {
if (!_hslDirty) return
val max = max(max(r, g), b)
val min = min(min(r, g), b)
val d = max - min
_l = (max + min) / 2f
_s = if (d == 0f) 0f else d / (1f - abs(2f * _l - 1f))
_h = when {
d == 0f -> 0f
max == r -> ((g - b) / d % 6f) * 60f
max == g -> (((b - r) / d) + 2f) * 60f
else -> (((r - g) / d) + 4f) * 60f
}.let { if (it < 0f) it + 360f else it }
_hslDirty = false
}
/**
* Set all three HSL channels at once.
* Hue in degrees [0...360), s/l [0...1].
*/
fun setHsl(h: Float, s: Float, l: Float) {
val hh = ((h % 360f) + 360f) % 360f
val cc = (1f - abs(2f * l - 1f)) * s
val x = cc * (1f - abs((hh / 60f) % 2f - 1f))
val m = l - cc / 2f
val (rp, gp, bp) = when {
hh < 60f -> Triple(cc, x, 0f)
hh < 120f -> Triple(x, cc, 0f)
hh < 180f -> Triple(0f, cc, x)
hh < 240f -> Triple(0f, x, cc)
hh < 300f -> Triple(x, 0f, cc)
else -> Triple(cc, 0f, x)
}
r = rp + m; g = gp + m; b = bp + m
_h = hh; _s = s; _l = l; _hslDirty = false
}
/** Return 0xRRGGBBAA int */
fun toRgbaInt(): Int {
val ai = (a * 255).roundToInt() and 0xFF
val ri = (r * 255).roundToInt() and 0xFF
val gi = (g * 255).roundToInt() and 0xFF
val bi = (b * 255).roundToInt() and 0xFF
return (ri shl 24) or (gi shl 16) or (bi shl 8) or ai
}
/** Return 0xAARRGGBB int */
fun toArgbInt(): Int {
val ai = (a * 255).roundToInt() and 0xFF
val ri = (r * 255).roundToInt() and 0xFF
val gi = (g * 255).roundToInt() and 0xFF
val bi = (b * 255).roundToInt() and 0xFF
return (ai shl 24) or (ri shl 16) or (gi shl 8) or bi
}
// — Convenience modifiers (chainable) —
/** Lighten by fraction [0...1] */
fun lighten(fraction: Float): CSSColor = apply {
lightness = (lightness + fraction).coerceIn(0f, 1f)
}
/** Darken by fraction [0...1] */
fun darken(fraction: Float): CSSColor = apply {
lightness = (lightness - fraction).coerceIn(0f, 1f)
}
/** Increase saturation by fraction [0...1] */
fun saturate(fraction: Float): CSSColor = apply {
saturation = (saturation + fraction).coerceIn(0f, 1f)
}
/** Decrease saturation by fraction [0...1] */
fun desaturate(fraction: Float): CSSColor = apply {
saturation = (saturation - fraction).coerceIn(0f, 1f)
}
/** Rotate hue by degrees (can be negative) */
fun rotateHue(degrees: Float): CSSColor = apply {
hue = (hue + degrees) % 360f
}
companion object {
/** Create from Android 0xAARRGGBB */
@JvmStatic fun fromArgb(color: Int): CSSColor {
val a = ((color ushr 24) and 0xFF) / 255f
val r = ((color ushr 16) and 0xFF) / 255f
val g = ((color ushr 8) and 0xFF) / 255f
val b = ( color and 0xFF) / 255f
return CSSColor(r, g, b, a)
}
/** Create from Android 0xRRGGBBAA */
@JvmStatic fun fromRgba(color: Int): CSSColor {
val r = ((color ushr 24) and 0xFF) / 255f
val g = ((color ushr 16) and 0xFF) / 255f
val b = ((color ushr 8) and 0xFF) / 255f
val a = ( color and 0xFF) / 255f
return CSSColor(r, g, b, a)
}
@JvmStatic fun fromAndroidColor(color: Int): CSSColor {
return fromArgb(color)
}
private val NAMED_HEX = mapOf(
"aliceblue" to "F0F8FF", "antiquewhite" to "FAEBD7", "aqua" to "00FFFF",
"aquamarine" to "7FFFD4", "azure" to "F0FFFF", "beige" to "F5F5DC",
"bisque" to "FFE4C4", "black" to "000000", "blanchedalmond" to "FFEBCD",
"blue" to "0000FF", "blueviolet" to "8A2BE2", "brown" to "A52A2A",
"burlywood" to "DEB887", "cadetblue" to "5F9EA0", "chartreuse" to "7FFF00",
"chocolate" to "D2691E", "coral" to "FF7F50", "cornflowerblue" to "6495ED",
"cornsilk" to "FFF8DC", "crimson" to "DC143C", "cyan" to "00FFFF",
"darkblue" to "00008B", "darkcyan" to "008B8B", "darkgoldenrod" to "B8860B",
"darkgray" to "A9A9A9", "darkgreen" to "006400", "darkgrey" to "A9A9A9",
"darkkhaki" to "BDB76B", "darkmagenta" to "8B008B", "darkolivegreen" to "556B2F",
"darkorange" to "FF8C00", "darkorchid" to "9932CC", "darkred" to "8B0000",
"darksalmon" to "E9967A", "darkseagreen" to "8FBC8F", "darkslateblue" to "483D8B",
"darkslategray" to "2F4F4F", "darkslategrey" to "2F4F4F", "darkturquoise" to "00CED1",
"darkviolet" to "9400D3", "deeppink" to "FF1493", "deepskyblue" to "00BFFF",
"dimgray" to "696969", "dimgrey" to "696969", "dodgerblue" to "1E90FF",
"firebrick" to "B22222", "floralwhite" to "FFFAF0", "forestgreen" to "228B22",
"fuchsia" to "FF00FF", "gainsboro" to "DCDCDC", "ghostwhite" to "F8F8FF",
"gold" to "FFD700", "goldenrod" to "DAA520", "gray" to "808080",
"green" to "008000", "greenyellow" to "ADFF2F", "grey" to "808080",
"honeydew" to "F0FFF0", "hotpink" to "FF69B4", "indianred" to "CD5C5C",
"indigo" to "4B0082", "ivory" to "FFFFF0", "khaki" to "F0E68C",
"lavender" to "E6E6FA", "lavenderblush" to "FFF0F5", "lawngreen" to "7CFC00",
"lemonchiffon" to "FFFACD", "lightblue" to "ADD8E6", "lightcoral" to "F08080",
"lightcyan" to "E0FFFF", "lightgoldenrodyellow" to "FAFAD2", "lightgray" to "D3D3D3",
"lightgreen" to "90EE90", "lightgrey" to "D3D3D3", "lightpink" to "FFB6C1",
"lightsalmon" to "FFA07A", "lightseagreen" to "20B2AA", "lightskyblue" to "87CEFA",
"lightslategray" to "778899", "lightslategrey" to "778899", "lightsteelblue" to "B0C4DE",
"lightyellow" to "FFFFE0", "lime" to "00FF00", "limegreen" to "32CD32",
"linen" to "FAF0E6", "magenta" to "FF00FF", "maroon" to "800000",
"mediumaquamarine" to "66CDAA", "mediumblue" to "0000CD", "mediumorchid" to "BA55D3",
"mediumpurple" to "9370DB", "mediumseagreen" to "3CB371", "mediumslateblue" to "7B68EE",
"mediumspringgreen" to "00FA9A", "mediumturquoise" to "48D1CC", "mediumvioletred" to "C71585",
"midnightblue" to "191970", "mintcream" to "F5FFFA", "mistyrose" to "FFE4E1",
"moccasin" to "FFE4B5", "navajowhite" to "FFDEAD", "navy" to "000080",
"oldlace" to "FDF5E6", "olive" to "808000", "olivedrab" to "6B8E23",
"orange" to "FFA500", "orangered" to "FF4500", "orchid" to "DA70D6",
"palegoldenrod" to "EEE8AA", "palegreen" to "98FB98", "paleturquoise" to "AFEEEE",
"palevioletred" to "DB7093", "papayawhip" to "FFEFD5", "peachpuff" to "FFDAB9",
"peru" to "CD853F", "pink" to "FFC0CB", "plum" to "DDA0DD",
"powderblue" to "B0E0E6", "purple" to "800080", "rebeccapurple" to "663399",
"red" to "FF0000", "rosybrown" to "BC8F8F", "royalblue" to "4169E1",
"saddlebrown" to "8B4513", "salmon" to "FA8072", "sandybrown" to "F4A460",
"seagreen" to "2E8B57", "seashell" to "FFF5EE", "sienna" to "A0522D",
"silver" to "C0C0C0", "skyblue" to "87CEEB", "slateblue" to "6A5ACD",
"slategray" to "708090", "slategrey" to "708090", "snow" to "FFFAFA",
"springgreen" to "00FF7F", "steelblue" to "4682B4", "tan" to "D2B48C",
"teal" to "008080", "thistle" to "D8BFD8", "tomato" to "FF6347",
"turquoise" to "40E0D0", "violet" to "EE82EE", "wheat" to "F5DEB3",
"white" to "FFFFFF", "whitesmoke" to "F5F5F5", "yellow" to "FFFF00",
"yellowgreen" to "9ACD32"
)
private val NAMED: Map<String, Int> = NAMED_HEX
.mapValues { (_, hexRgb) ->
// parse hexRgb ("RRGGBB") to Int, then OR in 0xFF000000 for full opacity
val rgb = hexRgb.toInt(16)
(rgb shl 8) or 0xFF
} + ("transparent" to 0x00000000)
private val HEX_REGEX = Regex("^#([0-9a-fA-F]{3,8})$", RegexOption.IGNORE_CASE)
private val RGB_REGEX = Regex("^rgba?\\(([^)]+)\\)\$", RegexOption.IGNORE_CASE)
private val HSL_REGEX = Regex("^hsla?\\(([^)]+)\\)\$", RegexOption.IGNORE_CASE)
@JvmStatic
fun parseColor(s: String): CSSColor {
val str = s.trim()
// named
NAMED[str.lowercase()]?.let { return it.RGBAtoCSSColor() }
// hex
HEX_REGEX.matchEntire(str)?.groupValues?.get(1)?.let { part ->
return parseHexPart(part)
}
// rgb/rgba
RGB_REGEX.matchEntire(str)?.groupValues?.get(1)?.let {
return parseRgbParts(it.split(',').map(String::trim))
}
// hsl/hsla
HSL_REGEX.matchEntire(str)?.groupValues?.get(1)?.let {
return parseHslParts(it.split(',').map(String::trim))
}
error("Cannot parse color: \"$s\"")
}
private fun parseHexPart(p: String): CSSColor {
// expand shorthand like "RGB" or "RGBA" to full 8-chars "RRGGBBAA"
val hex = when (p.length) {
3 -> p.map { "$it$it" }.joinToString("") + "FF"
4 -> p.map { "$it$it" }.joinToString("")
6 -> p + "FF"
8 -> p
else -> error("Invalid hex color: #$p")
}
val parsed = hex.toLong(16).toInt()
val alpha = (parsed and 0xFF) shl 24
val rgbOnly = (parsed ushr 8) and 0x00FFFFFF
val argb = alpha or rgbOnly
return fromArgb(argb)
}
private fun parseRgbParts(parts: List<String>): CSSColor {
require(parts.size == 3 || parts.size == 4) { "rgb/rgba needs 3 or 4 parts" }
// r/g/b: "128" → 128/255, "50%" → 0.5
fun channel(ch: String): Float =
if (ch.endsWith("%")) ch.removeSuffix("%").toFloat() / 100f
else ch.toFloat().coerceIn(0f, 255f) / 255f
// alpha: "0.5" → 0.5, "50%" → 0.5
fun alpha(a: String): Float =
if (a.endsWith("%")) a.removeSuffix("%").toFloat() / 100f
else a.toFloat().coerceIn(0f, 1f)
val r = channel(parts[0])
val g = channel(parts[1])
val b = channel(parts[2])
val a = if (parts.size == 4) alpha(parts[3]) else 1f
return CSSColor(r, g, b, a)
}
private fun parseHslParts(parts: List<String>): CSSColor {
require(parts.size == 3 || parts.size == 4) { "hsl/hsla needs 3 or 4 parts" }
fun hueOf(h: String): Float = when {
h.endsWith("deg") -> h.removeSuffix("deg").toFloat()
h.endsWith("grad") -> h.removeSuffix("grad").toFloat() * 0.9f
h.endsWith("rad") -> h.removeSuffix("rad").toFloat() * (180f / PI.toFloat())
h.endsWith("turn") -> h.removeSuffix("turn").toFloat() * 360f
else -> h.toFloat()
}
// for s and l you only ever see percentages
fun pct(p: String): Float =
p.removeSuffix("%").toFloat().coerceIn(0f, 100f) / 100f
// alpha: "0.5" → 0.5, "50%" → 0.5
fun alpha(a: String): Float =
if (a.endsWith("%")) pct(a)
else a.toFloat().coerceIn(0f, 1f)
val h = hueOf(parts[0])
val s = pct(parts[1])
val l = pct(parts[2])
val a = if (parts.size == 4) alpha(parts[3]) else 1f
return CSSColor(0f, 0f, 0f, a).apply { setHsl(h, s, l) }
}
}
}
fun Int.RGBAtoCSSColor(): CSSColor = CSSColor.fromRgba(this)
fun Int.ARGBtoCSSColor(): CSSColor = CSSColor.fromArgb(this)
fun CSSColor.toAndroidColor(): Int = toArgbInt()
@@ -216,10 +216,9 @@ private fun ByteArray.toInetAddress(): InetAddress {
return InetAddress.getByAddress(this); return InetAddress.getByAddress(this);
} }
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? { fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int, timeoutMs: Int = 10_000): Socket? {
ensureNotMainThread() ensureNotMainThread()
val timeout = 10000
val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance<Inet4Address>() else attemptAddresses; val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance<Inet4Address>() else attemptAddresses;
if(addresses.isEmpty()) if(addresses.isEmpty())
throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})"); throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})");
@@ -232,7 +231,7 @@ fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket?
val socket = Socket() val socket = Socket()
try { try {
return socket.apply { this.connect(InetSocketAddress(addresses[0], port), timeout) } return socket.apply { this.connect(InetSocketAddress(addresses[0], port), timeoutMs) }
} catch (e: Throwable) { } catch (e: Throwable) {
Log.i("getConnectedSocket", "Failed to connect to: ${addresses[0]}", e) Log.i("getConnectedSocket", "Failed to connect to: ${addresses[0]}", e)
socket.close() socket.close()
@@ -263,7 +262,7 @@ fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket?
} }
} }
socket.connect(InetSocketAddress(address, port), timeout); socket.connect(InetSocketAddress(address, port), timeoutMs);
synchronized(syncObject) { synchronized(syncObject) {
if (connectedSocket == null) { if (connectedSocket == null) {
@@ -2,10 +2,32 @@ package com.futo.platformplayer
import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.primitive.* import com.caoccao.javet.values.primitive.*
import com.caoccao.javet.values.reference.IV8ValuePromise
import com.caoccao.javet.values.reference.V8ValueArray import com.caoccao.javet.values.reference.V8ValueArray
import com.caoccao.javet.values.reference.V8ValueError
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.caoccao.javet.values.reference.V8ValuePromise
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.selects.SelectClause0
import kotlinx.coroutines.selects.SelectClause1
import java.util.concurrent.CountDownLatch
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
import kotlin.reflect.jvm.internal.impl.load.kotlin.JvmType
//V8 //V8
@@ -24,6 +46,10 @@ fun <R> V8Value?.orDefault(default: R, handler: (V8Value)->R): R {
return handler(this); return handler(this);
} }
inline fun V8Value.getSourcePlugin(): V8Plugin? {
return V8Plugin.getPluginFromRuntime(this.v8Runtime);
}
inline fun <reified T> V8Value.expectOrThrow(config: IV8PluginConfig, contextName: String): T { inline fun <reified T> V8Value.expectOrThrow(config: IV8PluginConfig, contextName: String): T {
if(this !is T) if(this !is T)
throw ScriptImplementationException(config, "Expected ${contextName} to be of type ${T::class.simpleName}, but found ${this::class.simpleName}"); throw ScriptImplementationException(config, "Expected ${contextName} to be of type ${T::class.simpleName}, but found ${this::class.simpleName}");
@@ -89,7 +115,27 @@ inline fun <reified T> V8ValueArray.expectV8Variants(config: IV8PluginConfig, co
.map { kv-> kv.second.orNull { it.expectV8Variant<T>(config, contextName + "[${kv.first}]", ) } as T }; .map { kv-> kv.second.orNull { it.expectV8Variant<T>(config, contextName + "[${kv.first}]", ) } as T };
} }
inline fun V8Plugin.ensureIsBusy() {
this.let {
if (!it.isThreadAlreadyBusy()) {
val stacktrace = Thread.currentThread().stackTrace;
val message = "V8 USE OUTSIDE BUSY: " + stacktrace.drop(3)?.firstOrNull().toString() +
", " + stacktrace.drop(4)?.firstOrNull().toString() +
", " + stacktrace.drop(5)?.firstOrNull()?.toString() +
", " + stacktrace.drop(6)?.firstOrNull()?.toString();
Logger.w("Extensions_V8", message);
throw IllegalStateException(message);
}
}
}
inline fun V8Value.ensureIsBusy() {
this?.getSourcePlugin()?.let {
it.ensureIsBusy();
}
}
inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextName: String): T { inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextName: String): T {
ensureIsBusy();
return when(T::class) { return when(T::class) {
String::class -> this.expectOrThrow<V8ValueString>(config, contextName).value as T; String::class -> this.expectOrThrow<V8ValueString>(config, contextName).value as T;
Int::class -> { Int::class -> {
@@ -138,12 +184,241 @@ inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextN
else -> throw NotImplementedError("Type ${T::class.simpleName} not implemented conversion"); else -> throw NotImplementedError("Type ${T::class.simpleName} not implemented conversion");
} }
} }
fun V8ArrayToStringList(obj: V8ValueArray): List<String> = obj.keys.map { obj.getString(it) }; fun V8ArrayToStringList(obj: V8ValueArray): List<String> {
obj.ensureIsBusy();
return obj.keys.map { obj.getString(it) };
}
fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap<String, String> { fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap<String, String> {
if(obj == null) if(obj == null)
return hashMapOf(); return hashMapOf();
obj.ensureIsBusy();
val map = hashMapOf<String, String>(); val map = hashMapOf<String, String>();
for(prop in obj.ownPropertyNames.keys.map { obj.ownPropertyNames.get<V8Value>(it).toString() }) for(prop in obj.ownPropertyNames.keys.map { obj.ownPropertyNames.get<V8Value>(it).toString() })
map.put(prop, obj.getString(prop)); map.put(prop, obj.getString(prop));
return map; return map;
} }
fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
val latch = CountDownLatch(1);
var promiseResult: T? = null;
var promiseException: Throwable? = null;
plugin.busy {
this.register(object: IV8ValuePromise.IListener {
override fun onFulfilled(p0: V8Value?) {
plugin.busy {
if(p0 is V8ValueError)
promiseException = ScriptExecutionException(plugin.config, p0.message);
else {
if(p0 is V8ValueObject)
p0.setWeak();
promiseResult = p0 as T;
}
}
latch.countDown();
}
override fun onRejected(p0: V8Value?) {
plugin.busy {
promiseException = p0?.toException(plugin.config);
}
latch.countDown();
}
override fun onCatch(p0: V8Value?) {
plugin.busy {
promiseException = p0?.toException(plugin.config);
}
latch.countDown();
}
});
}
plugin.registerPromise(this) {
promiseException = CancellationException("Cancelled by system");
latch.countDown();
}
//Logger.i("V8", "V8ValueBlocking started (Busy) [" + blockCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString());
val isPending = plugin.busy {
promise.isPending
};
if(!isPending) {
plugin.busy {
try {
Logger.i("V8", "V8Promise resolved synchronously");
if(promise.isFulfilled)
promiseResult = promise.getResult<T>();
else
promiseException = promise.getResult<V8Value>().toException(plugin.config);
}
catch(ex: Throwable) {
promiseException = ex;
}
}
} else {
plugin.unbusy {
latch.await();
}
}
if(promiseException != null)
throw promiseException!!;
return promiseResult!!;
}
fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T> {
val underlyingDef = CompletableDeferred<T>();
val def = if(this.has("estDuration"))
V8Deferred(underlyingDef,
this.getOrDefault(plugin.config, "estDuration", "toV8ValueAsync", -1) ?: -1);
else
V8Deferred<T>(underlyingDef);
if(def.estDuration > 0)
Logger.i("V8", "Promise with duration: [${def.estDuration}]");
val promise = this;
plugin.busy {
this.register(object: IV8ValuePromise.IListener {
override fun onFulfilled(p0: V8Value?) {
plugin.busy {
plugin.resolvePromise(promise);
underlyingDef.complete(p0 as T);
}
}
override fun onRejected(p0: V8Value?) {
try {
plugin.busy {
plugin.resolvePromise(promise);
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onRejected promise not implemented..");
Logger.i("V8", "Promise rejected, setting exception");
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
}
}
catch(ex: Throwable) {
Logger.e("V8", "Rejection handling failed?" , ex);
}
}
override fun onCatch(p0: V8Value?) {
try {
plugin.busy {
plugin.resolvePromise(promise);
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onCatch promise not implemented..");
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
}
}
catch(ex: Throwable) {
Logger.e("V8", "Catching handling failed?" , ex);
}
}
});
}
plugin.registerPromise(promise) {
if(def.isActive)
def.cancel("Cancelled by system");
}
return def;
}
fun V8Value.toException(config: IV8PluginConfig): Throwable {
ensureIsBusy();
val p0 = this;
if(p0 is V8ValueObject) {
return V8Plugin.getExceptionFromPlugin(config, p0, null, null, null, "P:");
/*
val pluginType = p0.getOrDefault(config, "plugin_type", "Promise Exception", "")?.let { if(!it.isNullOrBlank()) it + "" else "" }
val msg = p0.getOrDefault<String?>(config, "msg", "Promise Exception", null)
?: p0.getOrDefault(config, "message", "Promise Exception", "");
return Throwable("Promise Failed: " + pluginType + msg);
*/
}
else if(p0 is V8ValueString)
return Throwable("Promise Failed:" + p0.value);
else
return NotImplementedError("onCatch promise not implemented..");
}
class V8Deferred<T>(val deferred: Deferred<T>, val estDuration: Int = -1): Deferred<T> by deferred {
fun <R> convert(conversion: (result: T)->R): V8Deferred<R>{
val newDef = CompletableDeferred<R>()
this.invokeOnCompletion {
if(it != null)
newDef.completeExceptionally(it);
else
newDef.complete(conversion(this@V8Deferred.getCompleted()));
}
return V8Deferred<R>(newDef, estDuration);
}
companion object {
fun <T, R> merge(scope: CoroutineScope, defs: List<V8Deferred<T>>, conversion: (result: List<T>)->R): V8Deferred<R> {
var amount = -1;
for(def in defs)
amount = Math.max(amount, def.estDuration);
val def = scope.async {
val results = defs.map { it.await() };
return@async conversion(results);
}
return V8Deferred(def, amount);
}
}
}
fun <T: V8Value> V8ValueObject.invokeV8(method: String, vararg obj: Any?): T {
ensureIsBusy();
var result = this.invoke<V8Value>(method, *obj);
if(result is V8ValuePromise) {
return result.toV8ValueBlocking(this.getSourcePlugin()!!);
}
return result as T;
}
fun <T: V8Value> V8ValueObject.invokeV8Async(method: String, vararg obj: Any?): V8Deferred<T> {
ensureIsBusy();
var result = this.invoke<V8Value>(method, *obj);
if(result is V8ValuePromise) {
return result.toV8ValueAsync(this.getSourcePlugin()!!);
}
return V8Deferred(CompletableDeferred(result as T));
}
fun V8ValueObject.invokeV8Void(method: String, vararg obj: Any?): V8Value {
ensureIsBusy();
var result = this.invoke<V8Value>(method, *obj);
if(result is V8ValuePromise) {
return result.toV8ValueBlocking(this.getSourcePlugin()!!);
}
return result;
}
fun V8ValueObject.invokeV8VoidAsync(method: String, vararg obj: Any?): V8Deferred<V8Value> {
ensureIsBusy();
var result = this.invoke<V8Value>(method, *obj);
if(result is V8ValuePromise) {
val result = result.toV8ValueAsync<V8Value>(this.getSourcePlugin()!!);
return result;
}
return V8Deferred(CompletableDeferred(result));
}
suspend fun <T> Deferred<T>.awaitCancelConverted(): T {
try {
return this.await();
}
catch(ex: CancellationException) {
if(ex.cause != null) {
throw ex.cause!!;
}
throw ex;
}
}
fun <T> IPager<T>.toList(): List<T> {
val list = this.getResults().toMutableList();
while(this.hasMorePages()) {
this.nextPage();
list.addAll(this.getResults());
}
return list.toList();
}
@@ -0,0 +1,118 @@
package com.futo.platformplayer
import android.app.Activity
import android.graphics.Color
import android.os.Build
import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.doOnAttach
import androidx.core.view.updatePadding
import kotlin.math.max
class RootInsetsController private constructor(
private val activity: Activity,
private val window: Window,
private val root: ViewGroup
) {
private val controller by lazy { WindowInsetsControllerCompat(window, root) }
private val basePaddingLeft = root.paddingLeft
private val basePaddingTop = root.paddingTop
private val basePaddingRight = root.paddingRight
private val basePaddingBottom = root.paddingBottom
private var currentInsets: WindowInsetsCompat = WindowInsetsCompat.CONSUMED
private var fullscreen = false
init {
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets ->
currentInsets = insets
applyPadding()
insets
}
root.doOnAttach { ViewCompat.requestApplyInsets(root) }
}
private fun effectiveInsets(): Insets {
if (fullscreen) return Insets.NONE
val sys = currentInsets.getInsets(Type.systemBars())
val cut = currentInsets.getInsetsIgnoringVisibility(Type.displayCutout())
val portrait = activity.resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_PORTRAIT
val top = if (portrait) max(sys.top, cut.top) else sys.top
return Insets.of(sys.left, top, sys.right, sys.bottom)
}
private fun applyPadding() {
val e = effectiveInsets()
root.updatePadding(
left = basePaddingLeft + e.left,
top = basePaddingTop + e.top,
right = basePaddingRight + e.right,
bottom = basePaddingBottom + e.bottom
)
}
private fun forceRelayoutAndInsets() {
root.post {
ViewCompat.requestApplyInsets(root)
applyPadding()
root.post {
ViewCompat.requestApplyInsets(root)
applyPadding()
}
}
}
fun enterFullscreen(allowCutoutShortEdges: Boolean = true) {
fullscreen = true
if (allowCutoutShortEdges) {
window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
controller.hide(Type.systemBars())
forceRelayoutAndInsets()
}
fun exitFullscreen() {
fullscreen = false
window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
}
controller.show(Type.systemBars())
forceRelayoutAndInsets()
}
fun onConfigurationChanged() {
forceRelayoutAndInsets()
}
fun setLightSystemBarAppearance(lightStatus: Boolean, lightNav: Boolean) {
controller.isAppearanceLightStatusBars = lightStatus
controller.isAppearanceLightNavigationBars = lightNav
}
companion object {
fun attach(activity: Activity, root: ViewGroup): RootInsetsController {
return RootInsetsController(activity, activity.window, root)
}
}
}
@@ -6,15 +6,16 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.webkit.CookieManager import android.webkit.CookieManager
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.ManageTabsActivity import com.futo.platformplayer.activities.ManageTabsActivity
import com.futo.platformplayer.activities.PolycentricHomeActivity import com.futo.platformplayer.activities.PolycentricHomeActivity
import com.futo.platformplayer.activities.PolycentricProfileActivity import com.futo.platformplayer.activities.PolycentricProfileActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.activities.SyncHomeActivity import com.futo.platformplayer.activities.SyncHomeActivity
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
@@ -25,6 +26,7 @@ import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePayment import com.futo.platformplayer.states.StatePayment
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSync
import com.futo.platformplayer.states.StateUpdate 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
@@ -34,13 +36,13 @@ 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
import com.futo.platformplayer.views.fields.FormFieldButton import com.futo.platformplayer.views.fields.FormFieldButton
import com.futo.platformplayer.views.fields.FormFieldWarning
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
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 import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.File import java.io.File
import java.time.OffsetDateTime import java.time.OffsetDateTime
@@ -62,7 +64,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.sync_grayjay, FieldForm.BUTTON, R.string.sync_grayjay_description, -8) @FormField(R.string.sync_grayjay, FieldForm.BUTTON, R.string.sync_grayjay_description, -8)
@FormFieldButton(R.drawable.ic_update) @FormFieldButton(R.drawable.ic_update)
fun syncGrayjay() { fun syncGrayjay() {
SettingsActivity.getActivity()?.let { StateApp?.instance?.activity?.let {
it.startActivity(Intent(it, SyncHomeActivity::class.java)) it.startActivity(Intent(it, SyncHomeActivity::class.java))
} }
} }
@@ -71,7 +73,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -7) @FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -7)
@FormFieldButton(R.drawable.ic_person) @FormFieldButton(R.drawable.ic_person)
fun managePolycentricIdentity() { fun managePolycentricIdentity() {
SettingsActivity.getActivity()?.let { StateApp?.instance?.activity?.let {
if (StatePolycentric.instance.enabled) { if (StatePolycentric.instance.enabled) {
if (StatePolycentric.instance.processHandle != null) { if (StatePolycentric.instance.processHandle != null) {
it.startActivity(Intent(it, PolycentricProfileActivity::class.java)); it.startActivity(Intent(it, PolycentricProfileActivity::class.java));
@@ -89,7 +91,7 @@ class Settings : FragmentedStorageFileJson() {
fun openFAQ() { fun openFAQ() {
try { try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(Settings.URL_FAQ)) val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(Settings.URL_FAQ))
SettingsActivity.getActivity()?.startActivity(browserIntent); StateApp?.instance?.activity?.startActivity(browserIntent);
} catch (e: Throwable) { } catch (e: Throwable) {
//Ignored //Ignored
} }
@@ -99,7 +101,7 @@ class Settings : FragmentedStorageFileJson() {
fun openIssues() { fun openIssues() {
try { try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/futo-org/grayjay-android/issues")) val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/futo-org/grayjay-android/issues"))
SettingsActivity.getActivity()?.startActivity(browserIntent); StateApp?.instance?.activity?.startActivity(browserIntent);
} catch (e: Throwable) { } catch (e: Throwable) {
//Ignored //Ignored
} }
@@ -130,7 +132,7 @@ class Settings : FragmentedStorageFileJson() {
@FormFieldButton(R.drawable.ic_tabs) @FormFieldButton(R.drawable.ic_tabs)
fun manageTabs() { fun manageTabs() {
try { try {
SettingsActivity.getActivity()?.let { StateApp?.instance?.activity?.let {
it.startActivity(Intent(it, ManageTabsActivity::class.java)); it.startActivity(Intent(it, ManageTabsActivity::class.java));
} }
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -143,7 +145,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, -3) @FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, -3)
@FormFieldButton(R.drawable.ic_move_up) @FormFieldButton(R.drawable.ic_move_up)
fun import() { fun import() {
val act = SettingsActivity.getActivity() ?: return; val act = StateApp.instance.activity ?: return;
val intent = MainActivity.getImportOptionsIntent(act); val intent = MainActivity.getImportOptionsIntent(act);
act.startActivity(intent); act.startActivity(intent);
} }
@@ -152,7 +154,7 @@ class Settings : FragmentedStorageFileJson() {
@FormFieldButton(R.drawable.ic_link) @FormFieldButton(R.drawable.ic_link)
fun manageLinks() { fun manageLinks() {
try { try {
SettingsActivity.getActivity()?.let { UIDialogs.showUrlHandlingPrompt(it) } StateApp.instance.activity?.let { UIDialogs.showUrlHandlingPrompt(it) }
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to show url handling prompt", e) Logger.e(TAG, "Failed to show url handling prompt", e)
} }
@@ -161,7 +163,7 @@ class Settings : FragmentedStorageFileJson() {
/*@FormField(R.string.disable_battery_optimization, FieldForm.BUTTON, R.string.click_to_go_to_battery_optimization_settings_disabling_battery_optimization_will_prevent_the_os_from_killing_media_sessions, -1) /*@FormField(R.string.disable_battery_optimization, FieldForm.BUTTON, R.string.click_to_go_to_battery_optimization_settings_disabling_battery_optimization_will_prevent_the_os_from_killing_media_sessions, -1)
@FormFieldButton(R.drawable.battery_full_24px) @FormFieldButton(R.drawable.battery_full_24px)
fun ignoreBatteryOptimization() { fun ignoreBatteryOptimization() {
SettingsActivity.getActivity()?.let { StateApp.instance.activity?.let {
val intent = Intent() val intent = Intent()
val packageName = it.packageName val packageName = it.packageName
val pm = it.getSystemService(POWER_SERVICE) as PowerManager; val pm = it.getSystemService(POWER_SERVICE) as PowerManager;
@@ -201,6 +203,8 @@ class Settings : FragmentedStorageFileJson() {
8 -> "zh"; 8 -> "zh";
9 -> "ru"; 9 -> "ru";
10 -> "ar"; 10 -> "ar";
11 -> "it";
12 -> "tr";
else -> null else -> null
} }
} }
@@ -240,7 +244,7 @@ class Settings : FragmentedStorageFileJson() {
fun clearHidden() { fun clearHidden() {
StateMeta.instance.removeAllHiddenCreators(); StateMeta.instance.removeAllHiddenCreators();
StateMeta.instance.removeAllHiddenVideos(); StateMeta.instance.removeAllHiddenVideos();
SettingsActivity.getActivity()?.let { StateApp.instance.activity?.let {
UIDialogs.toast(it, "Creators and videos should show up again"); UIDialogs.toast(it, "Creators and videos should show up again");
} }
} }
@@ -309,7 +313,7 @@ class Settings : FragmentedStorageFileJson() {
var showSubscriptionGroups: Boolean = true; var showSubscriptionGroups: Boolean = true;
@FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6) @FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6)
var useSubscriptionExchange: Boolean = false; var useSubscriptionExchange: Boolean = true;
@AdvancedField @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)
@@ -370,9 +374,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 16) @FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 16)
fun clearChannelCache() { fun clearChannelCache() {
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing.."); UIDialogs.toast(StateApp.instance.activity!!, "Started clearing..");
StateCache.instance.clear(); StateCache.instance.clear();
UIDialogs.toast(SettingsActivity.getActivity()!!, "Finished clearing"); UIDialogs.toast(StateApp.instance.activity!!, "Finished clearing");
} }
} }
@@ -384,7 +388,7 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.audio_languages) @DropdownFieldOptionsId(R.array.audio_languages)
var primaryLanguage: Int = 0; var primaryLanguage: Int = 0;
fun getPrimaryLanguage(context: Context): String? { fun getPrimaryLanguage(context: Context? = null): String? {
return when(primaryLanguage) { return when(primaryLanguage) {
0 -> "en"; 0 -> "en";
1 -> "es"; 1 -> "es";
@@ -397,13 +401,18 @@ class Settings : FragmentedStorageFileJson() {
8 -> "id"; 8 -> "id";
9 -> "hi"; 9 -> "hi";
10 -> "ar"; 10 -> "ar";
11 -> "tu"; 11 -> "tr";
12 -> "ru"; 12 -> "ru";
13 -> "pt"; 13 -> "pt";
14 -> "zh"; 14 -> "zh";
15 -> "it";
else -> null else -> null
} }
} }
@FormField(R.string.sticky_subtitles, FieldForm.TOGGLE, R.string.sticky_subtitles_description, -1)
var stickySubtitles: Boolean = true;
@FormField(R.string.prefer_original_audio, FieldForm.TOGGLE, R.string.prefer_original_audio_description, -1) @FormField(R.string.prefer_original_audio, FieldForm.TOGGLE, R.string.prefer_original_audio_description, -1)
var preferOriginalAudio: Boolean = true; var preferOriginalAudio: Boolean = true;
@@ -422,6 +431,9 @@ class Settings : FragmentedStorageFileJson() {
6 -> 1.75f; 6 -> 1.75f;
7 -> 2.0f; 7 -> 2.0f;
8 -> 2.25f; 8 -> 2.25f;
9 -> 2.5f;
10 -> 2.75f;
11 -> 3.0f;
else -> 1.0f; else -> 1.0f;
}; };
@@ -584,6 +596,35 @@ class Settings : FragmentedStorageFileJson() {
playbackSpeeds.sort(); playbackSpeeds.sort();
return playbackSpeeds; return playbackSpeeds;
} }
@FormField(R.string.hold_playback_speed, FieldForm.DROPDOWN, R.string.hold_playback_speed_description, 27)
@DropdownFieldOptionsId(R.array.hold_playback_speeds)
var holdPlaybackSpeed: Int = 4;
fun getHoldPlaybackSpeed(): Double {
return when(holdPlaybackSpeed) {
0 -> 1.0
1 -> 1.25
2 -> 1.5
3 -> 1.75
4 -> 2.0
5 -> 2.25
6 -> 2.5
7 -> 2.75
8 -> 3.0
else -> 2.0
}
}
@AdvancedField
@FormField(R.string.shorts_pregenerate, FieldForm.TOGGLE, R.string.shorts_pregenerate_description, 28)
var shortsPregenerate: Boolean = false;
@AdvancedField
@FormField(R.string.shorts_fit_video, FieldForm.TOGGLE, R.string.shorts_fit_video_description, 29)
@FormFieldWarning(R.string.shorts_fit_video_warning)
var shortsFitVideo: Boolean = false;
} }
@FormField(R.string.comments, "group", R.string.comments_description, 6) @FormField(R.string.comments, "group", R.string.comments_description, 6)
@@ -718,7 +759,7 @@ class Settings : FragmentedStorageFileJson() {
try { try {
if (!Logger.submitLogs()) { if (!Logger.submitLogs()) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.please_enable_logging_to_submit_logs)) } StateApp.instance.activity?.let { UIDialogs.toast(it, it.getString(R.string.please_enable_logging_to_submit_logs)) }
} }
} }
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -735,7 +776,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.reset_announcements, FieldForm.BUTTON, R.string.reset_hidden_announcements, 1) @FormField(R.string.reset_announcements, FieldForm.BUTTON, R.string.reset_hidden_announcements, 1)
fun resetAnnouncements() { fun resetAnnouncements() {
StateAnnouncement.instance.resetAnnouncements(); StateAnnouncement.instance.resetAnnouncements();
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); }; StateApp.instance.activity?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); };
} }
} }
@@ -748,7 +789,6 @@ class Settings : FragmentedStorageFileJson() {
} }
@FormField(R.string.plugins, FieldForm.GROUP, -1, 13) @FormField(R.string.plugins, FieldForm.GROUP, -1, 13)
@Transient
var plugins = Plugins(); var plugins = Plugins();
@Serializable @Serializable
class Plugins { class Plugins {
@@ -757,6 +797,9 @@ class Settings : FragmentedStorageFileJson() {
@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_after_login, FieldForm.TOGGLE, R.string.clear_cookies_after_login_desc, 0)
var clearCookiesAfterLogin: Boolean = false;
@AdvancedField @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;
@@ -766,6 +809,12 @@ class Settings : FragmentedStorageFileJson() {
val cookieManager: CookieManager = CookieManager.getInstance(); val cookieManager: CookieManager = CookieManager.getInstance();
cookieManager.removeAllCookies(null); cookieManager.removeAllCookies(null);
} }
fun shouldClearWebviewCookies(): Boolean {
return clearCookiesAfterLogin;
}
/*@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1) /*@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
fun reinstallEmbedded() { fun reinstallEmbedded() {
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
@@ -803,13 +852,13 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.change_external_general_directory, FieldForm.BUTTON, R.string.change_the_external_directory_for_general_files, 3) @FormField(R.string.change_external_general_directory, FieldForm.BUTTON, R.string.change_the_external_directory_for_general_files, 3)
fun changeStorageGeneral() { fun changeStorageGeneral() {
SettingsActivity.getActivity()?.let { StateApp.instance.activity?.let {
StateApp.instance.changeExternalGeneralDirectory(it); StateApp.instance.changeExternalGeneralDirectory(it);
} }
} }
@FormField(R.string.change_external_downloads_directory, FieldForm.BUTTON, R.string.change_the_external_storage_for_download_files, 4) @FormField(R.string.change_external_downloads_directory, FieldForm.BUTTON, R.string.change_the_external_storage_for_download_files, 4)
fun changeStorageDownload() { fun changeStorageDownload() {
SettingsActivity.getActivity()?.let { StateApp.instance.activity?.let {
StateApp.instance.changeExternalDownloadDirectory(it); StateApp.instance.changeExternalDownloadDirectory(it);
} }
} }
@@ -818,7 +867,7 @@ class Settings : FragmentedStorageFileJson() {
fun clearStorageDownload() { fun clearStorageDownload() {
Settings.instance.storage.storage_download = null; Settings.instance.storage.storage_download = null;
Settings.instance.save(); Settings.instance.save();
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, "Cleared download storage directory") }; StateApp.instance.activity?.let { UIDialogs.toast(it, "Cleared download storage directory") };
} }
} }
@@ -831,9 +880,9 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.auto_update_when_array) @DropdownFieldOptionsId(R.array.auto_update_when_array)
var check: Int = 0; var check: Int = 0;
@FormField(R.string.background_download, FieldForm.DROPDOWN, R.string.configure_if_background_download_should_be_used, 1) @FormField(R.string.background_download, FieldForm.TOGGLE, R.string.configure_if_background_download_should_be_used, 1)
@DropdownFieldOptionsId(R.array.background_download) //@DropdownFieldOptionsId(R.array.background_download)
var backgroundDownload: Int = 0; var shouldBackgroundDownload: Boolean = false;
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2) @FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
@DropdownFieldOptionsId(R.array.when_download) @DropdownFieldOptionsId(R.array.when_download)
@@ -855,13 +904,13 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.manual_check, FieldForm.BUTTON, R.string.manually_check_for_updates, 3) @FormField(R.string.manual_check, FieldForm.BUTTON, R.string.manually_check_for_updates, 3)
fun manualCheck() { fun manualCheck() {
if (!BuildConfig.IS_PLAYSTORE_BUILD) { if (!BuildConfig.IS_PLAYSTORE_BUILD) {
SettingsActivity.getActivity()?.let { StateApp.instance.activity?.let {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
StateUpdate.instance.checkForUpdates(it, true) StateUpdate.instance.checkForUpdates(it, true)
} }
} }
} else { } else {
SettingsActivity.getActivity()?.let { StateApp.instance.activity?.let {
try { try {
it.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${it.packageName}"))) it.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${it.packageName}")))
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
@@ -873,7 +922,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.view_changelog, FieldForm.BUTTON, R.string.review_the_current_and_past_changelogs, 4) @FormField(R.string.view_changelog, FieldForm.BUTTON, R.string.review_the_current_and_past_changelogs, 4)
fun viewChangelog() { fun viewChangelog() {
SettingsActivity.getActivity()?.let { StateApp.instance.activity?.let {
UIDialogs.toast(it.getString(R.string.retrieving_changelog)); UIDialogs.toast(it.getString(R.string.retrieving_changelog));
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
@@ -914,21 +963,34 @@ class Settings : FragmentedStorageFileJson() {
@Serializable(with = OffsetDateTimeSerializer::class) @Serializable(with = OffsetDateTimeSerializer::class)
var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN; var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN;
var didAskAutoBackup: Boolean = false; var didAskAutoBackup: Boolean = false;
var autoBackupEnabled: Boolean = false
var autoBackupPassword: String? = null; var autoBackupPassword: String? = null;
fun shouldAutomaticBackup() = autoBackupPassword != null; fun shouldAutomaticBackup() = autoBackupEnabled
@FormField(R.string.automatic_backup, FieldForm.READONLYTEXT, -1, 0) @FormField(R.string.automatic_backup, FieldForm.READONLYTEXT, -1, 0)
val automaticBackupText get() = if(!shouldAutomaticBackup()) "None" else "Every Day"; val automaticBackupText get() = if(!shouldAutomaticBackup()) "None" else "Every Day";
@FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1) @FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1)
fun configureAutomaticBackup() { fun configureAutomaticBackup() {
UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!, autoBackupPassword != null) { StateApp.instance.activity?.let { activity ->
SettingsActivity.getActivity()?.reloadSettings(); if(!Settings.instance.storage.isStorageMainValid(activity)) {
}; UIDialogs.toast("Missing general directory")
StateApp.instance.changeExternalGeneralDirectory(activity) {
UIDialogs.showAutomaticBackupDialog(activity) {
SettingsFragment.currentView?.reloadSettings()
}
};
}
else {
UIDialogs.showAutomaticBackupDialog(activity) {
SettingsFragment.currentView?.reloadSettings()
}
}
}
} }
@FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2) @FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
fun restoreAutomaticBackup() { fun restoreAutomaticBackup() {
val activity = SettingsActivity.getActivity()!! val activity = StateApp.instance.activity!!
if(!StateBackup.hasAutomaticBackup()) if(!StateBackup.hasAutomaticBackup())
UIDialogs.toast(activity, activity.getString(R.string.you_don_t_have_any_automatic_backups), false); UIDialogs.toast(activity, activity.getString(R.string.you_don_t_have_any_automatic_backups), false);
@@ -939,8 +1001,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.export_data, FieldForm.BUTTON, R.string.creates_a_zip_file_with_your_data_which_can_be_imported_by_opening_it_with_grayjay, 3) @FormField(R.string.export_data, FieldForm.BUTTON, R.string.creates_a_zip_file_with_your_data_which_can_be_imported_by_opening_it_with_grayjay, 3)
fun export() { fun export() {
val activity = SettingsActivity.getActivity() ?: return; val activity = StateApp.instance.activity ?: return;
UISlideOverlays.showOverlay(activity.overlay, "Select export type", null, {}, val fragView = SettingsFragment.currentView ?: return;
UISlideOverlays.showOverlay(fragView.overlay, "Select export type", null, {},
SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", tag = null, call = { SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", tag = null, call = {
StateBackup.shareExternalBackup(); StateBackup.shareExternalBackup();
}), }),
@@ -956,11 +1019,11 @@ class Settings : FragmentedStorageFileJson() {
@Serializable @Serializable
class Payment { class Payment {
@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() = StateApp.instance.activity?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown";
@FormField(R.string.license_status, FieldForm.BUTTON, R.string.view_license_status, 2) @FormField(R.string.license_status, FieldForm.BUTTON, R.string.view_license_status, 2)
fun viewLicenseStatus() { fun viewLicenseStatus() {
SettingsActivity.getActivity()?.let { StateApp.instance.activity?.let {
try { try {
if (StatePayment.instance.hasPaid) { if (StatePayment.instance.hasPaid) {
val paymentKey = StatePayment.instance.getPaymentKey() val paymentKey = StatePayment.instance.getPaymentKey()
@@ -976,12 +1039,12 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 3) @FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 3)
fun clearPayment() { fun clearPayment() {
SettingsActivity.getActivity()?.let { context -> StateApp.instance.activity?.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?", {
StatePayment.instance.clearLicenses(); StatePayment.instance.clearLicenses();
SettingsActivity.getActivity()?.let { StateApp.instance.activity?.let {
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart)); UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
it.reloadSettings(); SettingsFragment.currentView?.reloadSettings();
} }
}) })
} }
@@ -999,11 +1062,15 @@ class Settings : FragmentedStorageFileJson() {
@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;
@FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 4) @FormField(R.string.watch_later_add_start, FieldForm.TOGGLE, R.string.watch_later_add_start_description, 4)
var watchLaterAddStart: Boolean = true;
@FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 5)
var polycentricEnabled: Boolean = true; var polycentricEnabled: Boolean = true;
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 5) @FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
var polycentricLocalCache: Boolean = true; var polycentricLocalCache: Boolean = true;
var showPrivacyModeDialog: Boolean = true;
} }
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19) @FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
@@ -1065,6 +1132,39 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3) @FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3)
var localConnections: Boolean = true; var localConnections: Boolean = true;
var syncServerUrl: String? = null;
@FormField(R.string.relay_server, FieldForm.READONLYTEXT, -1, 6)
val syncServer: String get() = if(syncServerUrl?.isBlank() == true) StateSync.RELAY_SERVER else syncServerUrl ?: StateSync.RELAY_SERVER;
@AdvancedField
@FormField(R.string.configure_sync_server, FieldForm.BUTTON, R.string.configure_sync_server_description, 7)
fun configureSyncServer() {
StateApp.instance.activity?.let { context ->
UIDialogs.showDialog(context, R.drawable.device_sync, false,
"Enter the url to your relay server",
"Using your own relay server requires a proper setup with portforwarding.\nUse at your own risk.",
null,
syncServerUrl ?: "",
"YourRelayServerDomain.com", 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Reset", {
syncServerUrl = null;
instance.save();
SettingsFragment.currentView?.reloadSettings();
UIDialogs.toast("Sync server changes require a restart");
}, UIDialogs.ActionStyle.ACCENT),
UIDialogs.Action.withInput("Configure", {
syncServerUrl = it?.text
instance.save();
SettingsFragment.currentView?.reloadSettings();
UIDialogs.toast("Sync server changes require a restart");
}, UIDialogs.ActionStyle.PRIMARY),
)
}
}
} }
@FormField(R.string.info, FieldForm.GROUP, -1, 21) @FormField(R.string.info, FieldForm.GROUP, -1, 21)
@@ -8,9 +8,7 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import com.caoccao.javet.values.primitive.V8ValueInteger import com.caoccao.javet.values.primitive.V8ValueInteger
import com.caoccao.javet.values.primitive.V8ValueString import com.caoccao.javet.values.primitive.V8ValueString
import com.futo.platformplayer.activities.DeveloperActivity
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
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.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@@ -20,6 +18,8 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.background.BackgroundWorker import com.futo.platformplayer.background.BackgroundWorker
import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.fragment.mainactivity.main.DeveloperFragment
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
import com.futo.platformplayer.states.StateAnnouncement import com.futo.platformplayer.states.StateAnnouncement
@@ -97,10 +97,10 @@ class SettingsDev : FragmentedStorageFileJson() {
fun subscriptionsCache5000() { fun subscriptionsCache5000() {
Logger.i("SettingsDev", "Started caching 5000 sub items"); Logger.i("SettingsDev", "Started caching 5000 sub items");
UIDialogs.toast( UIDialogs.toast(
SettingsActivity.getActivity()!!, StateApp.instance.activity!!,
"Started caching 5000 sub items" "Started caching 5000 sub items"
); );
val button = DeveloperActivity.getActivity()?.getField("subscription_cache_button"); val button = DeveloperFragment.currentView?.getField("subscription_cache_button");
if(button is ButtonField) if(button is ButtonField)
button.setButtonEnabled(false); button.setButtonEnabled(false);
StateApp.instance.scope.launch(Dispatchers.IO) { StateApp.instance.scope.launch(Dispatchers.IO) {
@@ -121,7 +121,7 @@ class SettingsDev : FragmentedStorageFileJson() {
val diff = System.currentTimeMillis() - lastToast; val diff = System.currentTimeMillis() - lastToast;
lastToast = System.currentTimeMillis(); lastToast = System.currentTimeMillis();
UIDialogs.toast( UIDialogs.toast(
SettingsActivity.getActivity()!!, StateApp.instance.activity!!,
"Page: ${page}, Total: ${total}, Speed: ${diff}ms" "Page: ${page}, Total: ${total}, Speed: ${diff}ms"
); );
} }
@@ -130,7 +130,7 @@ class SettingsDev : FragmentedStorageFileJson() {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast( UIDialogs.toast(
SettingsActivity.getActivity()!!, StateApp.instance.activity!!,
"FINISHED Page: ${page}, Total: ${total}" "FINISHED Page: ${page}, Total: ${total}"
); );
} }
@@ -152,10 +152,10 @@ class SettingsDev : FragmentedStorageFileJson() {
fun historyCache100() { fun historyCache100() {
Logger.i("SettingsDev", "Started caching 100 history items (from home)"); Logger.i("SettingsDev", "Started caching 100 history items (from home)");
UIDialogs.toast( UIDialogs.toast(
SettingsActivity.getActivity()!!, StateApp.instance.activity!!,
"Started caching 100 history items (from home)" "Started caching 100 history items (from home)"
); );
val button = DeveloperActivity.getActivity()?.getField("history_cache_button"); val button = DeveloperFragment.currentView?.getField("history_cache_button");
if(button is ButtonField) if(button is ButtonField)
button.setButtonEnabled(false); button.setButtonEnabled(false);
StateApp.instance.scope.launch(Dispatchers.IO) { StateApp.instance.scope.launch(Dispatchers.IO) {
@@ -186,7 +186,7 @@ class SettingsDev : FragmentedStorageFileJson() {
val diff = System.currentTimeMillis() - lastToast; val diff = System.currentTimeMillis() - lastToast;
lastToast = System.currentTimeMillis(); lastToast = System.currentTimeMillis();
UIDialogs.toast( UIDialogs.toast(
SettingsActivity.getActivity()!!, StateApp.instance.activity!!,
"Page: ${page}, Total: ${total}, Speed: ${diff}ms" "Page: ${page}, Total: ${total}, Speed: ${diff}ms"
); );
} }
@@ -195,7 +195,7 @@ class SettingsDev : FragmentedStorageFileJson() {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast( UIDialogs.toast(
SettingsActivity.getActivity()!!, StateApp.instance.activity!!,
"FINISHED Page: ${page}, Total: ${total}" "FINISHED Page: ${page}, Total: ${total}"
); );
} }
@@ -235,9 +235,9 @@ class SettingsDev : FragmentedStorageFileJson() {
@FormField(R.string.test_background_worker, FieldForm.BUTTON, @FormField(R.string.test_background_worker, FieldForm.BUTTON,
R.string.test_background_worker_description, 4) R.string.test_background_worker_description, 4)
fun triggerBackgroundUpdate() { fun triggerBackgroundUpdate() {
val act = SettingsActivity.getActivity()!!; val act = StateApp.instance.activity!!;
try { try {
UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker"); UIDialogs.toast(StateApp.instance.activity!!, "Starting test background worker");
val wm = WorkManager.getInstance(act); val wm = WorkManager.getInstance(act);
val req = OneTimeWorkRequestBuilder<BackgroundWorker>() val req = OneTimeWorkRequestBuilder<BackgroundWorker>()
@@ -251,9 +251,9 @@ class SettingsDev : FragmentedStorageFileJson() {
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, @FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
R.string.test_background_worker_description, 4) R.string.test_background_worker_description, 4)
fun clearChannelContentCache() { fun clearChannelContentCache() {
UIDialogs.toast(SettingsActivity.getActivity()!!, "Clearing cache"); UIDialogs.toast(StateApp.instance.activity!!, "Clearing cache");
StateCache.instance.clearToday(); StateCache.instance.clearToday();
UIDialogs.toast(SettingsActivity.getActivity()!!, "Cleared"); UIDialogs.toast(StateApp.instance.activity!!, "Cleared");
} }
@@ -19,6 +19,7 @@ import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@@ -113,8 +114,8 @@ class UIDialogs {
currentDialog.code, currentDialog.code,
currentDialog.defaultCloseAction, currentDialog.defaultCloseAction,
*currentDialog.actions.map { *currentDialog.actions.map {
return@map Action(it.text, { return@map Action.withInput(it.text, { str ->
it.action(); it.invokeAction(str);
multiShowDialog(context, dialogDescriptor.drop(1), finally); multiShowDialog(context, dialogDescriptor.drop(1), finally);
}, it.style); }, it.style);
}.toTypedArray()); }.toTypedArray());
@@ -165,27 +166,42 @@ class UIDialogs {
dialog.show() dialog.show()
} }
fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) { fun showAutomaticBackupDialog(context: Context, onClosed: (() -> Unit)? = null) {
val dialogAction: ()->Unit = { val dialogAction: () -> Unit = {
val dialog = AutomaticBackupDialog(context); val dialog = AutomaticBackupDialog(context)
registerDialogOpened(dialog); registerDialogOpened(dialog)
dialog.setOnDismissListener { registerDialogClosed(dialog); onClosed?.invoke() }; dialog.setOnDismissListener {
dialog.show(); registerDialogClosed(dialog)
}; onClosed?.invoke()
if(StateBackup.hasAutomaticBackup() && !skipRestoreCheck) }
UIDialogs.showDialog(context, R.drawable.ic_move_up, context.getString(R.string.an_old_backup_is_available), context.getString(R.string.would_you_like_to_restore_this_backup), null, 0, dialog.show()
UIDialogs.Action(context.getString(R.string.cancel), {}), //To nothing }
UIDialogs.Action(context.getString(R.string.override), {
dialogAction(); if (!Settings.instance.backup.autoBackupEnabled && StateBackup.hasAutomaticBackup()) {
UIDialogs.showDialog(
context,
R.drawable.ic_move_up,
context.getString(R.string.an_old_backup_is_available),
context.getString(R.string.would_you_like_to_restore_this_backup),
null,
0,
UIDialogs.Action(context.getString(R.string.cancel), {}),
UIDialogs.Action(context.getString(R.string.continue_anyway), {
dialogAction()
}, UIDialogs.ActionStyle.DANGEROUS), }, UIDialogs.ActionStyle.DANGEROUS),
UIDialogs.Action(context.getString(R.string.restore), { UIDialogs.Action(context.getString(R.string.restore), {
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope); val scope = (context as? androidx.lifecycle.LifecycleOwner)?.lifecycleScope
?: StateApp.instance.scopeOrNull
?: StateApp.instance.scope
UIDialogs.showAutomaticRestoreDialog(context, scope)
}, UIDialogs.ActionStyle.PRIMARY) }, UIDialogs.ActionStyle.PRIMARY)
); )
else { } else {
dialogAction(); dialogAction()
} }
} }
fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) { fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) {
val dialog = AutomaticRestoreDialog(context, scope); val dialog = AutomaticRestoreDialog(context, scope);
registerDialogOpened(dialog); registerDialogOpened(dialog);
@@ -203,7 +219,9 @@ class UIDialogs {
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog { 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); 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 { fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog
= showDialog(context, icon, animated, text, textDetails, code, null, null, defaultCloseAction, *actions);
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, input: String?, placeholder: String?, 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);
@@ -226,6 +244,16 @@ class UIDialogs {
this.text = textDetails; this.text = textDetails;
} }
}; };
var inputView = view.findViewById<TextView>(R.id.dialog_text_input);
inputView.apply {
if (input == null && placeholder == null) this.visibility = View.GONE;
else {
this.text = input ?: "";
this.hint = placeholder ?: "";
this.visibility = View.VISIBLE;
this.textAlignment = View.TEXT_ALIGNMENT_VIEW_START
}
};
view.findViewById<TextView>(R.id.dialog_text_code).apply { view.findViewById<TextView>(R.id.dialog_text_code).apply {
if (code == null) this.visibility = View.GONE; if (code == null) this.visibility = View.GONE;
else { else {
@@ -250,7 +278,7 @@ class UIDialogs {
buttonView.textSize = 14f; buttonView.textSize = 14f;
buttonView.typeface = resources.getFont(R.font.inter_regular); buttonView.typeface = resources.getFont(R.font.inter_regular);
buttonView.text = act.text; buttonView.text = act.text;
buttonView.setOnClickListener { act.action(); dialog.dismiss(); }; buttonView.setOnClickListener { act.invokeAction(DialogResult(inputView?.text?.toString())); dialog.dismiss(); };
when(act.style) { when(act.style) {
ActionStyle.PRIMARY -> buttonView.setBackgroundResource(R.drawable.background_button_primary); ActionStyle.PRIMARY -> buttonView.setBackgroundResource(R.drawable.background_button_primary);
ActionStyle.ACCENT -> buttonView.setBackgroundResource(R.drawable.background_button_accent); ActionStyle.ACCENT -> buttonView.setBackgroundResource(R.drawable.background_button_accent);
@@ -275,7 +303,7 @@ class UIDialogs {
}; };
dialog.setOnCancelListener { dialog.setOnCancelListener {
if(defaultCloseAction >= 0 && defaultCloseAction < actions.size) if(defaultCloseAction >= 0 && defaultCloseAction < actions.size)
actions[defaultCloseAction].action(); actions[defaultCloseAction].invokeAction(DialogResult(inputView?.text?.toString()));
} }
dialog.setOnDismissListener { dialog.setOnDismissListener {
registerDialogClosed(dialog); registerDialogClosed(dialog);
@@ -358,17 +386,19 @@ class UIDialogs {
} }
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null) { fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null): AlertDialog {
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY) val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT) val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction) return showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction).apply {
setOnDismissListener { dismissAction?.invoke() }
}
} }
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null) { fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null): AlertDialog {
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY) val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT) val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE) val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE)
showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction) return showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
} }
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) { fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
@@ -391,13 +421,6 @@ class UIDialogs {
dialog.setMaxVersion(lastVersion); dialog.setMaxVersion(lastVersion);
} }
fun showInstallDownloadedUpdateDialog(context: Context, apkFile: File) {
val dialog = AutoUpdateDialog(context);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.showPredownloaded(apkFile);
}
fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) { fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) {
if(!store.hasMissingReconstructions()) if(!store.hasMissingReconstructions())
onConcluded(); onConcluded();
@@ -424,7 +447,7 @@ class UIDialogs {
} }
fun showCastingDialog(context: Context) { fun showCastingDialog(context: Context, ownerActivity: Activity? = null) {
val d = StateCasting.instance.activeDevice; val d = StateCasting.instance.activeDevice;
if (d != null) { if (d != null) {
val dialog = ConnectedCastingDialog(context); val dialog = ConnectedCastingDialog(context);
@@ -432,6 +455,7 @@ class UIDialogs {
dialog.setOwnerActivity(context) dialog.setOwnerActivity(context)
} }
registerDialogOpened(dialog); registerDialogOpened(dialog);
ownerActivity?.let { dialog.setOwnerActivity(it) }
dialog.setOnDismissListener { registerDialogClosed(dialog) }; dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show(); dialog.show();
} else { } else {
@@ -444,21 +468,24 @@ class UIDialogs {
if (c is Activity) { if (c is Activity) {
dialog.setOwnerActivity(c); dialog.setOwnerActivity(c);
} }
ownerActivity?.let { dialog.setOwnerActivity(it) }
dialog.setOnDismissListener { registerDialogClosed(dialog) }; dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show(); dialog.show();
} }
} }
fun showCastingTutorialDialog(context: Context) { fun showCastingTutorialDialog(context: Context, ownerActivity: Activity? = null) {
val dialog = CastingHelpDialog(context); val dialog = CastingHelpDialog(context);
registerDialogOpened(dialog); registerDialogOpened(dialog);
ownerActivity?.let { dialog.setOwnerActivity(it) }
dialog.setOnDismissListener { registerDialogClosed(dialog) }; dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show(); dialog.show();
} }
fun showCastingAddDialog(context: Context) { fun showCastingAddDialog(context: Context, ownerActivity: Activity? = null) {
val dialog = CastingAddDialog(context); val dialog = CastingAddDialog(context);
registerDialogOpened(dialog); registerDialogOpened(dialog);
ownerActivity?.let { dialog.setOwnerActivity(it) }
dialog.setOnDismissListener { registerDialogClosed(dialog) }; dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show(); dialog.show();
} }
@@ -531,17 +558,36 @@ class UIDialogs {
} }
class Action { class Action {
val text: String; val text: String;
val action: ()->Unit; val action: ((DialogResult?)->Unit);
val style: ActionStyle; val style: ActionStyle;
var center: Boolean; var center: Boolean;
constructor(text: String, action: ()->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) { constructor(text: String, action: ()->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) {
this.text = text;
this.action = { action() };
this.style = style;
this.center = center;
}
protected constructor(text: String, action: (DialogResult?)->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) {
this.text = text; this.text = text;
this.action = action; this.action = action;
this.style = style; this.style = style;
this.center = center; this.center = center;
} }
fun invokeAction(input: DialogResult? = null) {
this.action(input);
}
companion object {
fun withInput(text: String, action: (DialogResult?)->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false): Action {
return Action(text, action, style, center);
}
}
} }
class DialogResult(
val text: String?
);
enum class ActionStyle { enum class ActionStyle {
NONE, NONE,
PRIMARY, PRIMARY,
@@ -5,6 +5,7 @@ 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.net.Uri
import android.util.Log
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.OptIn import androidx.annotation.OptIn
@@ -14,7 +15,6 @@ import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist 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.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
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
@@ -74,6 +74,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import androidx.core.net.toUri import androidx.core.net.toUri
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList
import kotlin.collections.toList
class UISlideOverlays { class UISlideOverlays {
companion object { companion object {
@@ -129,115 +132,163 @@ class UISlideOverlays {
val originalVideo = subscription.doFetchVideos; val originalVideo = subscription.doFetchVideos;
val originalPosts = subscription.doFetchPosts; val originalPosts = subscription.doFetchPosts;
val menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, listOf()); val menu = SlideUpMenuOverlay(
container.context,
container,
"Subscription Settings",
null,
true,
listOf()
);
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){ StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url); try {
val capabilities = plugin.getChannelCapabilities(); val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
val capabilities = plugin.getChannelCapabilities();
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
items.addAll(listOf( items.addAll(
SlideUpMenuItem( listOf(
container.context, SlideUpMenuItem(
R.drawable.ic_notifications, container.context,
"Notifications", R.drawable.ic_notifications,
"", "Notifications",
tag = "notifications", "",
call = { tag = "notifications",
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications; call = {
}, subscription.doNotifications =
invokeParent = false menu?.selectOption(null, "notifications", true, true)
), ?: subscription.doNotifications;
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty()) },
SlideUpMenuGroup(container.context, "Subscription Groups", invokeParent = false
"You can select which groups this subscription is part of.", ),
-1, listOf()) else null, if (StateSubscriptionGroups.instance.getSubscriptionGroups()
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty()) .isNotEmpty()
SlideUpMenuRecycler(container.context, "as") { )
val groups = ArrayList<SubscriptionGroup>(StateSubscriptionGroups.instance.getSubscriptionGroups() SlideUpMenuGroup(
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) } container.context, "Subscription Groups",
.sortedBy { !it.selected }); "You can select which groups this subscription is part of.",
var adapter: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>? = null; -1, listOf()
adapter = it.asAny(groups, RecyclerView.HORIZONTAL) { ) else null,
it.onClick.subscribe { if (StateSubscriptionGroups.instance.getSubscriptionGroups()
if(it is SubscriptionGroup.Selectable) { .isNotEmpty()
val actualGroup = StateSubscriptionGroups.instance.getSubscriptionGroup(it.id) )
?: return@subscribe; SlideUpMenuRecycler(container.context, "as") {
groups.clear(); val groups =
if(it.selected) ArrayList<SubscriptionGroup>(
actualGroup.urls.remove(subscription.channel.url); StateSubscriptionGroups.instance.getSubscriptionGroups()
else .map {
actualGroup.urls.add(subscription.channel.url); SubscriptionGroup.Selectable(
it,
it.urls.contains(subscription.channel.url)
)
}
.sortedBy { !it.selected });
var adapter: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>? =
null;
adapter = it.asAny(groups, RecyclerView.HORIZONTAL) {
it.onClick.subscribe {
if (it is SubscriptionGroup.Selectable) {
val actualGroup =
StateSubscriptionGroups.instance.getSubscriptionGroup(
it.id
)
?: return@subscribe;
groups.clear();
if (it.selected)
actualGroup.urls.remove(subscription.channel.url);
else
actualGroup.urls.add(subscription.channel.url);
StateSubscriptionGroups.instance.updateSubscriptionGroup(actualGroup); StateSubscriptionGroups.instance.updateSubscriptionGroup(
groups.addAll(StateSubscriptionGroups.instance.getSubscriptionGroups() actualGroup
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) } );
.sortedBy { !it.selected }); groups.addAll(
adapter?.notifyContentChanged(); StateSubscriptionGroups.instance.getSubscriptionGroups()
} .map {
} SubscriptionGroup.Selectable(
}; it,
return@SlideUpMenuRecycler adapter; it.urls.contains(subscription.channel.url)
} else null, )
SlideUpMenuGroup(container.context, "Fetch Settings", }
"Depending on the platform you might not need to enable a type for it to be available.", .sortedBy { !it.selected });
-1, listOf()), adapter?.notifyContentChanged();
if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem( }
container.context, }
R.drawable.ic_live_tv, };
"Livestreams", return@SlideUpMenuRecycler adapter;
"Check for livestreams", } else null,
tag = "fetchLive", SlideUpMenuGroup(
call = { container.context, "Fetch Settings",
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive; "Depending on the platform you might not need to enable a type for it to be available.",
}, -1, listOf()
invokeParent = false ),
) else null, if (capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(
if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem( container.context,
container.context, R.drawable.ic_live_tv,
R.drawable.ic_play, "Livestreams",
"Streams", "Check for livestreams",
"Check for streams", tag = "fetchLive",
tag = "fetchStreams", call = {
call = { subscription.doFetchLive =
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams; menu?.selectOption(null, "fetchLive", true, true)
}, ?: subscription.doFetchLive;
invokeParent = false },
) else null, invokeParent = false
if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS)) ) else null,
SlideUpMenuItem( if (capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(
container.context, container.context,
R.drawable.ic_play, R.drawable.ic_play,
"Videos", "Streams",
"Check for videos", "Check for streams",
tag = "fetchVideos", tag = "fetchStreams",
call = { call = {
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos; subscription.doFetchStreams =
}, menu?.selectOption(null, "fetchStreams", true, true)
invokeParent = false ?: subscription.doFetchStreams;
) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty()) },
SlideUpMenuItem( invokeParent = false
container.context, ) else null,
R.drawable.ic_play, if (capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
"Content", SlideUpMenuItem(
"Check for content", container.context,
tag = "fetchVideos", R.drawable.ic_play,
call = { "Videos",
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos; "Check for videos",
}, tag = "fetchVideos",
invokeParent = false call = {
) else null, subscription.doFetchVideos =
if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem( menu?.selectOption(null, "fetchVideos", true, true)
container.context, ?: subscription.doFetchVideos;
R.drawable.ic_chat, },
"Posts", invokeParent = false
"Check for posts", ) else if (capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
tag = "fetchPosts", SlideUpMenuItem(
call = { container.context,
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts; R.drawable.ic_play,
}, "Content",
invokeParent = false "Check for content",
) else null/*,, tag = "fetchVideos",
call = {
subscription.doFetchVideos =
menu?.selectOption(null, "fetchVideos", true, true)
?: subscription.doFetchVideos;
},
invokeParent = false
) else null,
if (capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(
container.context,
R.drawable.ic_chat,
"Posts",
"Check for posts",
tag = "fetchPosts",
call = {
subscription.doFetchPosts =
menu?.selectOption(null, "fetchPosts", true, true)
?: subscription.doFetchPosts;
},
invokeParent = false
) else null/*,,
SlideUpMenuGroup(container.context, "Actions", SlideUpMenuGroup(container.context, "Actions",
"Various things you can do with this subscription", "Various things you can do with this subscription",
@@ -245,61 +296,76 @@ class UISlideOverlays {
SlideUpMenuItem(container.context, R.drawable.ic_list, "Add to Group", "", "btnAddToGroup", { SlideUpMenuItem(container.context, R.drawable.ic_list, "Add to Group", "", "btnAddToGroup", {
showCreateSubscriptionGroup(container, subscription.channel); showCreateSubscriptionGroup(container, subscription.channel);
}, false)*/ }, false)*/
).filterNotNull()); ).filterNotNull()
);
menu.setItems(items); menu.setItems(items);
if(subscription.doNotifications) if (subscription.doNotifications)
menu.selectOption(null, "notifications", true, true); menu.selectOption(null, "notifications", true, true);
if(subscription.doFetchLive) if (subscription.doFetchLive)
menu.selectOption(null, "fetchLive", true, true); menu.selectOption(null, "fetchLive", true, true);
if(subscription.doFetchStreams) if (subscription.doFetchStreams)
menu.selectOption(null, "fetchStreams", true, true); menu.selectOption(null, "fetchStreams", true, true);
if(subscription.doFetchVideos) if (subscription.doFetchVideos)
menu.selectOption(null, "fetchVideos", true, true); menu.selectOption(null, "fetchVideos", true, true);
if(subscription.doFetchPosts) if (subscription.doFetchPosts)
menu.selectOption(null, "fetchPosts", true, true); menu.selectOption(null, "fetchPosts", true, true);
menu.onOK.subscribe { menu.onOK.subscribe {
subscription.save(); subscription.save();
menu.hide(true); menu.hide(true);
if(subscription.doNotifications && !originalNotif) { if (subscription.doNotifications && !originalNotif) {
val mainContext = StateApp.instance.contextOrNull; val mainContext = StateApp.instance.contextOrNull;
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) { if (Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) {
UIDialogs.toast(container.context, "Enable 'Background Update' in settings for notifications to work"); UIDialogs.toast(
container.context,
"Enable 'Background Update' in settings for notifications to work"
);
if(mainContext is MainActivity) { if (mainContext is MainActivity) {
UIDialogs.showDialog(mainContext, R.drawable.ic_settings, "Background Updating Required", UIDialogs.showDialog(
"You need to set a Background Updating interval for notifications", null, 0, mainContext,
UIDialogs.Action("Cancel", {}), R.drawable.ic_settings,
UIDialogs.Action("Configure", { "Background Updating Required",
val intent = Intent(mainContext, SettingsActivity::class.java); "You need to set a Background Updating interval for notifications",
intent.putExtra("query", mainContext.getString(R.string.background_update)); null,
mainContext.startActivity(intent); 0,
}, UIDialogs.ActionStyle.PRIMARY)); UIDialogs.Action("Cancel", {}),
} UIDialogs.Action("Configure", {
return@subscribe; StateApp.instance.activity?.let {
} it.navigate(it.getFragment<SettingsFragment>(), mainContext.getString(R.string.background_update))
else if(!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) { }
UIDialogs.toast(container.context, "Android notifications are disabled"); }, UIDialogs.ActionStyle.PRIMARY)
if(mainContext is MainActivity) { );
mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work"); }
return@subscribe;
} else if (!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) {
UIDialogs.toast(
container.context,
"Android notifications are disabled"
);
if (mainContext is MainActivity) {
mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work");
}
} }
} }
} };
}; menu.onCancel.subscribe {
menu.onCancel.subscribe { subscription.doNotifications = originalNotif;
subscription.doNotifications = originalNotif; subscription.doFetchLive = originalLive;
subscription.doFetchLive = originalLive; subscription.doFetchStreams = originalStream;
subscription.doFetchStreams = originalStream; subscription.doFetchVideos = originalVideo;
subscription.doFetchVideos = originalVideo; subscription.doFetchPosts = originalPosts;
subscription.doFetchPosts = originalPosts; };
};
menu.setOk("Save"); menu.setOk("Save");
menu.show(); menu.show();
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to show subscription overlay.", e)
} }
} }
@@ -510,6 +576,51 @@ class UISlideOverlays {
return null; return null;
} }
val allLanguages = videoSources?.map { it.language }?.distinct() ?: listOf();
val langResCombinations = if(videoSources != null) allLanguages.flatMap {
lang -> videoSources
.filter { v -> v.language == lang }
.map { it.height * it.width }
.distinct()
.map { res -> Pair(res, lang) }
} else listOf();
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
var selectedLanguage: String? = null;
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
SlideUpMenuButtonList(container.context, null, "language_filter", true).apply {
var languageFilterLabels = allLanguages.filterNotNull().toList();
val english = languageFilterLabels.find { it?.lowercase() == "en" };
val originalLanguage = videoSources?.find { it.original == true }?.language;
val primaryLanguage = Settings.instance.playback.getPrimaryLanguage();
val hasPrimaryLanguage = videoSources?.any { it.language == primaryLanguage } ?: false;
if(english != null)
languageFilterLabels = listOf(english).plus(languageFilterLabels.filter { it != english }).toList();
if(primaryLanguage != null && languageFilterLabels.contains(primaryLanguage))
languageFilterLabels = listOf(primaryLanguage).plus(languageFilterLabels.filter { it != primaryLanguage }).toList();
if(originalLanguage != null)
languageFilterLabels = listOf(originalLanguage).plus(languageFilterLabels.filter { it != originalLanguage }).toList();
Log.i(TAG, "Language filtesr: ${languageFilterLabels.joinToString(", ")}");
selectedLanguage = originalLanguage ?: (if(hasPrimaryLanguage) primaryLanguage else null);
setButtons(languageFilterLabels, selectedLanguage);
onClick.subscribe { selected ->
setSelected(selected);
videoSourceItems.forEach {
val item = it.itemTag;
if(item is IVideoSource) {
if(item.language == selected)
it.visibility = View.VISIBLE;
else
it.visibility = View.GONE;
}
}
}
}
else null;
if(languageFilters != null) items.add(languageFilters)
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources, items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
listOf((if (audioSources != null) listOf(SlideUpMenuItem( listOf((if (audioSources != null) listOf(SlideUpMenuItem(
container.context, container.context,
@@ -546,7 +657,13 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download)); menu?.setOk(container.context.getString(R.string.download));
}, },
invokeParent = false invokeParent = false
) ).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
} }
is JSDashManifestRawSource -> { is JSDashManifestRawSource -> {
@@ -566,7 +683,13 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download)); menu?.setOk(container.context.getString(R.string.download));
}, },
invokeParent = false invokeParent = false
) ).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
} }
is IHLSManifestSource -> { is IHLSManifestSource -> {
@@ -580,7 +703,13 @@ class UISlideOverlays {
showHlsPicker(video, it, it.url, container) showHlsPicker(video, it, it.url, container)
}, },
invokeParent = false invokeParent = false
) ).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
} }
else -> { else -> {
@@ -1151,6 +1280,8 @@ class UISlideOverlays {
call = { call = {
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);
else
UIDialogs.toast(container.context.getString(R.string.already_in_watch_later))
}), }),
) )
); );
@@ -0,0 +1,63 @@
package com.futo.platformplayer
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.states.StateApp
import java.io.File
class UpdateActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
UpdateNotificationManager.ACTION_UPDATE_YES -> handleUpdateYes(context, intent)
UpdateNotificationManager.ACTION_UPDATE_NO -> handleUpdateNo(context)
UpdateNotificationManager.ACTION_UPDATE_NEVER -> handleUpdateNever(context)
UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent)
}
}
private fun handleUpdateYes(context: Context, intent: Intent) {
AutoUpdateDialog.currentDialog?.dismiss()
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
if (version == 0) {
return
}
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
val serviceIntent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
}
ContextCompat.startForegroundService(context, serviceIntent)
}
private fun handleUpdateNo(context: Context) {
AutoUpdateDialog.currentDialog?.dismiss()
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_AVAILABLE)
}
private fun handleUpdateNever(context: Context) {
AutoUpdateDialog.currentDialog?.dismiss()
Settings.instance.autoUpdate.check = 1
Settings.instance.save()
UpdateNotificationManager.cancelAll(context)
}
private fun handleDownloadCancel(context: Context, intent: Intent) {
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
val cancelIntent = Intent(context, UpdateDownloadService::class.java).apply {
putExtra(UpdateDownloadService.EXTRA_CANCEL, true)
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
}
ContextCompat.startForegroundService(context, cancelIntent)
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_DOWNLOADING)
}
}
@@ -0,0 +1,64 @@
package com.futo.platformplayer
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class UpdateCheckWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
if (!Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
Logger.i(TAG, "Auto-update disabled, skipping worker run")
return Result.success()
}
return withContext(Dispatchers.IO) {
try {
val client = ManagedHttpClient()
val latestVersion = StateUpdate.Companion.instance.downloadVersionCode(client)
if (latestVersion == null) {
Logger.w(TAG, "Failed to fetch latest version in worker")
return@withContext Result.retry()
}
val currentVersion = BuildConfig.VERSION_CODE
Logger.i(TAG, "Worker check: current=$currentVersion, latest=$latestVersion")
if (latestVersion <= currentVersion) {
return@withContext Result.success()
}
UpdateNotificationManager.showUpdateAvailableNotification(applicationContext, latestVersion)
if (StateApp.instance.isMainActive) {
withContext(Dispatchers.Main) {
StateApp.withContext { ctx ->
try {
UIDialogs.showUpdateAvailableDialog(ctx, latestVersion, false)
} catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update dialog from worker", t)
}
}
}
}
Result.success()
} catch (t: Throwable) {
Logger.w(TAG, "Exception in UpdateCheckWorker", t)
Result.retry()
}
}
}
companion object {
private const val TAG = "UpdateCheckWorker"
const val UNIQUE_WORK_NAME = "updateCheck"
}
}
@@ -0,0 +1,302 @@
package com.futo.platformplayer
import android.app.Dialog
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.os.SystemClock
import com.futo.platformplayer.UIDialogs.ActionStyle
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.SessionAnnouncement
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.*
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.time.OffsetDateTime
class UpdateDownloadService : Service() {
companion object {
private const val TAG = "UpdateDownloadService"
const val EXTRA_VERSION = "version"
const val EXTRA_CANCEL = "cancel"
private const val MAX_RETRIES = 5
private const val INITIAL_BACKOFF_MS = 5_000L
private const val BUFFER_SIZE = 8 * 1024
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
var updateDownloadedDialog: Dialog? = null
}
private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + job)
@Volatile
private var isDownloading: Boolean = false
@Volatile
private var cancelRequested: Boolean = false
private var lastProgressUpdateElapsedMs: Long = 0L
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) {
stopSelf()
return START_NOT_STICKY
}
if (intent.getBooleanExtra(EXTRA_CANCEL, false)) {
cancelRequested = true
Logger.i(TAG, "Download cancel requested")
stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
val version = intent.getIntExtra(EXTRA_VERSION, 0)
if (version == 0) {
stopSelf()
return START_NOT_STICKY
}
if (isDownloading) {
Logger.i(TAG, "Download already in progress, ignoring new start")
return START_STICKY
}
isDownloading = true
cancelRequested = false
val notification = UpdateNotificationManager.buildDownloadProgressNotification(this, version, 0, true)
startForeground(UpdateNotificationManager.NOTIF_ID_DOWNLOADING, notification)
scope.launch {
downloadApk(version)
}
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
job.cancel()
}
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean, onProgress: ((Int) -> Unit)? = null) {
val now = SystemClock.elapsedRealtime()
val force = progress == 100 && !indeterminate
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
lastProgressUpdateElapsedMs = now
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate);
if(onProgress != null)
onProgress.invoke(progress);
}
}
private suspend fun downloadApk(version: Int) {
val apkFile = StateUpdate.getApkFile(this, version)
val partialFile = StateUpdate.getPartialApkFile(this, version)
var announcement: SessionAnnouncement? = null;
try {
if (apkFile.exists() && apkFile.length() > 0L) {
Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}")
onDownloadComplete(version, apkFile)
return
}
try {
announcement = StateAnnouncement.instance.registerLoading("Downloading new version [${version}]", "New version is being downloaded..",
ImageVariable.fromResource(R.drawable.foreground));
}
catch(ex: Exception){
Logger.e(TAG, "Failed to set progress announcement", ex);
}
var backoffMs = INITIAL_BACKOFF_MS
for (attempt in 0 until MAX_RETRIES) {
if (cancelRequested) {
Logger.i(TAG, "Download cancelled before attempt ${attempt + 1}")
break
}
try {
performDownload(StateUpdate.APK_URL, partialFile, version, {
try {
if (announcement != null)
announcement?.setProgress(it);
}
catch(ex: Throwable) {}
})
if (!cancelRequested) {
if (apkFile.exists()) {
apkFile.delete()
}
if (!partialFile.renameTo(apkFile)) {
throw IllegalStateException("Failed to rename partial APK file")
}
onDownloadComplete(version, apkFile)
}
break
} catch (t: Throwable) {
if (cancelRequested) {
Logger.i(TAG, "Download cancelled by user", t)
break
}
if (attempt == MAX_RETRIES - 1) {
Logger.e(TAG, "Download failed after ${attempt + 1} attempts", t)
UpdateNotificationManager.showDownloadFailedNotification(this, version, t)
break
} else {
Logger.w(TAG, "Download attempt ${attempt + 1} failed, retrying in ${backoffMs / 1000}s", t)
delay(backoffMs)
backoffMs *= 2
}
}
}
} finally {
try {
if (announcement != null) {
StateAnnouncement.instance.closeAnnouncement(announcement.id);
}
}
catch(ex: Throwable){}
isDownloading = false
cancelRequested = false
stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
private fun performDownload(url: String, partialFile: File, version: Int, onProgress: ((Int)->Unit)? = null) {
var startOffset = if (partialFile.exists()) partialFile.length() else 0L
Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset")
var connection: HttpURLConnection? = null
try {
connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 30_000
if (startOffset > 0L) {
setRequestProperty("Range", "bytes=$startOffset-")
}
}
connection.connect()
val responseCode = connection.responseCode
if (responseCode == HttpURLConnection.HTTP_OK && startOffset > 0L) {
Logger.w(TAG, "Server ignored Range header, restarting download from scratch")
partialFile.delete()
startOffset = 0L
} else if (responseCode != HttpURLConnection.HTTP_OK &&
responseCode != HttpURLConnection.HTTP_PARTIAL) {
throw IllegalStateException("Unexpected HTTP response code $responseCode")
}
val contentLength = connection.contentLengthLong
val totalBytes = if (contentLength > 0L) startOffset + contentLength else -1L
val buffer = ByteArray(BUFFER_SIZE)
var downloaded = 0L
var lastProgress = -1
connection.inputStream.use { input ->
FileOutputStream(partialFile, startOffset > 0L).use { output ->
while (!cancelRequested) {
val read = input.read(buffer)
if (read == -1) {
break
}
output.write(buffer, 0, read)
downloaded += read
if (totalBytes > 0L) {
val progress = (((startOffset + downloaded) * 100L) / totalBytes).toInt()
if (progress != lastProgress) {
lastProgress = progress
val safeProgress = when {
progress < 0 -> 0
progress > 100 -> 100
else -> progress
}
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false, onProgress)
}
} else {
throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true)
}
}
if (!cancelRequested && totalBytes > 0L) {
val finalProgress = 100
throttledUpdateDownloadProgress(version, finalProgress, indeterminate = false)
}
output.flush()
}
}
if (cancelRequested) {
throw CancellationException("Download cancelled")
}
if (totalBytes > 0L && startOffset + downloaded < totalBytes) {
throw IllegalStateException("Download incomplete: expected=$totalBytes, got=${startOffset + downloaded}")
}
} finally {
connection?.disconnect()
}
}
private fun onDownloadComplete(version: Int, apkFile: File) {
Logger.i(TAG, "Download complete for version=$version, file=${apkFile.absolutePath}")
UpdateNotificationManager.showDownloadCompleteNotification(this, version, apkFile)
if (StateApp.instance.isMainActive) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
StateApp.withContext { ctx ->
try {
updateDownloadedDialog = UIDialogs.showDialog(ctx, R.drawable.foreground,
"Update downloaded",
"Would you like to install it now?", null, 0,
UIDialogs.Action("Not now", {
updateDownloadedDialog = null
}, ActionStyle.NONE, true),
UIDialogs.Action("Install", {
UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, version, apkFile)
}, ActionStyle.PRIMARY, true));
try {
StateAnnouncement.instance.registerAnnouncement("install-update-apk", "Grayjay v${version} is ready!", "You can now install the new Grayjay version.",
AnnouncementType.SESSION,
OffsetDateTime.now(), "update", "Install", {
UpdateNotificationManager.cancelAll(ctx)
UpdateInstaller.startInstall(ctx, version, apkFile)
});
}
catch(ex: Throwable) {
}
} catch (t: Throwable) {
Logger.w(TAG, "Failed to show in-app update downloaded dialog", t)
updateDownloadedDialog = null
}
}
}
}
}
}
@@ -0,0 +1,122 @@
package com.futo.platformplayer
import android.annotation.SuppressLint
import android.app.PendingIntent.FLAG_MUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.PendingIntent.getBroadcast
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.graphics.drawable.Animatable
import android.provider.Settings
import android.view.View
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.receivers.InstallReceiver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.InputStream
import androidx.core.net.toUri
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.states.StateApp
object UpdateInstaller {
private const val TAG = "UpdateInstaller"
@SuppressLint("RequestInstallPackagesPolicy")
fun startInstall(context: Context, version: Int, apkFile: File) {
if (!apkFile.exists()) {
Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}")
UIDialogs.toast(context, "Update file missing")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "APK file does not exist.")
return
}
if (BuildConfig.IS_PLAYSTORE_BUILD) {
UIDialogs.toast(context, "Updates are managed by the Play Store")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Updates are managed by the Play Store.")
return
}
try {
val pm = context.packageManager
if (!pm.canRequestPackageInstalls()) {
UIDialogs.toast(context, "Allow this app to install updates, then try again")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Install update permission was missing.")
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:${context.packageName}".toUri()
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
return
}
} catch (t: Throwable) {
Logger.e(TAG, "Failed to check unknown sources permission", t)
}
GlobalScope.launch(Dispatchers.IO) {
var inputStream: InputStream? = null
var session: PackageInstaller.Session? = null
try {
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
val sessionId = packageInstaller.createSession(params)
session = packageInstaller.openSession(sessionId)
inputStream = apkFile.inputStream()
val dataLength = apkFile.length()
session.openWrite("package", 0, dataLength).use { sessionStream ->
inputStream.copyToOutputStream(dataLength, sessionStream) { _ -> }
session.fsync(sessionStream)
}
val intent = Intent(context, InstallReceiver::class.java).apply {
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkFile.absolutePath)
}
val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val statusReceiver = pendingIntent.intentSender
InstallReceiver.onReceiveResult.subscribe(this) { message ->
InstallReceiver.onReceiveResult.clear();
onReceiveResult(context, version, apkFile, message);
};
Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}")
session.commit(statusReceiver)
} catch (e: Throwable) {
Logger.w(TAG, "Exception while installing update", e)
session?.abandon()
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to install update: ${e.message}")
}
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, e.message)
} finally {
session?.close()
inputStream?.close()
}
}
}
private fun onReceiveResult(context: Context, version: Int, apkFile: File, result: String?) {
try {
InstallReceiver.onReceiveResult.remove(this)
if (result.isNullOrEmpty()) {
Logger.i(TAG, "Update install finished successfully")
UpdateNotificationManager.showInstallSucceededNotification(context, version)
} else {
Logger.w(TAG, "Update install failed: $result")
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result)
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result")
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle install result", e)
}
}
}
@@ -0,0 +1,233 @@
package com.futo.platformplayer
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_MUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.PendingIntent.getBroadcast
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.futo.platformplayer.activities.InstallUpdateActivity
import java.io.File
object UpdateNotificationManager {
private const val CHANNEL_ID = "app_updates"
private const val CHANNEL_NAME = "App updates"
private const val CHANNEL_DESCRIPTION = "Notifications about new app versions"
const val ACTION_UPDATE_YES = "com.futo.platformplayer.UPDATE_YES"
const val ACTION_UPDATE_NO = "com.futo.platformplayer.UPDATE_NO"
const val ACTION_UPDATE_NEVER = "com.futo.platformplayer.UPDATE_NEVER"
const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
private const val REQUEST_CODE_INSTALL = 1001
const val EXTRA_VERSION = "version"
const val EXTRA_APK_PATH = "apk_path"
const val NOTIF_ID_AVAILABLE = 2001
const val NOTIF_ID_DOWNLOADING = 2002
const val NOTIF_ID_READY = 2003
const val NOTIF_ID_INSTALL_FAILED = 2004
const val NOTIF_ID_INSTALL_SUCCEEDED = 2005
fun ensureChannel(context: Context) {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
description = CHANNEL_DESCRIPTION
enableVibration(false)
enableLights(false)
setSound(null, null)
}
manager.createNotificationChannel(channel)
}
}
fun showInstallSucceededNotification(context: Context, version: Int) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val launchIntent = context.packageManager
.getLaunchIntentForPackage(context.packageName)
?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
}
val launchPendingIntent = launchIntent?.let {
PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Update installed")
.setContentText("Version $version installed. Tap to open.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setSilent(true)
if (launchPendingIntent != null) {
builder.setContentIntent(launchPendingIntent)
builder.addAction(0, "Open app", launchPendingIntent)
}
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_SUCCEEDED, builder.build())
}
fun showUpdateAvailableNotification(context: Context, version: Int) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val yesIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_YES
putExtra(EXTRA_VERSION, version)
}
val yesPendingIntent = getBroadcast(context, 0, yesIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val noIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_NO
putExtra(EXTRA_VERSION, version)
}
val noPendingIntent = getBroadcast(context, 1, noIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val neverIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_UPDATE_NEVER
putExtra(EXTRA_VERSION, version)
}
val neverPendingIntent = getBroadcast(context, 2, neverIntent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Update available")
.setContentText("A new version ($version) is available.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setContentIntent(yesPendingIntent)
.setSilent(true)
.addAction(0, "Never", neverPendingIntent)
.addAction(0, "Not now", noPendingIntent)
.addAction(0, "Download", yesPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_AVAILABLE, builder.build())
}
fun buildDownloadProgressNotification(context: Context, version: Int, progress: Int, indeterminate: Boolean): Notification {
ensureChannel(context)
val cancelIntent = Intent(context, UpdateActionReceiver::class.java).apply {
action = ACTION_DOWNLOAD_CANCEL
putExtra(EXTRA_VERSION, version)
}
val cancelPendingIntent = getBroadcast(
context,
3,
cancelIntent,
FLAG_MUTABLE or FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Downloading update")
.setContentText("Downloading version $version")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setOngoing(true)
.setSilent(true)
.addAction(0, "Cancel", cancelPendingIntent)
if (indeterminate) {
builder.setProgress(0, 0, true)
} else {
builder.setProgress(100, progress, false)
}
return builder.build()
}
fun updateDownloadProgress(context: Context, version: Int, progress: Int, indeterminate: Boolean) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
val notification = buildDownloadProgressNotification(context, version, progress, indeterminate)
NotificationManagerCompat.from(context).notify(NOTIF_ID_DOWNLOADING, notification)
}
fun showDownloadCompleteNotification(context: Context, version: Int, apkFile: File) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Update downloaded")
.setContentText("Tap to install version $version.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(installPendingIntent)
.setAutoCancel(true)
.setSilent(true)
.addAction(0, "Install", installPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
}
fun showDownloadFailedNotification(context: Context, version: Int, error: Throwable?) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
ensureChannel(context)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Failed to download update")
.setContentText(error?.message ?: "Unknown error")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setSilent(true)
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
}
fun showInstallFailedNotification(context: Context, version: Int, apkFile: File, error: String?) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)
return
ensureChannel(context)
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.foreground)
.setContentTitle("Failed to install update")
.setContentText(if (error != null && error.isNotBlank()) "$error Tap to try again." else "Tap to try again.")
.setAutoCancel(true)
.setSilent(true)
.setContentIntent(installPendingIntent)
.addAction(0, "Install again", installPendingIntent)
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_FAILED, builder.build())
}
fun cancelAll(context: Context) {
NotificationManagerCompat.from(context).cancel(NOTIF_ID_AVAILABLE)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED)
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED)
}
}
@@ -5,8 +5,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.Resources import android.content.res.Resources
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.icu.util.Output
import android.os.Build import android.os.Build
import android.os.Looper import android.os.Looper
import android.os.OperationCanceledException import android.os.OperationCanceledException
@@ -44,6 +42,9 @@ import java.util.*
import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.ThreadLocalRandom
import java.util.zip.GZIPInputStream import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream import java.util.zip.GZIPOutputStream
import androidx.core.graphics.scale
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz "; private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
fun getRandomString(sizeOfRandomString: Int): String { fun getRandomString(sizeOfRandomString: Int): String {
@@ -101,7 +102,7 @@ fun String.isHexColor(): Boolean {
fun IPlatformClient.fromPool(pool: PlatformMultiClientPool) = pool.getClientPooled(this); fun IPlatformClient.fromPool(pool: PlatformMultiClientPool) = pool.getClientPooled(this);
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec); fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
fun DocumentFile.getInputStream(context: Context) = context.contentResolver.openInputStream(this.uri); fun DocumentFile.getInputStream(context: Context) = context.contentResolver.openInputStream(this.uri);
fun DocumentFile.getOutputStream(context: Context) = context.contentResolver.openOutputStream(this.uri); fun DocumentFile.getOutputStream(context: Context) = context.contentResolver.openOutputStream(this.uri);
@@ -114,23 +115,6 @@ fun DocumentFile.writeBytes(context: Context, byteArray: ByteArray) = context.co
it.flush(); it.flush();
}; };
fun loadBitmap(url: String): Bitmap {
try {
val client = ManagedHttpClient();
val response = client.get(url);
if (response.isOk && response.body != null) {
val bitmapStream = response.body.byteStream();
val bitmap = BitmapFactory.decodeStream(bitmapStream);
return bitmap;
} else {
throw Exception("Failed to find data at URL.");
}
} catch (e: Throwable) {
Logger.w("Utility", "Exception thrown while downloading bitmap.", e);
throw e;
}
}
fun TextView.setPlatformPlayerLinkMovementMethod(context: Context) { fun TextView.setPlatformPlayerLinkMovementMethod(context: Context) {
this.movementMethod = PlatformLinkMovementMethod(context); this.movementMethod = PlatformLinkMovementMethod(context);
} }
@@ -458,4 +442,11 @@ fun addressScore(addr: InetAddress): Int {
} }
} }
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this) fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
fun <T> RequestBuilder<T>.withMaxSizePx(maxSizePx: Int = 1920): RequestBuilder<T> {
return this;
//.downsample(DownsampleStrategy.AT_MOST)
//.override(maxSizePx, maxSizePx)
//.centerInside()
}
@@ -107,10 +107,9 @@ class AddSourceActivity : AppCompatActivity() {
onNewIntent(intent); onNewIntent(intent);
} }
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
var url = intent?.dataString; var url = intent.dataString;
if(url == null) if(url == null)
UIDialogs.showDialog(this, R.drawable.ic_error, getString(R.string.no_valid_url_provided), null, null, UIDialogs.showDialog(this, R.drawable.ic_error, getString(R.string.no_valid_url_provided), null, null,
0, UIDialogs.Action(getString(R.string.ok), { finish() }, UIDialogs.ActionStyle.PRIMARY)); 0, UIDialogs.Action(getString(R.string.ok), { finish() }, UIDialogs.ActionStyle.PRIMARY));
@@ -68,11 +68,15 @@ class CaptchaActivity : AppCompatActivity() {
intent.getStringExtra("body"); intent.getStringExtra("body");
else null; else null;
_webView.settings.userAgentString = captchaConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36"; // Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
if (captchaConfig.userAgent != null)
_webView.settings.userAgentString = captchaConfig.userAgent;
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
val capturedUserAgent = _webView.settings.userAgentString;
_webView.settings.useWideViewPort = true; _webView.settings.useWideViewPort = true;
_webView.settings.loadWithOverviewMode = true; _webView.settings.loadWithOverviewMode = true;
val webViewClient = if(config != null) CaptchaWebViewClient(config) else CaptchaWebViewClient(captchaConfig); val webViewClient = if(config != null) CaptchaWebViewClient(config, capturedUserAgent) else CaptchaWebViewClient(captchaConfig, capturedUserAgent);
webViewClient.onCaptchaFinished.subscribe { captcha -> webViewClient.onCaptchaFinished.subscribe { captcha ->
_callback?.let { _callback?.let {
_callback = null; _callback = null;
@@ -1,58 +0,0 @@
package com.futo.platformplayer.activities
import android.annotation.SuppressLint
import android.os.Bundle
import android.widget.ImageButton
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.*
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.IField
class DeveloperActivity : AppCompatActivity() {
private lateinit var _form: FieldForm;
private lateinit var _buttonBack: ImageButton;
fun getField(id: String): IField? {
return _form.findField(id);
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
DeveloperActivity._lastActivity = this;
setContentView(R.layout.activity_dev);
setNavigationBarColorAndIcons();
_buttonBack = findViewById(R.id.button_back);
_form = findViewById(R.id.settings_form);
_form.fromObject(SettingsDev.instance);
_form.onChanged.subscribe { _, _ ->
_form.setObjectValues();
SettingsDev.instance.save();
};
_buttonBack.setOnClickListener {
finish();
}
}
override fun finish() {
super.finish()
overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up)
}
companion object {
//TODO: Temporary for solving Settings issues
@SuppressLint("StaticFieldLeak")
private var _lastActivity: DeveloperActivity? = null;
fun getActivity(): DeveloperActivity? {
val act = _lastActivity;
if(act != null)
return act;
return null;
}
}
}
@@ -0,0 +1,49 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UpdateInstaller
import com.futo.platformplayer.UpdateNotificationManager
import com.futo.platformplayer.logging.Logger
import java.io.File
class InstallUpdateActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
UpdateNotificationManager.cancelAll(this)
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH)
if (version == 0 || apkPath.isNullOrEmpty()) {
Logger.w("InstallUpdateActivity", "Missing version or apkPath")
finish()
return
}
val apkFile = File(apkPath)
if (!apkFile.exists()) {
Logger.w("InstallUpdateActivity", "APK file does not exist: $apkPath")
UIDialogs.Companion.toast(this, "Update file missing")
finish()
return
}
UpdateInstaller.startInstall(this, version, apkFile)
finish()
}
companion object {
fun createIntent(context: Context, version: Int, apkPath: String): Intent =
Intent(context, InstallUpdateActivity::class.java).apply {
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkPath)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
}
@@ -15,6 +15,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.matchesDomain
import com.futo.platformplayer.others.LoginWebViewClient import com.futo.platformplayer.others.LoginWebViewClient
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
@@ -60,11 +61,15 @@ class LoginActivity : AppCompatActivity() {
else throw IllegalStateException("No valid configuration?"); else throw IllegalStateException("No valid configuration?");
//TODO: Backwards compat removal? //TODO: Backwards compat removal?
_webView.settings.userAgentString = authConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36"; // Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
if (authConfig.userAgent != null)
_webView.settings.userAgentString = authConfig.userAgent;
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
val capturedUserAgent = _webView.settings.userAgentString;
_webView.settings.useWideViewPort = true; _webView.settings.useWideViewPort = true;
_webView.settings.loadWithOverviewMode = true; _webView.settings.loadWithOverviewMode = true;
val webViewClient = if(config != null) LoginWebViewClient(config) else LoginWebViewClient(authConfig); val webViewClient = if(config != null) LoginWebViewClient(config, capturedUserAgent) else LoginWebViewClient(authConfig, capturedUserAgent);
webViewClient.onLogin.subscribe { auth -> webViewClient.onLogin.subscribe { auth ->
_callback?.let { _callback?.let {
@@ -74,9 +79,26 @@ class LoginActivity : AppCompatActivity() {
finish(); finish();
}; };
var isFirstLoad = true; var isFirstLoad = true;
val loginWarnings = authConfig.loginWarnings?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.Warning>();
val uiMods = authConfig.uiMods?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.UIMod>();
var currentScale = 100;
var currentDesktop = false;
webViewClient.onPageLoaded.subscribe { view, url -> webViewClient.onPageLoaded.subscribe { view, url ->
_textUrl.setText(url ?: ""); _textUrl.setText(url ?: "");
if(loginWarnings.size > 0 && url != null) {
synchronized(loginWarnings) {
val warning = loginWarnings.find { url.matches(it.getRegex()) };
if(warning != null) {
if(warning.once == true)
loginWarnings.remove(warning);
UIDialogs.showDialog(this@LoginActivity, R.drawable.ic_warning_yellow, warning.text ?: "", warning.details ?: "", null, 0,
UIDialogs.Action("Understood", {
}, UIDialogs.ActionStyle.PRIMARY));
}
}
}
if(!isFirstLoad) if(!isFirstLoad)
return@subscribe; return@subscribe;
isFirstLoad = false; isFirstLoad = false;
@@ -86,6 +108,35 @@ class LoginActivity : AppCompatActivity() {
//TODO: Find most reliable way to wait for page js to finish //TODO: Find most reliable way to wait for page js to finish
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {}); view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
} }
/*
var specifiedScale = false;
var specifiedDesktop = false;
if(uiMods.size > 0 && url != null) {
synchronized(uiMods) {
val uimod = uiMods.find { url.matches(it.getRegex()) };
if(uimod != null) {
if(uimod.scale != null) {
currentScale =(uimod.scale * 100).toInt();
_webView.setInitialScale(currentScale);
specifiedScale = true;
}
if(uimod.desktop != null && uimod.desktop) {
_webView.settings.useWideViewPort = true;
specifiedDesktop = true;
}
}
}
}
if(!specifiedScale && currentScale != 100) {
currentScale = (100).toInt();
_webView.setInitialScale(currentScale);
}
if(!specifiedDesktop && currentDesktop) {
_webView.settings.useWideViewPort = false;
currentDesktop = false;
}
*/
} }
_webView.settings.domStorageEnabled = true; _webView.settings.domStorageEnabled = true;
@@ -8,6 +8,7 @@ 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.graphics.Color
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -16,7 +17,6 @@ import android.os.StrictMode.VmPolicy
import android.util.Log import android.util.Log
import android.util.TypedValue import android.util.TypedValue
import android.view.View import android.view.View
import android.view.WindowManager
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
@@ -32,14 +32,15 @@ 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.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
import com.futo.platformplayer.RootInsetsController
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
import com.futo.platformplayer.api.media.models.video.LocalVideoDetails
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.dp
@@ -52,17 +53,29 @@ import com.futo.platformplayer.fragment.mainactivity.main.CommentsFragment
import com.futo.platformplayer.fragment.mainactivity.main.ContentSearchResultsFragment import com.futo.platformplayer.fragment.mainactivity.main.ContentSearchResultsFragment
import com.futo.platformplayer.fragment.mainactivity.main.CreatorSearchResultsFragment import com.futo.platformplayer.fragment.mainactivity.main.CreatorSearchResultsFragment
import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment
import com.futo.platformplayer.fragment.mainactivity.main.DeveloperFragment
import com.futo.platformplayer.fragment.mainactivity.main.DownloadsFragment import com.futo.platformplayer.fragment.mainactivity.main.DownloadsFragment
import com.futo.platformplayer.fragment.mainactivity.main.HistoryFragment import com.futo.platformplayer.fragment.mainactivity.main.HistoryFragment
import com.futo.platformplayer.fragment.mainactivity.main.HomeFragment import com.futo.platformplayer.fragment.mainactivity.main.HomeFragment
import com.futo.platformplayer.fragment.mainactivity.main.ImportPlaylistsFragment import com.futo.platformplayer.fragment.mainactivity.main.ImportPlaylistsFragment
import com.futo.platformplayer.fragment.mainactivity.main.ImportSubscriptionsFragment import com.futo.platformplayer.fragment.mainactivity.main.ImportSubscriptionsFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryAlbumFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryAlbumsFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryArtistFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryArtistsFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFilesFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibrarySearchFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryVideosFragment
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistFragment import com.futo.platformplayer.fragment.mainactivity.main.PlaylistFragment
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsFragment import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsFragment
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
import com.futo.platformplayer.fragment.mainactivity.main.PostDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.PostDetailFragment
import com.futo.platformplayer.fragment.mainactivity.main.RemotePlaylistFragment import com.futo.platformplayer.fragment.mainactivity.main.RemotePlaylistFragment
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
import com.futo.platformplayer.fragment.mainactivity.main.ShortsFragment
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment
@@ -75,6 +88,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment.St
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.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.FilesTopBarFragment
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
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
@@ -96,6 +110,7 @@ import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.stores.SubscriptionStorage import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView import com.futo.platformplayer.views.ToastView
import com.futo.platformplayer.views.notification.NotificationOverlayView
import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.ApiMethods
import com.google.gson.JsonParser import com.google.gson.JsonParser
import com.google.zxing.integration.android.IntentIntegrator import com.google.zxing.integration.android.IntentIntegrator
@@ -114,7 +129,6 @@ import java.io.PrintWriter
import java.io.StringWriter 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.UUID import java.util.UUID
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
@@ -147,6 +161,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragTopBarNavigation: NavigationTopBarFragment; lateinit var _fragTopBarNavigation: NavigationTopBarFragment;
lateinit var _fragTopBarImport: ImportTopBarFragment; lateinit var _fragTopBarImport: ImportTopBarFragment;
lateinit var _fragTopBarAdd: AddTopBarFragment; lateinit var _fragTopBarAdd: AddTopBarFragment;
lateinit var _fragTopBarFiles: FilesTopBarFragment;
//Frags BotBar //Frags BotBar
lateinit var _fragBotBarMenu: MenuBottomBarFragment; lateinit var _fragBotBarMenu: MenuBottomBarFragment;
@@ -171,6 +186,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragMainRemotePlaylist: RemotePlaylistFragment; lateinit var _fragMainRemotePlaylist: RemotePlaylistFragment;
lateinit var _fragWatchlist: WatchLaterFragment; lateinit var _fragWatchlist: WatchLaterFragment;
lateinit var _fragHistory: HistoryFragment; lateinit var _fragHistory: HistoryFragment;
lateinit var _fragShorts: ShortsFragment;
lateinit var _fragSourceDetail: SourceDetailFragment; lateinit var _fragSourceDetail: SourceDetailFragment;
lateinit var _fragDownloads: DownloadsFragment; lateinit var _fragDownloads: DownloadsFragment;
lateinit var _fragImportSubscriptions: ImportSubscriptionsFragment; lateinit var _fragImportSubscriptions: ImportSubscriptionsFragment;
@@ -178,6 +194,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragBuy: BuyFragment; lateinit var _fragBuy: BuyFragment;
lateinit var _fragSubGroup: SubscriptionGroupFragment; lateinit var _fragSubGroup: SubscriptionGroupFragment;
lateinit var _fragSubGroupList: SubscriptionGroupListFragment; lateinit var _fragSubGroupList: SubscriptionGroupListFragment;
lateinit var _fragLibrary: LibraryFragment;
lateinit var _fragLibraryAlbums: LibraryAlbumsFragment;
lateinit var _fragLibraryAlbum: LibraryAlbumFragment;
lateinit var _fragLibraryArtists: LibraryArtistsFragment;
lateinit var _fragLibraryArtist: LibraryArtistFragment;
lateinit var _fragLibraryVideos: LibraryVideosFragment;
lateinit var _fragLibrarySearch: LibrarySearchFragment;
lateinit var _fragLibraryFiles: LibraryFilesFragment;
lateinit var _fragNotifications: NotificationOverlayView.Frag;
lateinit var _fragSettings: SettingsFragment;
lateinit var _fragDeveloper: DeveloperFragment;
lateinit var _fragLogin: LoginFragment;
lateinit var _fragBrowser: BrowserFragment; lateinit var _fragBrowser: BrowserFragment;
@@ -186,7 +214,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//State //State
private val _queue: LinkedList<Pair<MainFragment, Any?>> = LinkedList(); private val _queue: LinkedList<Pair<MainFragment, Any?>> = LinkedList();
lateinit var fragCurrent: MainFragment private set; var fragCurrent: MainFragment? = null; private set;
private var _parameterCurrent: Any? = null; private var _parameterCurrent: Any? = null;
var fragBeforeOverlay: MainFragment? = null; private set; var fragBeforeOverlay: MainFragment? = null; private set;
@@ -198,6 +226,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private var _privateModeEnabled = false private var _privateModeEnabled = false
private var _pictureInPictureEnabled = false private var _pictureInPictureEnabled = false
private var _isFullscreen = false private var _isFullscreen = false
private lateinit var _rootInsetsController: RootInsetsController
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)
@@ -273,6 +302,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
@UnstableApi @UnstableApi
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Logger.w(TAG, "MainActivity Starting [$mainId]"); Logger.w(TAG, "MainActivity Starting [$mainId]");
StateApp.instance.setGlobalContext(this, lifecycleScope, mainId); StateApp.instance.setGlobalContext(this, lifecycleScope, mainId);
StateApp.instance.mainAppStarting(this); StateApp.instance.mainAppStarting(this);
@@ -283,9 +313,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
setContentView(R.layout.activity_main); setContentView(R.layout.activity_main);
setNavigationBarColorAndIcons(); setNavigationBarColorAndIcons();
if (Settings.instance.playback.allowVideoToGoUnderCutout)
window.attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
runBlocking { runBlocking {
try { try {
@@ -295,11 +322,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
//Preload common files to memory //Preload common files to memory
FragmentedStorage.get<SubscriptionStorage>(); FragmentedStorage.get<SubscriptionStorage>();
FragmentedStorage.get<Settings>(); FragmentedStorage.get<Settings>();
rootView = findViewById(R.id.rootView); rootView = findViewById(R.id.rootView);
_rootInsetsController = RootInsetsController.attach(this, rootView)
_rootInsetsController.setLightSystemBarAppearance(lightStatus = false, lightNav = false)
_fragContainerTopBar = findViewById(R.id.fragment_top_bar); _fragContainerTopBar = findViewById(R.id.fragment_top_bar);
_fragContainerMain = findViewById(R.id.fragment_main); _fragContainerMain = findViewById(R.id.fragment_main);
_fragContainerBotBar = findViewById(R.id.fragment_bottom_bar); _fragContainerBotBar = findViewById(R.id.fragment_bottom_bar);
@@ -316,6 +350,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragTopBarNavigation = NavigationTopBarFragment.newInstance(); _fragTopBarNavigation = NavigationTopBarFragment.newInstance();
_fragTopBarImport = ImportTopBarFragment.newInstance(); _fragTopBarImport = ImportTopBarFragment.newInstance();
_fragTopBarAdd = AddTopBarFragment.newInstance(); _fragTopBarAdd = AddTopBarFragment.newInstance();
_fragTopBarFiles = FilesTopBarFragment.newInstance();
//BotBars //BotBars
_fragBotBarMenu = MenuBottomBarFragment.newInstance(); _fragBotBarMenu = MenuBottomBarFragment.newInstance();
@@ -340,6 +375,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragWebDetail = WebDetailFragment.newInstance(); _fragWebDetail = WebDetailFragment.newInstance();
_fragWatchlist = WatchLaterFragment.newInstance(); _fragWatchlist = WatchLaterFragment.newInstance();
_fragHistory = HistoryFragment.newInstance(); _fragHistory = HistoryFragment.newInstance();
_fragShorts = ShortsFragment.newInstance();
_fragSourceDetail = SourceDetailFragment.newInstance(); _fragSourceDetail = SourceDetailFragment.newInstance();
_fragDownloads = DownloadsFragment(); _fragDownloads = DownloadsFragment();
_fragImportSubscriptions = ImportSubscriptionsFragment.newInstance(); _fragImportSubscriptions = ImportSubscriptionsFragment.newInstance();
@@ -347,6 +383,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragBuy = BuyFragment.newInstance(); _fragBuy = BuyFragment.newInstance();
_fragSubGroup = SubscriptionGroupFragment.newInstance(); _fragSubGroup = SubscriptionGroupFragment.newInstance();
_fragSubGroupList = SubscriptionGroupListFragment.newInstance(); _fragSubGroupList = SubscriptionGroupListFragment.newInstance();
_fragLibrary = LibraryFragment.newInstance();
_fragLibraryAlbums = LibraryAlbumsFragment.newInstance();
_fragLibraryAlbum = LibraryAlbumFragment.newInstance();
_fragLibraryArtists = LibraryArtistsFragment.newInstance();
_fragLibraryArtist = LibraryArtistFragment.newInstance();
_fragLibraryVideos = LibraryVideosFragment.newInstance();
_fragLibraryFiles = LibraryFilesFragment.newInstance();
_fragLibrarySearch = LibrarySearchFragment.newInstance();
_fragNotifications = NotificationOverlayView.Frag();
_fragSettings = SettingsFragment.newInstance();
_fragDeveloper = DeveloperFragment.newInstance();
_fragLogin = LoginFragment.newInstance();
_fragBrowser = BrowserFragment.newInstance(); _fragBrowser = BrowserFragment.newInstance();
@@ -365,12 +413,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
updateSegmentPaddings(); updateSegmentPaddings();
}; };
_fragVideoDetail.onTransitioning.subscribe { _fragVideoDetail.onTransitioning.subscribe {
if (it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED) if (it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED) {
Logger.i(TAG, "onTransition Setting elevation higher");
_fragContainerOverlay.elevation = _fragContainerOverlay.elevation =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics); TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics);
else }
else {
Logger.i(TAG, "onTransition Setting elevation lower");
_fragContainerOverlay.elevation = _fragContainerOverlay.elevation =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics); TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
}
} }
_fragVideoDetail.onCloseEvent.subscribe { _fragVideoDetail.onCloseEvent.subscribe {
@@ -409,6 +462,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.i(TAG, "onFullscreenChanged ${it}"); Logger.i(TAG, "onFullscreenChanged ${it}");
_isFullscreen = it _isFullscreen = it
updatePrivateModeVisibility() updatePrivateModeVisibility()
if (it) {
_rootInsetsController.enterFullscreen(allowCutoutShortEdges = Settings.instance.playback.allowVideoToGoUnderCutout)
} else {
_rootInsetsController.exitFullscreen()
}
} }
_fragVideoDetail.onMinimize.subscribe { _fragVideoDetail.onMinimize.subscribe {
@@ -473,6 +531,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragImportSubscriptions.topBar = _fragTopBarImport; _fragImportSubscriptions.topBar = _fragTopBarImport;
_fragImportPlaylists.topBar = _fragTopBarImport; _fragImportPlaylists.topBar = _fragTopBarImport;
_fragSubGroupList.topBar = _fragTopBarAdd; _fragSubGroupList.topBar = _fragTopBarAdd;
_fragLibrary.topBar = _fragTopBarGeneral;
_fragLibraryAlbums.topBar = _fragTopBarNavigation;
_fragLibraryAlbum.topBar = _fragTopBarNavigation;
_fragLibraryArtists.topBar = _fragTopBarNavigation;
_fragLibraryArtist.topBar = _fragTopBarNavigation;
_fragLibraryVideos.topBar = _fragTopBarNavigation;
_fragLibraryFiles.topBar = _fragTopBarFiles;
_fragLibrarySearch.topBar = _fragTopBarSearch;
_fragSettings.topBar = _fragTopBarNavigation;
_fragDeveloper.topBar = _fragTopBarNavigation;
_fragNotifications.topBar = _fragTopBarGeneral;
_fragBrowser.topBar = _fragTopBarNavigation; _fragBrowser.topBar = _fragTopBarNavigation;
@@ -498,7 +567,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
defaultTab.action(_fragBotBarMenu); defaultTab.action(_fragBotBarMenu);
StateSubscriptions.instance; StateSubscriptions.instance;
fragCurrent.onShown(null, false); fragCurrent?.onShown(null, false);
//Other stuff //Other stuff
rootView.progress = 0f; rootView.progress = 0f;
@@ -553,6 +622,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply() sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled() && Settings.instance.autoUpdate.shouldBackgroundDownload) {
requestNotificationPermissions("You have enabled background updating.\n\nGrayjay uses notifications to inform you when a new app update is available.");
}
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus") val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount() val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
@@ -610,6 +683,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}, UIDialogs.ActionStyle.PRIMARY) }, UIDialogs.ActionStyle.PRIMARY)
) )
} }
//startActivity(Intent(this, TestActivity::class.java))
} }
/* /*
@@ -635,6 +710,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private var _qrCodeLoadingDialog: AlertDialog? = null private var _qrCodeLoadingDialog: AlertDialog? = null
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
_rootInsetsController.onConfigurationChanged()
}
fun showUrlQrCodeScanner() { fun showUrlQrCodeScanner() {
try { try {
_qrCodeLoadingDialog = UIDialogs.showDialog(this, R.drawable.ic_loader_animated, true, _qrCodeLoadingDialog = UIDialogs.showDialog(this, R.drawable.ic_loader_animated, true,
@@ -693,17 +773,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_wasStopped = true; _wasStopped = true;
} }
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent); super.onNewIntent(intent);
handleIntent(intent); handleIntent(intent);
} }
private fun handleIntent(intent: Intent?) { private fun handleIntent(intent: Intent) {
if (intent == null)
return;
Logger.i(TAG, "handleIntent started by " + intent.action); Logger.i(TAG, "handleIntent started by " + intent.action);
var targetData: String? = null; var targetData: String? = null;
when (intent.action) { when (intent.action) {
@@ -765,7 +841,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (targetData != null) { if (targetData != null) {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
try { try {
handleUrlAll(targetData) handleUrlAll(targetData, intent)
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception in handleUrlAll", e) Logger.e(TAG, "Unhandled exception in handleUrlAll", e)
} }
@@ -776,8 +852,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
} }
suspend fun handleUrlAll(url: String) { suspend fun handleUrlAll(url: String, openIntent: Intent? = null) {
val uri = Uri.parse(url) val uri = Uri.parse(url)
val intent = openIntent ?: this.intent;
when (uri.scheme) { when (uri.scheme) {
"grayjay" -> { "grayjay" -> {
if (url.startsWith("grayjay://license/")) { if (url.startsWith("grayjay://license/")) {
@@ -804,11 +881,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
"content" -> { "content" -> {
if (!handleContent(url, intent.type)) { if (!handleContent(url, intent?.type)) {
UIDialogs.showSingleButtonDialog( UIDialogs.showSingleButtonDialog(
this, this,
R.drawable.ic_play, R.drawable.ic_play,
getString(R.string.unknown_content_format) + " [${url}]\n[${intent.type}]", getString(R.string.unknown_content_format) + " [${url}]\n[${intent?.type}]",
"Ok", "Ok",
{ }); { });
} }
@@ -929,6 +1006,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} else if (file.lowercase().endsWith(".txt") || mime == "text/plain") { } else if (file.lowercase().endsWith(".txt") || mime == "text/plain") {
return handleUnknownText(String(data)); return handleUnknownText(String(data));
} }
else if (mime?.let { it.startsWith("video/") || it.startsWith("audio/") } ?: false) {
val mediaItem = LocalVideoDetails.fromContent(file, mime);
navigateWhenReady(_fragVideoDetail, mediaItem);
return true;
}
return false; return false;
} }
@@ -1043,7 +1126,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.i(TAG, "handleFCast"); Logger.i(TAG, "handleFCast");
try { try {
StateCasting.instance.handleUrl(this, url) StateCasting.instance.handleUrl(url)
return true; return true;
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(TAG, "Failed to parse FCast URL '${url}'.", e) Log.e(TAG, "Failed to parse FCast URL '${url}'.", e)
@@ -1075,7 +1158,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed()) if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
return; return;
if (!fragCurrent.onBackPressed()) if (!(fragCurrent?.onBackPressed() ?: true))
closeSegment(); closeSegment();
} }
@@ -1126,6 +1209,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
} }
inline fun <reified T : Fragment> navigate(parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) {
val segment = getFragment<T>();
navigate(segment as MainFragment, 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
@@ -1148,27 +1236,27 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return; return;
} }
fragCurrent.onHide(); fragCurrent?.onHide();
if (segment.isMainView) { if (segment.isMainView) {
var transaction = supportFragmentManager.beginTransaction(); var transaction = supportFragmentManager.beginTransaction();
if (segment.topBar != null) { if (segment.topBar != null) {
if (segment.topBar != fragCurrent.topBar) { if (segment.topBar != fragCurrent?.topBar) {
transaction = transaction transaction = transaction
.show(segment.topBar as Fragment) .show(segment.topBar as Fragment)
.replace(R.id.fragment_top_bar, segment.topBar as Fragment); .replace(R.id.fragment_top_bar, segment.topBar as Fragment);
fragCurrent.topBar?.onHide(); fragCurrent?.topBar?.onHide();
} }
} else if (fragCurrent.topBar != null) } else if (fragCurrent?.topBar != null)
transaction.hide(fragCurrent.topBar as Fragment); transaction.hide(fragCurrent?.topBar as Fragment);
transaction = transaction.replace(R.id.fragment_main, segment); transaction = transaction.replace(R.id.fragment_main, segment);
if (segment.hasBottomBar) { if (segment.hasBottomBar) {
if (!fragCurrent.hasBottomBar) if (!(fragCurrent?.hasBottomBar ?: false))
transaction = transaction.show(_fragBotBarMenu); transaction = transaction.show(_fragBotBarMenu);
} else { } else {
if (fragCurrent.hasBottomBar) if (fragCurrent?.hasBottomBar ?: false)
transaction = transaction.hide(_fragBotBarMenu); transaction = transaction.hide(_fragBotBarMenu);
} }
transaction.commitNow(); transaction.commitNow();
@@ -1181,10 +1269,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
} }
if (fragCurrent.isHistory && withHistory && _queue.lastOrNull() != fragCurrent) if (fragCurrent?.isHistory ?: false && withHistory && _queue.lastOrNull() != fragCurrent)
_queue.add(Pair(fragCurrent, _parameterCurrent)); _queue.add(Pair(fragCurrent!!, _parameterCurrent));
if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory) if (segment.isOverlay && !(fragCurrent?.isOverlay ?: false) && withHistory)// && fragCurrent.isHistory)
fragBeforeOverlay = fragCurrent; fragBeforeOverlay = fragCurrent;
fragCurrent = segment; fragCurrent = segment;
@@ -1215,11 +1303,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
navigate(last.first, last.second, false, true); navigate(last.first, last.second, false, true);
} else { } else {
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) { if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
Logger.i(TAG, "Closing activity because _fragVideoDetail.state == closed");
finish(); finish();
} else { } else {
//UIDialogs.toast("Grayjay continues in background because of an open video.")
if(Settings.instance.playback.isBackgroundPictureInPicture()) {
try {
_fragVideoDetail._viewDetail?.startPictureInPicture();
_fragVideoDetail?.forcePictureInPicture();
} catch (ex: Throwable) {
} //Fail silently
}
else
moveTaskToBack(false);
/*
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", { UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
finish(); finish();
}) })
*/
} }
} }
} }
@@ -1238,6 +1339,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
VideoDetailFragment::class -> _fragVideoDetail as T; VideoDetailFragment::class -> _fragVideoDetail as T;
MenuBottomBarFragment::class -> _fragBotBarMenu as T; MenuBottomBarFragment::class -> _fragBotBarMenu as T;
GeneralTopBarFragment::class -> _fragTopBarGeneral as T; GeneralTopBarFragment::class -> _fragTopBarGeneral as T;
FilesTopBarFragment::class -> _fragTopBarFiles as T;
SearchTopBarFragment::class -> _fragTopBarSearch as T; SearchTopBarFragment::class -> _fragTopBarSearch as T;
CreatorsFragment::class -> _fragMainSubscriptions as T; CreatorsFragment::class -> _fragMainSubscriptions as T;
CommentsFragment::class -> _fragMainComments as T; CommentsFragment::class -> _fragMainComments as T;
@@ -1253,6 +1355,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
WebDetailFragment::class -> _fragWebDetail 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;
ShortsFragment::class -> _fragShorts as T;
SourceDetailFragment::class -> _fragSourceDetail as T; SourceDetailFragment::class -> _fragSourceDetail as T;
DownloadsFragment::class -> _fragDownloads as T; DownloadsFragment::class -> _fragDownloads as T;
ImportSubscriptionsFragment::class -> _fragImportSubscriptions as T; ImportSubscriptionsFragment::class -> _fragImportSubscriptions as T;
@@ -1261,6 +1364,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
BuyFragment::class -> _fragBuy as T; BuyFragment::class -> _fragBuy as T;
SubscriptionGroupFragment::class -> _fragSubGroup as T; SubscriptionGroupFragment::class -> _fragSubGroup as T;
SubscriptionGroupListFragment::class -> _fragSubGroupList as T; SubscriptionGroupListFragment::class -> _fragSubGroupList as T;
LibraryFragment::class -> _fragLibrary as T;
LibraryAlbumsFragment::class -> _fragLibraryAlbums as T;
LibraryAlbumFragment::class -> _fragLibraryAlbum as T;
LibraryArtistsFragment::class -> _fragLibraryArtists as T;
LibraryArtistFragment::class -> _fragLibraryArtist as T;
LibraryVideosFragment::class -> _fragLibraryVideos as T;
LibraryFilesFragment::class -> _fragLibraryFiles as T;
LibrarySearchFragment::class -> _fragLibrarySearch as T;
NotificationOverlayView.Frag::class -> _fragNotifications as T;
SettingsFragment:: class -> _fragSettings as T;
DeveloperFragment::class -> _fragDeveloper as T;
LoginFragment::class -> _fragLogin as T;
else -> throw IllegalArgumentException("Fragment type ${T::class.java.name} is not available in MainActivity"); else -> throw IllegalArgumentException("Fragment type ${T::class.java.name} is not available in MainActivity");
} }
} }
@@ -1268,7 +1383,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private fun updateSegmentPaddings() { private fun updateSegmentPaddings() {
var paddingBottom = 0f; var paddingBottom = 0f;
if (fragCurrent.hasBottomBar) if (fragCurrent?.hasBottomBar ?: false)
paddingBottom += HEIGHT_MENU_DP; paddingBottom += HEIGHT_MENU_DP;
_fragContainerOverlay.setPadding( _fragContainerOverlay.setPadding(
@@ -1285,6 +1400,23 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
); );
} }
var _callbackPermissionAudio: ((Boolean)->Unit)? = null;
var _callbackPermissionVideo: ((Boolean)->Unit)? = null;
val permissionReqAudio = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
_callbackPermissionAudio?.invoke(isGranted);
});
val permissionReqVideo = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
_callbackPermissionVideo?.invoke(isGranted);
});
fun requestPermissionAudio(cb: ((Boolean)->Unit)? = null) {
_callbackPermissionAudio = cb;
permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO);
}
fun requestPermissionVideo(cb: ((Boolean)->Unit)? = null) {
_callbackPermissionVideo = cb;
permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO);
}
val notifPermission = "android.permission.POST_NOTIFICATIONS"; val notifPermission = "android.permission.POST_NOTIFICATIONS";
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
@@ -1411,4 +1543,4 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return sourcesIntent; return sourcesIntent;
} }
} }
} }
@@ -13,13 +13,18 @@ import android.view.View
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
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.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.StateApp.Companion.withContext
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.activities.QRCodeFullscreenActivity
import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.SignedEvent import com.futo.polycentric.core.SignedEvent
import com.futo.polycentric.core.StorageTypeCRDTItem import com.futo.polycentric.core.StorageTypeCRDTItem
@@ -27,8 +32,13 @@ import com.futo.polycentric.core.StorageTypeCRDTSetItem
import com.futo.polycentric.core.Store import com.futo.polycentric.core.Store
import com.futo.polycentric.core.toBase64Url import com.futo.polycentric.core.toBase64Url
import com.google.zxing.BarcodeFormat import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.MultiFormatWriter import com.google.zxing.MultiFormatWriter
import com.google.zxing.common.BitMatrix import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import userpackage.Protocol import userpackage.Protocol
import userpackage.Protocol.ExportBundle import userpackage.Protocol.ExportBundle
import userpackage.Protocol.URLInfo import userpackage.Protocol.URLInfo
@@ -36,9 +46,26 @@ import userpackage.Protocol.URLInfo
class PolycentricBackupActivity : AppCompatActivity() { class PolycentricBackupActivity : AppCompatActivity() {
private lateinit var _buttonShare: BigButton; private lateinit var _buttonShare: BigButton;
private lateinit var _buttonCopy: BigButton; private lateinit var _buttonCopy: BigButton;
private lateinit var _buttonExportFile: BigButton;
private lateinit var _imageQR: ImageView; private lateinit var _imageQR: ImageView;
private lateinit var _exportBundle: String; private lateinit var _exportBundle: String;
private lateinit var _textQR: TextView; private lateinit var _textQR: TextView;
private lateinit var _textQRHint: TextView;
private lateinit var _loader: View
private val _createDocumentLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
uri?.let { fileUri ->
try {
contentResolver.openOutputStream(fileUri)?.use { outputStream ->
outputStream.write(_exportBundle.toByteArray())
}
UIDialogs.toast(this, getString(R.string.profile_saved_successfully))
} catch (e: Exception) {
Logger.e(TAG, "Failed to write to document", e)
UIDialogs.toast(this, "Failed to save profile: ${e.message}")
}
}
}
override fun attachBaseContext(newBase: Context?) { override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase)) super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
@@ -49,24 +76,75 @@ class PolycentricBackupActivity : AppCompatActivity() {
setContentView(R.layout.activity_polycentric_backup); setContentView(R.layout.activity_polycentric_backup);
setNavigationBarColorAndIcons(); setNavigationBarColorAndIcons();
_buttonShare = findViewById(R.id.button_share); _buttonShare = findViewById(R.id.button_share)
_buttonCopy = findViewById(R.id.button_copy); _buttonCopy = findViewById(R.id.button_copy)
_imageQR = findViewById(R.id.image_qr); _buttonExportFile = findViewById(R.id.button_export_file)
_textQR = findViewById(R.id.text_qr); _imageQR = findViewById(R.id.image_qr)
_textQR = findViewById(R.id.text_qr)
_textQRHint = findViewById(R.id.text_qr_hint)
_loader = findViewById(R.id.progress_loader)
findViewById<ImageButton>(R.id.button_back).setOnClickListener { findViewById<ImageButton>(R.id.button_back).setOnClickListener {
finish(); finish();
}; };
_exportBundle = createExportBundle(); _imageQR.visibility = View.INVISIBLE
_textQR.visibility = View.INVISIBLE
_textQRHint.visibility = View.INVISIBLE
_loader.visibility = View.VISIBLE
_buttonShare.visibility = View.INVISIBLE
_buttonCopy.visibility = View.INVISIBLE
_buttonExportFile.visibility = View.INVISIBLE
try { lifecycleScope.launch {
val dimension = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics).toInt(); val bundle = withContext(Dispatchers.IO) { createExportBundle() }
val qrCodeBitmap = generateQRCode(_exportBundle, dimension, dimension); _exportBundle = bundle
_imageQR.setImageBitmap(qrCodeBitmap); Logger.i(TAG, "Export bundle created, length: ${bundle.length}")
} catch (e: Exception) {
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e); try {
_imageQR.visibility = View.INVISIBLE; val pair = withContext(Dispatchers.IO) {
_textQR.visibility = View.INVISIBLE; if (!isContentSuitableForQRCode(bundle)) {
throw Exception("Data too big for QR code generation")
}
val dimension = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics
).toInt()
val qr = generateQRCode(bundle, dimension, dimension)
Pair(bundle, qr)
}
_imageQR.setImageBitmap(pair.second)
_imageQR.visibility = View.VISIBLE
_textQR.visibility = View.VISIBLE
_textQRHint.visibility = View.VISIBLE
_buttonShare.visibility = View.VISIBLE
_buttonCopy.visibility = View.VISIBLE
_imageQR.setOnClickListener {
val intent = QRCodeFullscreenActivity.createIntent(this@PolycentricBackupActivity, _exportBundle)
startActivity(intent)
}
} catch (e: Exception) {
val byteSize = bundle.toByteArray(Charsets.UTF_8).size
Logger.e(TAG, "QR code generation failed. Bundle length: ${bundle.length} chars, ${byteSize} bytes, Error: ${e.message}", e)
if (e.message?.contains("Data too big") == true) {
_textQR.text = getString(R.string.qr_code_too_large_use_file_export)
_buttonExportFile.visibility = View.VISIBLE
} else {
_textQR.text = getString(R.string.failed_to_generate_qr_code)
}
_textQR.visibility = View.VISIBLE
_textQRHint.visibility = View.INVISIBLE
_buttonShare.visibility = View.VISIBLE
_buttonCopy.visibility = View.VISIBLE
// Hide QR image since generation failed
_imageQR.visibility = View.INVISIBLE
} finally {
_loader.visibility = View.GONE
}
} }
_buttonShare.onClick.subscribe { _buttonShare.onClick.subscribe {
@@ -79,11 +157,29 @@ class PolycentricBackupActivity : AppCompatActivity() {
val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle); val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle);
clipboard.setPrimaryClip(clip); clipboard.setPrimaryClip(clip);
}; };
_buttonExportFile.onClick.subscribe {
val fileName = "polycentric_profile_${System.currentTimeMillis()}.txt"
_createDocumentLauncher.launch(fileName)
};
}
private fun isContentSuitableForQRCode(content: String): Boolean {
val bytes = content.toByteArray(Charsets.UTF_8)
return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes
} }
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap { private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height); if (!isContentSuitableForQRCode(content)) {
return bitMatrixToBitmap(bitMatrix); throw Exception("Data too big for QR code generation")
}
val hints = java.util.EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
hints[EncodeHintType.MARGIN] = 1
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints)
return bitMatrixToBitmap(bitMatrix)
} }
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap { private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
@@ -174,7 +270,8 @@ class PolycentricBackupActivity : AppCompatActivity() {
.setBody(exportBundle.toByteString()) .setBody(exportBundle.toByteString())
.build(); .build();
return "polycentric://" + urlInfo.toByteArray().toBase64Url() val data = urlInfo.toByteArray()
return "polycentric://" + data.toBase64Url()
} }
companion object { companion object {
@@ -32,100 +32,166 @@ import userpackage.Protocol
import userpackage.Protocol.ExportBundle import userpackage.Protocol.ExportBundle
class PolycentricImportProfileActivity : AppCompatActivity() { class PolycentricImportProfileActivity : AppCompatActivity() {
private lateinit var _buttonHelp: ImageButton; private lateinit var _buttonHelp: ImageButton
private lateinit var _buttonScanProfile: LinearLayout; private lateinit var _buttonScanProfile: LinearLayout
private lateinit var _buttonImportProfile: LinearLayout; private lateinit var _buttonImportFile: LinearLayout
private lateinit var _editProfile: EditText; private lateinit var _buttonImportProfile: LinearLayout
private lateinit var _loaderOverlay: LoaderOverlay; private lateinit var _editProfile: EditText
private lateinit var _loaderOverlay: LoaderOverlay
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val _qrCodeResultLauncher =
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data) registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
scanResult?.let { val scanResult =
if (it.contents != null) { IntentIntegrator.parseActivityResult(result.resultCode, result.data)
val scannedUrl = it.contents scanResult?.let {
import(scannedUrl) if (it.contents != null) {
val scannedUrl = it.contents
import(scannedUrl)
}
}
}
private val _filePickerLauncher =
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { fileUri ->
try {
// Check file size before reading
val fileSize =
contentResolver.openFileDescriptor(fileUri, "r")?.statSize ?: 0
val maxFileSize = 10 * 1024 * 1024 // 10MB limit
if (fileSize > maxFileSize) {
UIDialogs.toast(this, "File too large. Maximum size is 10MB.")
return@let
}
if (fileSize == 0L) {
UIDialogs.toast(this, "Selected file is empty.")
return@let
}
val content =
contentResolver
.openInputStream(fileUri)
?.bufferedReader()
?.readText()
content?.let { fileContent ->
val trimmedContent = fileContent.trim()
// Check if content is empty after trimming
if (trimmedContent.isEmpty()) {
UIDialogs.toast(this, "Selected file contains no data.")
return@let
}
// Check if content looks like a valid polycentric URL
if (!trimmedContent.startsWith("polycentric://")) {
UIDialogs.toast(
this,
"Selected file does not contain a valid polycentric profile URL."
)
return@let
}
import(trimmedContent)
}
?: run { UIDialogs.toast(this, "Could not read file content.") }
} catch (e: SecurityException) {
Logger.e(TAG, "Security exception reading file", e)
UIDialogs.toast(this, "Permission denied to read file.")
} catch (e: OutOfMemoryError) {
Logger.e(TAG, "Out of memory reading file", e)
UIDialogs.toast(this, "File too large to process.")
} catch (e: Exception) {
Logger.e(TAG, "Failed to read file", e)
UIDialogs.toast(this, "Failed to read file: ${e.message}")
}
}
} }
}
}
override fun attachBaseContext(newBase: Context?) { override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase)) super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState)
setContentView(R.layout.activity_polycentric_import_profile); setContentView(R.layout.activity_polycentric_import_profile)
setNavigationBarColorAndIcons(); setNavigationBarColorAndIcons()
_buttonHelp = findViewById(R.id.button_help); _buttonHelp = findViewById(R.id.button_help)
_buttonScanProfile = findViewById(R.id.button_scan_profile); _buttonScanProfile = findViewById(R.id.button_scan_profile)
_buttonImportProfile = findViewById(R.id.button_import_profile); _buttonImportFile = findViewById(R.id.button_import_file)
_loaderOverlay = findViewById(R.id.loader_overlay); _buttonImportProfile = findViewById(R.id.button_import_profile)
_editProfile = findViewById(R.id.edit_profile); _loaderOverlay = findViewById(R.id.loader_overlay)
findViewById<ImageButton>(R.id.button_back).setOnClickListener { _editProfile = findViewById(R.id.edit_profile)
finish(); findViewById<ImageButton>(R.id.button_back).setOnClickListener { finish() }
};
_buttonHelp.setOnClickListener { _buttonHelp.setOnClickListener {
startActivity(Intent(this, PolycentricWhyActivity::class.java)); startActivity(Intent(this, PolycentricWhyActivity::class.java))
}; }
_buttonScanProfile.setOnClickListener { _buttonScanProfile.setOnClickListener {
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))
integrator.setOrientationLocked(true); integrator.setOrientationLocked(true)
integrator.setCameraId(0) integrator.setCameraId(0)
integrator.setBeepEnabled(false) integrator.setBeepEnabled(false)
integrator.setBarcodeImageEnabled(true) integrator.setBarcodeImageEnabled(true)
integrator.setCaptureActivity(QRCaptureActivity::class.java); integrator.setCaptureActivity(QRCaptureActivity::class.java)
_qrCodeResultLauncher.launch(integrator.createScanIntent()) _qrCodeResultLauncher.launch(integrator.createScanIntent())
}; }
_buttonImportFile.setOnClickListener { _filePickerLauncher.launch("text/plain") }
_buttonImportProfile.setOnClickListener { _buttonImportProfile.setOnClickListener {
if (_editProfile.text.isEmpty()) { if (_editProfile.text.isEmpty()) {
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data)); UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data))
return@setOnClickListener; return@setOnClickListener
} }
import(_editProfile.text.toString()); import(_editProfile.text.toString())
}; }
val url = intent.getStringExtra("url"); val url = intent.getStringExtra("url")
if (url != null) { if (url != null) {
import(url); import(url)
} }
} }
private fun import(url: String) { private fun import(url: String) {
if (!url.startsWith("polycentric://")) { if (!url.startsWith("polycentric://")) {
UIDialogs.toast(this, getString(R.string.not_a_valid_url)); UIDialogs.toast(this, getString(R.string.not_a_valid_url))
return; return
} }
_loaderOverlay.show() _loaderOverlay.show()
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {
val data = url.substring("polycentric://".length).base64UrlToByteArray(); val data = url.substring("polycentric://".length).base64UrlToByteArray()
val urlInfo = Protocol.URLInfo.parseFrom(data); val urlInfo = Protocol.URLInfo.parseFrom(data)
if (urlInfo.urlType != 3L) { if (urlInfo.urlType != 3L) {
throw Exception("Expected urlInfo struct of type ExportBundle") throw Exception("Expected urlInfo struct of type ExportBundle")
} }
val exportBundle = ExportBundle.parseFrom(urlInfo.body); val exportBundle = ExportBundle.parseFrom(urlInfo.body)
val keyPair = KeyPair.fromProto(exportBundle.keyPair); val keyPair = KeyPair.fromProto(exportBundle.keyPair)
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey); val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey)
if (existingProcessSecret != null) { if (existingProcessSecret != null) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.this_profile_is_already_imported)); UIDialogs.toast(
this@PolycentricImportProfileActivity,
getString(R.string.this_profile_is_already_imported)
)
} }
return@launch; return@launch
} }
val processSecret = ProcessSecret(keyPair, Process.random()); val processSecret = ProcessSecret(keyPair, Process.random())
Store.instance.addProcessSecret(processSecret); Store.instance.addProcessSecret(processSecret)
try { try {
PolycentricStorage.instance.addProcessSecret(processSecret) PolycentricStorage.instance.addProcessSecret(processSecret)
@@ -133,37 +199,43 @@ class PolycentricImportProfileActivity : 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)
} }
val processHandle = processSecret.toProcessHandle(); val processHandle = processSecret.toProcessHandle()
for (e in exportBundle.events.eventsList) { for (e in exportBundle.events.eventsList) {
try { try {
val se = SignedEvent.fromProto(e); val se = SignedEvent.fromProto(e)
Store.instance.putSignedEvent(se); Store.instance.putSignedEvent(se)
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Ignored invalid event", e); Logger.w(TAG, "Ignored invalid event", e)
} }
} }
StatePolycentric.instance.setProcessHandle(processHandle); StatePolycentric.instance.setProcessHandle(processHandle)
processHandle.fullyBackfillClient(ApiMethods.SERVER); processHandle.fullyBackfillClient(ApiMethods.SERVER)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java)); startActivity(
finish(); Intent(
this@PolycentricImportProfileActivity,
PolycentricProfileActivity::class.java
)
)
finish()
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to import profile", e); Logger.w(TAG, "Failed to import profile", e)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.failed_to_import_profile) + " '${e.message}'"); UIDialogs.toast(
this@PolycentricImportProfileActivity,
getString(R.string.failed_to_import_profile) + " '${e.message}'"
)
} }
} finally { } finally {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) { _loaderOverlay.hide() }
_loaderOverlay.hide();
}
} }
} }
} }
companion object { companion object {
private const val TAG = "PolycentricImportProfileActivity"; private const val TAG = "PolycentricImportProfileActivity"
} }
} }
@@ -0,0 +1,147 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.os.Bundle
import android.widget.ImageButton
import android.widget.SeekBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.polycentric.ModerationsManager
import com.futo.platformplayer.R
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.setNavigationBarColorAndIcons
class PolycentricModerationActivity : AppCompatActivity() {
private lateinit var _seekbarOffensive: SeekBar
private lateinit var _seekbarExplicit: SeekBar
private lateinit var _seekbarViolence: SeekBar
private lateinit var _textOffensiveDesc: TextView
private lateinit var _textExplicitDesc: TextView
private lateinit var _textViolenceDesc: TextView
private lateinit var _textOffensiveValue: TextView
private lateinit var _textExplicitValue: TextView
private lateinit var _textViolenceValue: TextView
private lateinit var _moderationsManager: ModerationsManager
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_polycentric_moderation)
setNavigationBarColorAndIcons()
_moderationsManager = ModerationsManager.getInstance()
try {
_moderationsManager = ModerationsManager.getInstance()
} catch (e: IllegalStateException) {
finish()
return
}
_seekbarOffensive = findViewById(R.id.seekbar_offensive)
_seekbarExplicit = findViewById(R.id.seekbar_explicit)
_seekbarViolence = findViewById(R.id.seekbar_violence)
_textOffensiveDesc = findViewById(R.id.text_offensive_desc)
_textExplicitDesc = findViewById(R.id.text_explicit_desc)
_textViolenceDesc = findViewById(R.id.text_violence_desc)
_textOffensiveValue = findViewById(R.id.text_offensive_value)
_textExplicitValue = findViewById(R.id.text_explicit_value)
_textViolenceValue = findViewById(R.id.text_violence_value)
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
finish()
}
loadSettings()
setupListeners()
}
private fun loadSettings() {
val levels = _moderationsManager.moderationLevels.value ?: mapOf()
val offensiveLevel = levels["hate"] ?: 2
val explicitLevel = levels["sexual"] ?: 1
val violenceLevel = levels["violence"] ?: 1
_seekbarOffensive.progress = offensiveLevel
_seekbarExplicit.progress = explicitLevel
_seekbarViolence.progress = violenceLevel
updateDescriptionText(_seekbarOffensive, _textOffensiveDesc, _textOffensiveValue, getOffensiveDescriptions())
updateDescriptionText(_seekbarExplicit, _textExplicitDesc, _textExplicitValue, getExplicitDescriptions())
updateDescriptionText(_seekbarViolence, _textViolenceDesc, _textViolenceValue, getViolenceDescriptions())
}
private fun setupListeners() {
_seekbarOffensive.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
updateDescriptionText(seekBar, _textOffensiveDesc, _textOffensiveValue, getOffensiveDescriptions())
if (fromUser) {
_moderationsManager.setModerationLevel("hate", progress)
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
_seekbarExplicit.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
updateDescriptionText(seekBar, _textExplicitDesc, _textExplicitValue, getExplicitDescriptions())
if (fromUser) {
_moderationsManager.setModerationLevel("sexual", progress)
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
_seekbarViolence.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
updateDescriptionText(seekBar, _textViolenceDesc, _textViolenceValue, getViolenceDescriptions())
if (fromUser) {
_moderationsManager.setModerationLevel("violence", progress)
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
}
private fun updateDescriptionText(seekBar: SeekBar?, textDesc: TextView, textValue: TextView, descriptions: Array<String>) {
val progress = seekBar?.progress ?: 0
textDesc.text = descriptions[progress]
textValue.text = progress.toString()
}
private fun getOffensiveDescriptions(): Array<String> {
return arrayOf(
"Neutral, general terms, no bias or hate.",
"Mildly sensitive, factual.",
"Potentially offensive content",
"Offensive content"
)
}
private fun getExplicitDescriptions(): Array<String> {
return arrayOf(
"No explicit content",
"Mildly suggestive, factual or educational",
"Moderate sexual content, non-graphic",
"Explicit sexual content"
)
}
private fun getViolenceDescriptions(): Array<String> {
return arrayOf(
"Non-violent",
"Mild violence, factual or contextual",
"Moderate violence, some graphic content.",
"Graphic violence"
)
}
}
@@ -49,7 +49,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
private lateinit var _buttonHelp: ImageButton; private lateinit var _buttonHelp: ImageButton;
private lateinit var _editName: EditText; private lateinit var _editName: EditText;
private lateinit var _buttonExport: BigButton; private lateinit var _buttonExport: BigButton;
private lateinit var _buttonOpenHarborProfile: BigButton; private lateinit var _buttonModeration: BigButton;
private lateinit var _buttonLogout: BigButton; private lateinit var _buttonLogout: BigButton;
private lateinit var _buttonDelete: BigButton; private lateinit var _buttonDelete: BigButton;
private lateinit var _username: String; private lateinit var _username: String;
@@ -71,7 +71,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
_imagePolycentric = findViewById(R.id.image_polycentric); _imagePolycentric = findViewById(R.id.image_polycentric);
_editName = findViewById(R.id.edit_profile_name); _editName = findViewById(R.id.edit_profile_name);
_buttonExport = findViewById(R.id.button_export); _buttonExport = findViewById(R.id.button_export);
_buttonOpenHarborProfile = findViewById(R.id.button_open_harbor_profile); _buttonModeration = findViewById(R.id.button_moderation);
_buttonLogout = findViewById(R.id.button_logout); _buttonLogout = findViewById(R.id.button_logout);
_buttonDelete = findViewById(R.id.button_delete); _buttonDelete = findViewById(R.id.button_delete);
_loaderOverlay = findViewById(R.id.loader_overlay); _loaderOverlay = findViewById(R.id.loader_overlay);
@@ -99,15 +99,9 @@ class PolycentricProfileActivity : AppCompatActivity() {
startActivity(Intent(this, PolycentricBackupActivity::class.java)); startActivity(Intent(this, PolycentricBackupActivity::class.java));
}; };
_buttonOpenHarborProfile.onClick.subscribe { _buttonModeration.onClick.subscribe {
val processHandle = StatePolycentric.instance.processHandle!!; startActivity(Intent(this, PolycentricModerationActivity::class.java));
processHandle?.let { };
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(it.system));
val url = it.system.systemToURLInfoSystemLinkUrl(systemState.servers.asIterable());
val navUrl = "https://harbor.social/" + url.substring("polycentric://".length)
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl)))
}
}
_buttonLogout.onClick.subscribe { _buttonLogout.onClick.subscribe {
StatePolycentric.instance.setProcessHandle(null); StatePolycentric.instance.setProcessHandle(null);
@@ -0,0 +1,109 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.os.Bundle
import android.util.TypedValue
import android.view.View
import android.widget.ImageButton
import android.widget.ImageView
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.R
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.MultiFormatWriter
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
class QRCodeFullscreenActivity : AppCompatActivity() {
companion object {
private const val EXTRA_QR_TEXT = "qr_text"
fun createIntent(context: Context, qrText: String): android.content.Intent {
return android.content.Intent(context, QRCodeFullscreenActivity::class.java).apply {
putExtra(EXTRA_QR_TEXT, qrText)
}
}
}
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_qr_code_fullscreen)
setNavigationBarColorAndIcons()
val qrText = intent.getStringExtra(EXTRA_QR_TEXT)
val imageQR = findViewById<ImageView>(R.id.image_qr_fullscreen)
val buttonBack = findViewById<ImageButton>(R.id.button_back_fullscreen)
val buttonClose = findViewById<ImageButton>(R.id.button_close_fullscreen)
// Generate QR code bitmap from text
qrText?.let { text ->
try {
if (!isContentSuitableForQRCode(text)) {
throw Exception("Data too big for QR code generation")
}
val dimension = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 300f, resources.displayMetrics
).toInt()
val qrBitmap = generateQRCode(text, dimension, dimension)
imageQR.setImageBitmap(qrBitmap)
} catch (e: Exception) {
// If QR generation fails, show error or fallback
imageQR.setImageResource(R.drawable.ic_qr)
}
}
buttonBack.setOnClickListener {
finish()
}
buttonClose.setOnClickListener {
finish()
}
imageQR.setOnClickListener {
finish()
}
}
private fun isContentSuitableForQRCode(content: String): Boolean {
val bytes = content.toByteArray(Charsets.UTF_8)
return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes
}
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
if (!isContentSuitableForQRCode(content)) {
throw Exception("Data too big for QR code generation")
}
val hints = java.util.EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
hints[EncodeHintType.MARGIN] = 1
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints)
return bitMatrixToBitmap(bitMatrix)
}
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
val width = matrix.width
val height = matrix.height
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
for (x in 0 until width) {
for (y in 0 until height) {
bmp.setPixel(x, y, if (matrix[x, y]) Color.BLACK else Color.WHITE)
}
}
return bmp
}
}
@@ -1,208 +0,0 @@
package com.futo.platformplayer.activities
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.ReadOnlyTextField
import com.google.android.material.button.MaterialButton
class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
private lateinit var _form: FieldForm;
private lateinit var _buttonBack: ImageButton;
private lateinit var _loaderView: LoaderView;
private lateinit var _devSets: LinearLayout;
private lateinit var _buttonDev: MaterialButton;
private var _isFinished = false;
lateinit var overlay: FrameLayout;
val notifPermission = "android.permission.POST_NOTIFICATIONS";
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
if (isGranted)
UIDialogs.toast(this, "Notification permission granted");
else
UIDialogs.toast(this, "Notification permission denied");
}
override fun attachBaseContext(newBase: Context?) {
Logger.i("SettingsActivity", "SettingsActivity.attachBaseContext")
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_settings);
setNavigationBarColorAndIcons();
_form = findViewById(R.id.settings_form);
_buttonBack = findViewById(R.id.button_back);
_buttonDev = findViewById(R.id.button_dev);
_devSets = findViewById(R.id.dev_settings);
_loaderView = findViewById(R.id.loader);
overlay = findViewById(R.id.overlay_container);
_form.onChanged.subscribe { field, _ ->
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
_form.setObjectValues();
Settings.instance.save();
if(field.descriptor?.id == "app_language") {
Logger.i("SettingsActivity", "App language change detected, propogating to shared preferences");
StateApp.instance.setLocaleSetting(this, Settings.instance.language.getAppLanguageLocaleString());
}
if(field.descriptor?.id == "background_update") {
Logger.i("SettingsActivity", "Detected change in background work ${field.value}");
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval > 0) {
val notifManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
if(!notifManager.areNotificationsEnabled()) {
UIDialogs.toast(this, "Notifications aren't enabled");
when {
ContextCompat.checkSelfPermission(this, notifPermission) == PackageManager.PERMISSION_GRANTED -> {
}
ActivityCompat.shouldShowRequestPermissionRationale(this, notifPermission) -> {
UIDialogs.showDialog(this, R.drawable.ic_notifications, "Notifications Required",
"Notifications need to be enabled for background updating to function", null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Enable", {
requestPermissionLauncher.launch(notifPermission);
}, UIDialogs.ActionStyle.PRIMARY));
}
else -> {
requestPermissionLauncher.launch(notifPermission);
}
}
}
}
}
};
_buttonBack.setOnClickListener {
finish();
}
_buttonDev.setOnClickListener {
startActivity(Intent(this, DeveloperActivity::class.java));
}
_lastActivity = this;
reloadSettings();
}
var isFirstLoad = true;
fun reloadSettings() {
val firstLoad = isFirstLoad;
isFirstLoad = false;
_form.setSearchVisible(false);
_loaderView.start();
_form.fromObject(lifecycleScope, Settings.instance) {
_loaderView.stop();
_form.setSearchVisible(true);
var devCounter = 0;
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
devCounter++;
if(devCounter > 5) {
devCounter = 0;
SettingsDev.instance.developerMode = true;
SettingsDev.instance.save();
updateDevMode();
UIDialogs.toast(this, getString(R.string.you_are_now_in_developer_mode));
}
};
if(firstLoad) {
val query = intent.getStringExtra("query");
if(!query.isNullOrEmpty()) {
_form.setSearchQuery(query);
}
}
};
}
override fun onResume() {
super.onResume()
updateDevMode();
}
fun updateDevMode() {
if(SettingsDev.instance.developerMode)
_devSets.visibility = View.VISIBLE;
else
_devSets.visibility = View.GONE;
}
override fun finish() {
super.finish()
_isFinished = true;
if(_lastActivity == this)
_lastActivity = null;
overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up)
}
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
private var requestCode: Int? = -1;
private val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult ->
val handler = synchronized(resultLauncherMap) {
resultLauncherMap.remove(requestCode);
}
if(handler != null)
handler(result);
};
override fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit) {
synchronized(resultLauncherMap) {
resultLauncherMap[code] = handler;
}
requestCode = code;
resultLauncher.launch(intent);
}
override fun onDestroy() {
super.onDestroy()
settingsActivityClosed.emit()
}
companion object {
//TODO: Temporary for solving Settings issues
@SuppressLint("StaticFieldLeak")
private var _lastActivity: SettingsActivity? = null;
val settingsActivityClosed = Event0()
fun getActivity(): SettingsActivity? {
val act = _lastActivity;
if(act != null && !act._isFinished)
return act;
return null;
}
}
}
@@ -110,7 +110,19 @@ class SyncPairActivity : AppCompatActivity() {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {
StateSync.instance.syncService?.connect(deviceInfo) { complete, message -> var wasCompleted = false
StateSync.instance.syncService?.connect(deviceInfo, true) { complete, message ->
if (wasCompleted) {
Logger.i(TAG, "onStatusUpdate(complete = ${complete}, message = '${message} ignored because wasCompleted')")
return@connect
}
if (complete == true) {
wasCompleted = true
}
Logger.i(TAG, "onStatusUpdate(complete = ${complete}, message = '${message}')")
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
if (complete != null) { if (complete != null) {
if (complete) { if (complete) {
@@ -2,12 +2,24 @@ package com.futo.platformplayer.activities
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.views.TargetTapLoaderView
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class TestActivity : AppCompatActivity() { class TestActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_test); setContentView(R.layout.activity_test);
val view = findViewById<TargetTapLoaderView>(R.id.test_view)
view.startLoader(10000)
lifecycleScope.launch {
delay(5000)
view.startLoader()
}
} }
companion object { companion object {
@@ -0,0 +1,318 @@
package com.futo.platformplayer.api.http.server.handlers
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import android.provider.OpenableColumns
import com.futo.platformplayer.api.http.server.HttpContext
import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.logging.Logger
import java.io.FileNotFoundException
import java.io.InputStream
import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.*
class HttpContentUriHandler(
method: String,
path: String,
private val contentResolver: ContentResolver,
private val uri: Uri,
private val explicitContentType: String? = null
) : HttpHandler(method, path) {
override fun handle(httpContext: HttpContext) {
val resolver = contentResolver
val requestHeaders = httpContext.headers
val responseHeaders = this.headers.clone()
val meta = try {
queryMetadata(resolver, uri)
} catch (e: Exception) {
Logger.e(TAG, "Failed to query metadata for $uri", e)
httpContext.respondCode(404, responseHeaders)
return
}
val contentType = explicitContentType
?: resolver.getType(uri)
?: "application/octet-stream"
responseHeaders["Content-Type"] = contentType
meta.lastModifiedMillis?.let { lastModified ->
responseHeaders["Last-Modified"] = httpDateFormat.format(Date(lastModified))
val ifModifiedSinceHeader = requestHeaders["If-Modified-Since"]
if (ifModifiedSinceHeader != null) {
val ifModifiedSince = try {
httpDateFormat.parse(ifModifiedSinceHeader)
} catch (_: Exception) {
null
}
if (ifModifiedSince != null && lastModified <= ifModifiedSince.time) {
httpContext.respondCode(304, responseHeaders)
return
}
}
}
val safeName = (meta.displayName ?: "content.bin").replace("\"", "\\\"")
responseHeaders["Content-Disposition"] = "attachment; filename=\"$safeName\""
val length = meta.size
if (length == null) {
Logger.i(TAG, "Streaming $uri with unknown length; Range not supported")
responseHeaders.remove("Content-Length")
responseHeaders.remove("Content-Range")
responseHeaders.remove("Accept-Ranges")
stream(
httpContext = httpContext,
resolver = resolver,
uri = uri,
statusCode = 200,
headers = responseHeaders,
start = null,
length = null
)
return
}
responseHeaders["Accept-Ranges"] = "bytes"
val rangeHeader = requestHeaders["Range"]
if (rangeHeader.isNullOrBlank()) {
responseHeaders["Content-Length"] = length.toString()
Logger.i(TAG, "Sending full content for $uri, length=$length")
stream(
httpContext = httpContext,
resolver = resolver,
uri = uri,
statusCode = 200,
headers = responseHeaders,
start = 0L,
length = length
)
return
}
val range = parseRange(rangeHeader, length)
if (range == null) {
Logger.w(TAG, "Invalid Range '$rangeHeader' for $uri (length=$length)")
responseHeaders["Content-Range"] = "bytes */$length"
httpContext.respondCode(416, responseHeaders)
return
}
val start = range.first
val endInclusive = range.last
val bytesToSend = endInclusive - start + 1
responseHeaders["Content-Range"] = "bytes $start-$endInclusive/$length"
responseHeaders["Content-Length"] = bytesToSend.toString()
Logger.i(TAG, "Sending range $start-$endInclusive (length=$bytesToSend) of $length for $uri")
stream(
httpContext = httpContext,
resolver = resolver,
uri = uri,
statusCode = 206,
headers = responseHeaders,
start = start,
length = bytesToSend
)
}
data class ContentMeta(
val displayName: String?,
val size: Long?,
val lastModifiedMillis: Long?
)
private fun queryMetadata(resolver: ContentResolver, uri: Uri): ContentMeta {
var displayName: String? = null
var size: Long? = null
var lastModifiedMillis: Long? = null
resolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1 && !cursor.isNull(nameIndex)) {
displayName = cursor.getString(nameIndex)
}
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
if (sizeIndex != -1 && !cursor.isNull(sizeIndex)) {
val s = cursor.getLong(sizeIndex)
if (s >= 0) size = s // -1 means unknown
}
val dateModifiedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED)
if (dateModifiedIndex != -1 && !cursor.isNull(dateModifiedIndex)) {
val seconds = cursor.getLong(dateModifiedIndex)
if (seconds > 0) {
lastModifiedMillis = seconds * 1000L
}
}
if (lastModifiedMillis == null) {
val dateAddedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED)
if (dateAddedIndex != -1 && !cursor.isNull(dateAddedIndex)) {
val seconds = cursor.getLong(dateAddedIndex)
if (seconds > 0) {
lastModifiedMillis = seconds * 1000L
}
}
}
}
}
if (displayName == null) {
displayName = uri.lastPathSegment
}
if (size == null) {
try {
resolver.openAssetFileDescriptor(uri, "r")?.use { afd ->
val assetLen = afd.length
if (assetLen >= 0) {
size = assetLen
}
}
} catch (_: Exception) { }
}
return ContentMeta(
displayName = displayName,
size = size,
lastModifiedMillis = lastModifiedMillis
)
}
private fun parseRange(header: String, totalLength: Long): LongRange? {
if (totalLength <= 0L) return null
val prefix = "bytes="
if (!header.startsWith(prefix, ignoreCase = true)) return null
val spec = header.substring(prefix.length).trim()
if (spec.isEmpty()) return null
if (spec.contains(",")) return null
val dashIndex = spec.indexOf('-')
if (dashIndex < 0) return null
val startPart = spec.substring(0, dashIndex).trim()
val endPart = spec.substring(dashIndex + 1).trim()
return when {
startPart.isNotEmpty() -> {
val start = startPart.toLongOrNull() ?: return null
if (start < 0 || start >= totalLength) return null
val end = if (endPart.isNotEmpty()) {
val rawEnd = endPart.toLongOrNull() ?: return null
if (rawEnd < start) return null
rawEnd.coerceAtMost(totalLength - 1)
} else {
totalLength - 1
}
start..end
}
endPart.isNotEmpty() -> {
val suffixLen = endPart.toLongOrNull() ?: return null
if (suffixLen <= 0L) return null
if (suffixLen >= totalLength) {
0L..(totalLength - 1)
} else {
val start = totalLength - suffixLen
val end = totalLength - 1
start..end
}
}
else -> null
}
}
private fun stream(httpContext: HttpContext, resolver: ContentResolver, uri: Uri, statusCode: Int, headers: HttpHeaders, start: Long?, length: Long?) {
try {
val input = resolver.openInputStream(uri)
if (input == null) {
Logger.w(TAG, "Content not found: $uri")
httpContext.respondCode(404, headers)
return
}
input.use { inputStream ->
httpContext.respond(statusCode, headers) { outputStream ->
try {
val offset = start ?: 0L
if (offset > 0L) {
skipFully(inputStream, offset)
}
copyStream(inputStream, outputStream, length)
outputStream.flush()
} catch (e: Exception) {
Logger.e(TAG, "Error while streaming $uri (start=$start, length=$length)", e)
}
}
}
} catch (e: FileNotFoundException) {
Logger.w(TAG, "Content not found: $uri", e)
httpContext.respondCode(404, headers)
} catch (e: Exception) {
Logger.e(TAG, "Failed to open stream for $uri", e)
httpContext.respondCode(500, headers)
}
}
private fun copyStream(input: InputStream, output: OutputStream, limit: Long?) {
val buffer = ByteArray(8192)
if (limit == null) {
while (true) {
val read = input.read(buffer)
if (read < 0) break
output.write(buffer, 0, read)
}
} else {
var remaining = limit
while (remaining > 0L) {
val toRead = remaining.coerceAtMost(buffer.size.toLong()).toInt()
val read = input.read(buffer, 0, toRead)
if (read < 0) break
output.write(buffer, 0, read)
remaining -= read.toLong()
}
}
}
private fun skipFully(input: InputStream, bytesToSkip: Long) {
var remaining = bytesToSkip
while (remaining > 0L) {
val skipped = input.skip(remaining)
if (skipped <= 0L) {
val b = input.read()
if (b == -1) break
remaining -= 1L
} else {
remaining -= skipped
}
}
}
companion object {
private const val TAG = "HttpContentUriHandler"
private val httpDateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("GMT")
}
}
}
@@ -5,6 +5,7 @@ import android.util.Log
import com.futo.platformplayer.api.http.server.HttpContext import com.futo.platformplayer.api.http.server.HttpContext
import com.futo.platformplayer.api.http.server.HttpHeaders import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.modifier.IRequest
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.parsers.HttpResponseParser import com.futo.platformplayer.parsers.HttpResponseParser
import com.futo.platformplayer.readLine import com.futo.platformplayer.readLine
@@ -27,6 +28,7 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
private var _injectReferer = false; private var _injectReferer = false;
private val _client = ManagedHttpClient(); private val _client = ManagedHttpClient();
private var _requestModifier: ((String, Map<String, String>) -> IRequest)? = null;
override fun handle(context: HttpContext) { override fun handle(context: HttpContext) {
if (useTcp) { if (useTcp) {
@@ -43,21 +45,33 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
for (injectHeader in _injectRequestHeader) for (injectHeader in _injectRequestHeader)
proxyHeaders[injectHeader.first] = injectHeader.second; proxyHeaders[injectHeader.first] = injectHeader.second;
val parsed = Uri.parse(targetUrl); val req = _requestModifier?.invoke(targetUrl, proxyHeaders)
var url = targetUrl
if (req != null) {
req.url?.let {
url = it
}
req.headers.let {
proxyHeaders.clear()
proxyHeaders.putAll(it)
}
}
val parsed = Uri.parse(url);
if(_injectHost) if(_injectHost)
proxyHeaders.put("Host", parsed.host!!); proxyHeaders.put("Host", parsed.host!!);
if(_injectReferer) if(_injectReferer)
proxyHeaders.put("Referer", targetUrl); proxyHeaders.put("Referer", url);
val useMethod = if (method == "inherit") context.method else method; val useMethod = if (method == "inherit") context.method else method;
Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${targetUrl}"); Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${url}");
Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n")); Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
val resp = when (useMethod) { val resp = when (useMethod) {
"GET" -> _client.get(targetUrl, proxyHeaders); "GET" -> _client.get(url, proxyHeaders);
"POST" -> _client.post(targetUrl, content ?: "", proxyHeaders); "POST" -> _client.post(url, content ?: "", proxyHeaders);
"HEAD" -> _client.head(targetUrl, proxyHeaders) "HEAD" -> _client.head(url, proxyHeaders)
else -> _client.requestMethod(useMethod, targetUrl, proxyHeaders); else -> _client.requestMethod(useMethod, url, proxyHeaders);
}; };
Logger.i(TAG, "Proxied Response [${resp.code}]"); Logger.i(TAG, "Proxied Response [${resp.code}]");
@@ -91,11 +105,23 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
for (injectHeader in _injectRequestHeader) for (injectHeader in _injectRequestHeader)
proxyHeaders[injectHeader.first] = injectHeader.second; proxyHeaders[injectHeader.first] = injectHeader.second;
val parsed = Uri.parse(targetUrl); val req = _requestModifier?.invoke(targetUrl, proxyHeaders)
var url = targetUrl
if (req != null) {
req.url?.let {
url = it
}
req.headers.let {
proxyHeaders.clear()
proxyHeaders.putAll(it)
}
}
val parsed = Uri.parse(url);
if(_injectHost) if(_injectHost)
proxyHeaders.put("Host", parsed.host!!); proxyHeaders.put("Host", parsed.host!!);
if(_injectReferer) if(_injectReferer)
proxyHeaders.put("Referer", targetUrl); proxyHeaders.put("Referer", url);
val useMethod = if (method == "inherit") context.method else method; val useMethod = if (method == "inherit") context.method else method;
Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${parsed}"); Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${parsed}");
@@ -242,6 +268,10 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
_ignoreRequestHeaders.add("referer"); _ignoreRequestHeaders.add("referer");
return this; return this;
} }
fun withRequestModifier(modifier: (String, Map<String, String>) -> IRequest) : HttpProxyHandler {
_requestModifier = modifier;
return this;
}
companion object { companion object {
private const val TAG = "HttpProxyHandler" private const val TAG = "HttpProxyHandler"
@@ -13,6 +13,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
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.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.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.models.ImageVariable import com.futo.platformplayer.models.ImageVariable
@@ -36,6 +37,11 @@ interface IPlatformClient {
*/ */
fun getHome(): IPager<IPlatformContent> fun getHome(): IPager<IPlatformContent>
/**
* Gets the shorts feed
*/
fun getShorts(): IPager<IPlatformVideo>
//Search //Search
/** /**
* Gets search suggestion for the provided query string * Gets search suggestion for the provided query string
@@ -176,6 +182,10 @@ interface IPlatformClient {
* Retrieves the subscriptions of the currently logged in user * Retrieves the subscriptions of the currently logged in user
*/ */
fun getUserSubscriptions(): Array<String>; fun getUserSubscriptions(): Array<String>;
/**
* Retrieves the history of the currently logged in user
*/
fun getUserHistory(): IPager<IPlatformContent>;
fun isClaimTypeSupported(claimType: Int): Boolean; fun isClaimTypeSupported(claimType: Int): Boolean;
@@ -11,6 +11,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
import com.futo.platformplayer.api.media.models.live.LiveEventComment import com.futo.platformplayer.api.media.models.live.LiveEventComment
import com.futo.platformplayer.api.media.models.live.LiveEventEmojis import com.futo.platformplayer.api.media.models.live.LiveEventEmojis
import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
import com.futo.platformplayer.api.media.platforms.js.models.JSVODEventPager
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.BatchedTaskHandler import com.futo.platformplayer.constructs.BatchedTaskHandler
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -26,12 +27,17 @@ class LiveChatManager {
private val _emojiCache: EmojiCache = EmojiCache(); private val _emojiCache: EmojiCache = EmojiCache();
private val _pager: IPager<IPlatformLiveEvent>?; private val _pager: IPager<IPlatformLiveEvent>?;
private var _position: Long = 0;
private var _eventsPosition: Long = 0;
private val _history: ArrayList<IPlatformLiveEvent> = arrayListOf(); private val _history: ArrayList<IPlatformLiveEvent> = arrayListOf();
private var _startCounter = 0; private var _startCounter = 0;
private val _followers: HashMap<Any, (List<IPlatformLiveEvent>) -> Unit> = hashMapOf(); private val _followers: HashMap<Any, (List<IPlatformLiveEvent>) -> Unit> = hashMapOf();
val isVOD get() = _pager is JSVODEventPager;
var viewCount: Long = 0 var viewCount: Long = 0
private set; private set;
@@ -39,8 +45,24 @@ class LiveChatManager {
_scope = scope; _scope = scope;
_pager = pager; _pager = pager;
viewCount = initialViewCount; viewCount = initialViewCount;
handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n"))); if(pager is JSVODEventPager)
handleEvents(pager.getResults()); handleEvents(listOf(LiveEventComment("SYSTEM", null, "VOD chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
else
handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
if(pager is JSVODEventPager) {
var replayResults = pager.getResults().filter { it.time > _eventsPosition || it is LiveEventEmojis };
//TODO: Remove this once dripfeed is done properly
replayResults = replayResults.filter{ it.time < _eventsPosition + 1500 || it is LiveEventEmojis };
if(replayResults.size > 0) {
_eventsPosition = replayResults.maxOf { it.time };
Logger.i(TAG, "VOD Events last event: " + _eventsPosition);
}
else
_eventsPosition = _eventsPosition + 1500;
}
else
handleEvents(pager.getResults());
} }
fun start() { fun start() {
@@ -52,6 +74,10 @@ class LiveChatManager {
_startCounter++; _startCounter++;
} }
fun setVideoPosition(ms: Long) {
_position = ms;
}
fun getHistory(): List<IPlatformLiveEvent> { fun getHistory(): List<IPlatformLiveEvent> {
synchronized(_history) { synchronized(_history) {
return _history.toList(); return _history.toList();
@@ -85,13 +111,34 @@ class LiveChatManager {
try { try {
while(_startCounter == counter) { while(_startCounter == counter) {
var nextInterval = 1000L; var nextInterval = 1000L;
if(_pager is JSVODEventPager && _eventsPosition > _position) {
delay(500);
continue;
}
try { try {
if(_pager == null || !_pager.hasMorePages()) if(_pager == null || !_pager.hasMorePages())
return@launch; return@launch;
_pager.nextPage(); val newEvents = if(_pager is JSVODEventPager) {
val newEvents = _pager.getResults(); val requestPosition = _position;
_pager.nextPage(requestPosition.toInt());
var replayResults = _pager.getResults().filter { it.time > requestPosition || it is LiveEventEmojis };
if(replayResults.size > 0) {
_eventsPosition = replayResults.maxOf { it.time };
Logger.i(TAG, "VOD Events last event: " + _eventsPosition);
}
else
_eventsPosition = requestPosition + _pager.nextRequest.coerceAtLeast(800).toLong();
replayResults;
}
else {
_pager.nextPage();
_pager.getResults();
}
if(_pager is JSLiveEventPager) if(_pager is JSLiveEventPager)
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong(); nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
else if(_pager is JSVODEventPager)
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
if(newEvents.size > 0) if(newEvents.size > 0)
Logger.i(TAG, "New Live Events (${newEvents.size}) [${newEvents.map { it.type.name }.joinToString(", ")}]"); Logger.i(TAG, "New Live Events (${newEvents.size}) [${newEvents.map { it.type.name }.joinToString(", ")}]");
@@ -20,7 +20,8 @@ data class PlatformClientCapabilities(
val hasGetContentChapters: Boolean = false, val hasGetContentChapters: Boolean = false,
val hasPeekChannelContents: Boolean = false, val hasPeekChannelContents: Boolean = false,
val hasGetChannelPlaylists: Boolean = false, val hasGetChannelPlaylists: Boolean = false,
val hasGetContentRecommendations: Boolean = false val hasGetContentRecommendations: Boolean = false,
val hasGetUserHistory: Boolean = false
) { ) {
} }
@@ -34,8 +34,10 @@ class PlatformClientPool {
isDead = true; isDead = true;
onDead.emit(parentClient, this); onDead.emit(parentClient, this);
for(clientPair in _pool) { synchronized(_pool) {
clientPair.key.disable(); for (clientPair in _pool) {
clientPair.key.disable();
}
} }
}; };
} }
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.ensureIsBusy
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullable import com.futo.platformplayer.getOrThrowNullable
@@ -44,6 +45,7 @@ class PlatformID {
val NONE = PlatformID("Unknown", null); val NONE = PlatformID("Unknown", null);
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformID { fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformID {
value.ensureIsBusy();
val contextName = "PlatformID"; val contextName = "PlatformID";
return PlatformID( return PlatformID(
value.getOrThrow(config, "platform", contextName), value.getOrThrow(config, "platform", contextName),
@@ -6,6 +6,7 @@ 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.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.models.JSContent import com.futo.platformplayer.api.media.platforms.js.models.JSContent
import com.futo.platformplayer.ensureIsBusy
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
@@ -33,6 +34,7 @@ open class PlatformAuthorLink {
val UNKNOWN = PlatformAuthorLink(PlatformID.NONE, "Unknown", "", null, null); val UNKNOWN = PlatformAuthorLink(PlatformID.NONE, "Unknown", "", null, null);
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorLink { fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorLink {
value.ensureIsBusy();
if(value.has("membershipUrl")) if(value.has("membershipUrl"))
return PlatformAuthorMembershipLink.fromV8(config, value); return PlatformAuthorMembershipLink.fromV8(config, value);
@@ -52,14 +54,16 @@ interface IPlatformChannelContent : IPlatformContent {
val subscribers: Long? val subscribers: Long?
} }
open class JSChannelContent : JSContent, IPlatformChannelContent { open class JSChannelContent(
override val contentType: ContentType get() = ContentType.CHANNEL config: SourcePluginConfig,
override val thumbnail: String? obj: V8ValueObject
override val subscribers: Long? ) : JSContent(config, obj), IPlatformChannelContent {
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) { final override val contentType: ContentType = ContentType.CHANNEL
val contextName = "Channel";
thumbnail = obj.getOrDefault<String>(config, "thumbnail", contextName, null) override val thumbnail: String? =
subscribers = if(obj.has("subscribers")) obj.getOrThrow(config,"subscribers", contextName) else null _content.getOrDefault<String>(_pluginConfig, "thumbnail", "Channel", null)
}
} override val subscribers: Long? =
_content.getOrDefault<Long>(_pluginConfig, "subscribers", "Channel", null)?.toLong()
}
@@ -3,6 +3,7 @@ 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.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.ensureIsBusy
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
@@ -20,6 +21,7 @@ class PlatformAuthorMembershipLink: PlatformAuthorLink {
companion object { companion object {
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorMembershipLink { fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorMembershipLink {
value.ensureIsBusy();
val context = "AuthorMembershipLink" val context = "AuthorMembershipLink"
return PlatformAuthorMembershipLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)), return PlatformAuthorMembershipLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)),
value.getOrThrow(config ,"name", context), value.getOrThrow(config ,"name", context),
@@ -5,6 +5,7 @@ import com.caoccao.javet.values.primitive.V8ValueInteger
import com.caoccao.javet.values.reference.V8ValueArray import com.caoccao.javet.values.reference.V8ValueArray
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.ensureIsBusy
import com.futo.platformplayer.expectV8Variant import com.futo.platformplayer.expectV8Variant
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
@@ -46,6 +47,7 @@ class ResultCapabilities(
fun fromV8(config: IV8PluginConfig, value: V8ValueObject): ResultCapabilities { fun fromV8(config: IV8PluginConfig, value: V8ValueObject): ResultCapabilities {
val contextName = "ResultCapabilities"; val contextName = "ResultCapabilities";
value.ensureIsBusy();
return ResultCapabilities( return ResultCapabilities(
value.getOrThrow<V8ValueArray>(config, "types", contextName).toArray().map { it.expectV8Variant(config, "Capabilities.types") }, value.getOrThrow<V8ValueArray>(config, "types", contextName).toArray().map { it.expectV8Variant(config, "Capabilities.types") },
value.getOrThrow<V8ValueArray>(config, "sorts", contextName).toArray().map { it.expectV8Variant(config, "Capabilities.sorts"); }, value.getOrThrow<V8ValueArray>(config, "sorts", contextName).toArray().map { it.expectV8Variant(config, "Capabilities.sorts"); },
@@ -69,6 +71,7 @@ class FilterGroup(
companion object { companion object {
fun fromV8(config: IV8PluginConfig, value: V8ValueObject): FilterGroup { fun fromV8(config: IV8PluginConfig, value: V8ValueObject): FilterGroup {
value.ensureIsBusy();
return FilterGroup( return FilterGroup(
value.getString("name"), value.getString("name"),
value.getOrDefault<V8ValueArray>(config, "filters", "FilterGroup", null) value.getOrDefault<V8ValueArray>(config, "filters", "FilterGroup", null)
@@ -90,6 +93,7 @@ class FilterCapability(
companion object { companion object {
fun fromV8(obj: V8ValueObject): FilterCapability { fun fromV8(obj: V8ValueObject): FilterCapability {
obj.ensureIsBusy();
val value = obj.get("value") as V8Value; val value = obj.get("value") as V8Value;
return FilterCapability( return FilterCapability(
obj.getString("name"), obj.getString("name"),
@@ -4,6 +4,7 @@ import com.caoccao.javet.values.reference.V8ValueArray
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8PluginConfig import com.futo.platformplayer.engine.V8PluginConfig
import com.futo.platformplayer.ensureIsBusy
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
@@ -31,6 +32,7 @@ class Thumbnails {
companion object { companion object {
fun fromV8(config: IV8PluginConfig, value: V8ValueObject): Thumbnails { fun fromV8(config: IV8PluginConfig, value: V8ValueObject): Thumbnails {
value.ensureIsBusy();
return Thumbnails((value.getOrThrow<V8ValueArray>(config, "sources", "Thumbnails")) return Thumbnails((value.getOrThrow<V8ValueArray>(config, "sources", "Thumbnails"))
.toArray() .toArray()
.map { Thumbnail.fromV8(config, it as V8ValueObject) } .map { Thumbnail.fromV8(config, it as V8ValueObject) }
@@ -6,25 +6,15 @@ import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import java.time.OffsetDateTime import java.time.OffsetDateTime
open class PlatformComment : IPlatformComment { open class PlatformComment(
override val contextUrl: String; override val contextUrl: String,
override val author: PlatformAuthorLink; override val author: PlatformAuthorLink,
override val message: String; override val message: String,
override val rating: IRating; override val rating: IRating,
override val date: OffsetDateTime; override val date: OffsetDateTime,
override val replyCount: Int? = null
) : IPlatformComment {
override val replyCount: Int?; override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> =
NoCommentsPager()
constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, replyCount: Int? = null) { }
this.contextUrl = contextUrl;
this.author = author;
this.message = msg;
this.rating = rating;
this.date = date;
this.replyCount = replyCount;
}
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> {
return NoCommentsPager();
}
}
@@ -2,14 +2,17 @@ package com.futo.platformplayer.api.media.models.live
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.ensureIsBusy
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
interface IPlatformLiveEvent { interface IPlatformLiveEvent {
val type : LiveEventType; val type : LiveEventType;
var time: Long;
companion object { companion object {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "LiveEvent") : IPlatformLiveEvent { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "LiveEvent") : IPlatformLiveEvent {
obj.ensureIsBusy();
val t = LiveEventType.fromInt(obj.getOrThrow<Int>(config, "type", contextName)); val t = LiveEventType.fromInt(obj.getOrThrow<Int>(config, "type", contextName));
return when(t) { return when(t) {
LiveEventType.COMMENT -> LiveEventComment.fromV8(config, obj); LiveEventType.COMMENT -> LiveEventComment.fromV8(config, obj);
@@ -4,6 +4,7 @@ import com.caoccao.javet.values.reference.V8ValueArray
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.ratings.RatingLikes import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.ensureIsBusy
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
@@ -17,16 +18,21 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage {
val colorName: String?; val colorName: String?;
val badges: List<String>; val badges: List<String>;
constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List<String>? = null) { override var time: Long = -1;
constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List<String>? = null, time: Long = -1) {
this.name = name; this.name = name;
this.message = message; this.message = message;
this.thumbnail = thumbnail; this.thumbnail = thumbnail;
this.colorName = colorName; this.colorName = colorName;
this.badges = badges ?: listOf(); this.badges = badges ?: listOf();
this.time = time;
} }
companion object { companion object {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventComment { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventComment {
obj.ensureIsBusy();
val contextName = "LiveEventComment" val contextName = "LiveEventComment"
val colorName = obj.getOrDefault<String>(config, "colorName", contextName, null); val colorName = obj.getOrDefault<String>(config, "colorName", contextName, null);
@@ -36,7 +42,8 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage {
obj.getOrThrow(config, "name", contextName), obj.getOrThrow(config, "name", contextName),
obj.getOrThrow(config, "thumbnail", contextName, true), obj.getOrThrow(config, "thumbnail", contextName, true),
obj.getOrThrow(config, "message", contextName), obj.getOrThrow(config, "message", contextName),
colorName, badges); colorName, badges,
obj.getOrDefault(config, "time", contextName, -1) ?: -1);
} }
} }
} }
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.models.live
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.ratings.RatingLikes import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.ensureIsBusy
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
@@ -20,6 +21,8 @@ class LiveEventDonation: IPlatformLiveEvent, ILiveEventChatMessage {
var expire: Int = 6000; var expire: Int = 6000;
override var time: Long = -1;
constructor(name: String, thumbnail: String?, message: String, amount: String, expire: Int = 6000, colorDonation: String? = null) { constructor(name: String, thumbnail: String?, message: String, amount: String, expire: Int = 6000, colorDonation: String? = null) {
this.name = name; this.name = name;
@@ -37,6 +40,7 @@ class LiveEventDonation: IPlatformLiveEvent, ILiveEventChatMessage {
companion object { companion object {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventDonation { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventDonation {
obj.ensureIsBusy();
val contextName = "LiveEventDonation" val contextName = "LiveEventDonation"
return LiveEventDonation( return LiveEventDonation(
obj.getOrThrow(config, "name", contextName), obj.getOrThrow(config, "name", contextName),
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.live
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.ensureIsBusy
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
class LiveEventEmojis: IPlatformLiveEvent { class LiveEventEmojis: IPlatformLiveEvent {
@@ -9,15 +10,17 @@ class LiveEventEmojis: IPlatformLiveEvent {
val emojis: HashMap<String, String>; val emojis: HashMap<String, String>;
override var time: Long = -1;
constructor(emojis: HashMap<String, String>) { constructor(emojis: HashMap<String, String>) {
this.emojis = emojis; this.emojis = emojis;
} }
companion object { companion object {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventEmojis { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventEmojis {
obj.ensureIsBusy();
val contextName = "LiveEventEmojis" val contextName = "LiveEventEmojis"
return LiveEventEmojis( return LiveEventEmojis(obj.getOrThrow(config, "emojis", contextName));
obj.getOrThrow(config, "emojis", contextName));
} }
} }
} }
@@ -2,6 +2,8 @@ package com.futo.platformplayer.api.media.models.live
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.ensureIsBusy
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
class LiveEventRaid: IPlatformLiveEvent { class LiveEventRaid: IPlatformLiveEvent {
@@ -10,20 +12,26 @@ class LiveEventRaid: IPlatformLiveEvent {
val targetName: String; val targetName: String;
val targetThumbnail: String; val targetThumbnail: String;
val targetUrl: String; val targetUrl: String;
val isOutgoing: Boolean;
constructor(name: String, url: String, thumbnail: String) { override var time: Long = -1;
constructor(name: String, url: String, thumbnail: String, isOutgoing: Boolean) {
this.targetName = name; this.targetName = name;
this.targetUrl = url; this.targetUrl = url;
this.targetThumbnail = thumbnail; this.targetThumbnail = thumbnail;
this.isOutgoing = isOutgoing;
} }
companion object { companion object {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventRaid { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventRaid {
obj.ensureIsBusy();
val contextName = "LiveEventRaid" val contextName = "LiveEventRaid"
return LiveEventRaid( return LiveEventRaid(
obj.getOrThrow(config, "targetName", contextName), obj.getOrThrow(config, "targetName", contextName),
obj.getOrThrow(config, "targetUrl", contextName), obj.getOrThrow(config, "targetUrl", contextName),
obj.getOrThrow(config, "targetThumbnail", contextName)); obj.getOrThrow(config, "targetThumbnail", contextName),
obj.getOrDefault<Boolean>(config, "isOutgoing", contextName, true) ?: true);
} }
} }
} }
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.live
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.ensureIsBusy
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
class LiveEventViewCount: IPlatformLiveEvent { class LiveEventViewCount: IPlatformLiveEvent {
@@ -9,12 +10,15 @@ class LiveEventViewCount: IPlatformLiveEvent {
val viewCount: Int; val viewCount: Int;
override var time: Long = -1;
constructor(viewCount: Int) { constructor(viewCount: Int) {
this.viewCount = viewCount; this.viewCount = viewCount;
} }
companion object { companion object {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventViewCount { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventViewCount {
obj.ensureIsBusy();
val contextName = "LiveEventViewCount" val contextName = "LiveEventViewCount"
return LiveEventViewCount( return LiveEventViewCount(
obj.getOrThrow(config, "viewCount", contextName)); obj.getOrThrow(config, "viewCount", contextName));
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.models.ratings
import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.ensureIsBusy
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.orDefault import com.futo.platformplayer.orDefault
import com.futo.platformplayer.serializers.IRatingSerializer import com.futo.platformplayer.serializers.IRatingSerializer
@@ -13,8 +14,12 @@ interface IRating {
companion object { companion object {
fun fromV8OrDefault(config: IV8PluginConfig, obj: V8Value?, default: IRating) = obj.orDefault(default) { fromV8(config, it as V8ValueObject) }; fun fromV8OrDefault(config: IV8PluginConfig, obj: V8Value?, default: IRating): IRating {
obj?.ensureIsBusy();
return obj.orDefault(default) { fromV8(config, it as V8ValueObject) }
};
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "Rating") : IRating { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "Rating") : IRating {
obj.ensureIsBusy();
val t = RatingType.fromInt(obj.getOrThrow<Int>(config, "type", contextName)); val t = RatingType.fromInt(obj.getOrThrow<Int>(config, "type", contextName));
return when(t) { return when(t) {
RatingType.LIKES -> RatingLikes.fromV8(config, obj); RatingType.LIKES -> RatingLikes.fromV8(config, obj);
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.ratings
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.ensureIsBusy
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
/** /**
@@ -14,6 +15,7 @@ class RatingLikeDislikes(val likes: Long, val dislikes: Long) : IRating {
companion object { companion object {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingLikeDislikes { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingLikeDislikes {
obj.ensureIsBusy();
return RatingLikeDislikes(obj.getOrThrow(config, "likes", "RatingLikeDislikes"), obj.getOrThrow(config, "dislikes", "RatingLikeDislikes")); return RatingLikeDislikes(obj.getOrThrow(config, "likes", "RatingLikeDislikes"), obj.getOrThrow(config, "dislikes", "RatingLikeDislikes"));
} }
} }
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.ratings
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.ensureIsBusy
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
/** /**
@@ -13,6 +14,7 @@ class RatingLikes(val likes: Long) : IRating {
companion object { companion object {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingLikes { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingLikes {
obj.ensureIsBusy();
return RatingLikes(obj.getOrThrow(config, "likes", "RatingLikes")); return RatingLikes(obj.getOrThrow(config, "likes", "RatingLikes"));
} }
} }
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.ratings
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.ensureIsBusy
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
/** /**
@@ -13,6 +14,7 @@ class RatingScaler(val value: Float) : IRating {
companion object { companion object {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingScaler { fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingScaler {
obj.ensureIsBusy()
return RatingScaler(obj.getOrThrow(config, "value", "RatingScaler")); return RatingScaler(obj.getOrThrow(config, "value", "RatingScaler"));
} }
} }
@@ -2,10 +2,24 @@ package com.futo.platformplayer.api.media.models.streams
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
import com.futo.platformplayer.downloads.VideoLocal import com.futo.platformplayer.downloads.VideoLocal
class LocalVideoUnMuxedSourceDescriptor(private val video: VideoLocal) : VideoUnMuxedSourceDescriptor() { class LocalVideoUnMuxedSourceDescriptor : VideoUnMuxedSourceDescriptor {
override val videoSources: Array<IVideoSource> get() = video.videoSource.toTypedArray(); override val videoSources: Array<IVideoSource>;
override val audioSources: Array<IAudioSource> get() = video.audioSource.toTypedArray(); override val audioSources: Array<IAudioSource>;
constructor(video: VideoLocal) {
videoSources = video.videoSource.toTypedArray();
audioSources = video.audioSource.toTypedArray();
}
constructor(audio: LocalAudioContentSource) {
videoSources = arrayOf()
audioSources = arrayOf(audio);
}
constructor(videoSources: Array<IVideoSource>, audioSources: Array<IAudioSource>) {
this.videoSources = videoSources;
this.audioSources = audioSources;
}
} }
@@ -14,7 +14,8 @@ class AudioUrlSource(
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 override var original: Boolean = false,
var isLocal: Boolean = false
) : IAudioUrlSource, IStreamMetaDataSource{ ) : IAudioUrlSource, IStreamMetaDataSource{
override var streamMetaData: StreamMetaData? = null; override var streamMetaData: StreamMetaData? = null;
@@ -12,6 +12,9 @@ class DashManifestSource : IVideoSource, IDashManifestSource {
override var priority: Boolean = false; override var priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
constructor(url : String) { constructor(url : String) {
this.url = url; this.url = url;
} }
@@ -12,6 +12,9 @@ class HLSManifestSource : IVideoSource, IHLSManifestSource {
override var priority: Boolean = false; override var priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
constructor(url : String) { constructor(url : String) {
this.url = url; this.url = url;
} }
@@ -14,6 +14,9 @@ class HLSVariantVideoUrlSource(
override val priority: Boolean, override val priority: Boolean,
val url: String val url: String
) : IVideoUrlSource { ) : IVideoUrlSource {
override val language: String? = null;
override val original: Boolean? = false;
override fun getVideoUrl(): String { override fun getVideoUrl(): String {
return url return url
} }
@@ -41,6 +44,7 @@ class HLSVariantSubtitleUrlSource(
override val format: String, override val format: String,
) : ISubtitleSource { ) : ISubtitleSource {
override val hasFetch: Boolean = false override val hasFetch: Boolean = false
override val language: String? = null
override fun getSubtitles(): String? { override fun getSubtitles(): String? {
return null return null
@@ -9,4 +9,6 @@ interface IVideoSource {
val bitrate : Int?; val bitrate : Int?;
val duration: Long; val duration: Long;
val priority: Boolean; val priority: Boolean;
val language: String?;
val original: Boolean?;
} }
@@ -9,13 +9,15 @@ class LocalSubtitleSource : ISubtitleSource {
override val name: String; override val name: String;
override val url: String?; override val url: String?;
override val format: String?; override val format: String?;
override val language: String?
override val hasFetch: Boolean get() = false; override val hasFetch: Boolean get() = false;
val filePath: String; val filePath: String;
constructor(name: String, format: String?, filePath: String) { constructor(name: String, language: String?, format: String?, filePath: String) {
this.name = name; this.name = name;
this.format = format; this.format = format;
this.language = language
this.filePath = filePath; this.filePath = filePath;
this.url = Uri.fromFile(File(filePath)).toString(); this.url = Uri.fromFile(File(filePath)).toString();
} }
@@ -32,6 +34,7 @@ class LocalSubtitleSource : ISubtitleSource {
fun fromSource(source: SubtitleRawSource, path: String): LocalSubtitleSource { fun fromSource(source: SubtitleRawSource, path: String): LocalSubtitleSource {
return LocalSubtitleSource( return LocalSubtitleSource(
source.name, source.name,
source.language,
source.format, source.format,
path path
); );
@@ -16,6 +16,10 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
override var priority: Boolean = false; override var priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
val filePath : String; val filePath : String;
val fileSize : Long; val fileSize : Long;
@@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
class SubtitleRawSource( class SubtitleRawSource(
override val name: String, override val name: String,
override val language: String?,
override val format: String?, override val format: String?,
val _subtitles: String, val _subtitles: String,
override val url: String? = null, override val url: String? = null,
@@ -14,10 +14,14 @@ open class VideoUrlSource(
override val codec : String = "", override val codec : String = "",
override val bitrate : Int? = 0, override val bitrate : Int? = 0,
override var priority: Boolean = false override var priority: Boolean = false,
var isLocal: Boolean = false
) : IVideoUrlSource, IStreamMetaDataSource { ) : IVideoUrlSource, IStreamMetaDataSource {
override var streamMetaData: StreamMetaData? = null; override var streamMetaData: StreamMetaData? = null;
override val language: String? = null;
override val original: Boolean? = false;
override fun getVideoUrl() : String { override fun getVideoUrl() : String {
return url; return url;
} }
@@ -7,6 +7,7 @@ interface ISubtitleSource {
val url: String?; val url: String?;
val format: String?; val format: String?;
val hasFetch: Boolean; val hasFetch: Boolean;
val language: String?
fun getSubtitles(): String?; fun getSubtitles(): String?;
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.video
import com.futo.platformplayer.api.media.models.Thumbnails 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.IPlatformContent
import java.time.OffsetDateTime
/** /**
* A search result representing a video (overview data) * A search result representing a video (overview data)
@@ -12,6 +13,9 @@ interface IPlatformVideo : IPlatformContent {
val duration: Long; val duration: Long;
val viewCount: Long; val viewCount: Long;
val playbackTime: Long;
val playbackDate: OffsetDateTime?;
val isLive : Boolean; val isLive : Boolean;
val isShort: Boolean; val isShort: Boolean;
@@ -0,0 +1,122 @@
package com.futo.platformplayer.api.media.models.video
import android.annotation.SuppressLint
import android.net.Uri
import android.provider.MediaStore
import android.provider.OpenableColumns
import androidx.core.net.toUri
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.Serializer
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.LocalVideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource
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.streams.sources.LocalAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.SubtitleRawSource
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
import com.futo.platformplayer.api.media.platforms.local.models.LocalVideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.others.Language
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.states.StateApp
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.OffsetDateTime
@kotlinx.serialization.Serializable
open class LocalVideoDetails(
override val id: PlatformID,
override val name: String,
override val thumbnails: Thumbnails,
override val author: PlatformAuthorLink,
override val url: String,
override val duration: Long,
val mimeType: String? = null,
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override val datetime: OffsetDateTime?
) : IPlatformVideo, IPlatformVideoDetails {
final override val contentType: ContentType get() = ContentType.MEDIA;
override var playbackTime: Long = -1;
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override var playbackDate: OffsetDateTime? = null;
override val isLive: Boolean get() = false;
override val dash: IDashManifestSource? get() = null;
override val hls: IHLSManifestSource? get() = null;
override val live: IVideoSource? get() = null;
override val shareUrl: String = ""
override val viewCount: Long = -1
override val rating: IRating = RatingLikes(0)
override val description: String = "";
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
(LocalVideoUnMuxedSourceDescriptor(
arrayOf(),
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name, duration))
))
else (LocalVideoMuxedSourceDescriptor(
LocalVideoContentSource(url, mimeType ?: "", name, duration)
))
);
override val preview: ISerializedVideoSourceDescriptor? = null;
override val subtitles: List<SubtitleRawSource> = listOf()
override val isShort: Boolean = false
fun toJson() : String {
return Json.encodeToString(this);
}
fun fromJson(str : String) : SerializedPlatformVideoDetails {
return Serializer.json.decodeFromString<SerializedPlatformVideoDetails>(str);
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null;
override fun getPlaybackTracker(): IPlaybackTracker? = null;
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? = null;
companion object {
fun fromFile(name: String, filePath: String, mimeType: String? = null) : LocalVideoDetails {
if(filePath.startsWith("content://"))
return fromContent(filePath, mimeType);
return LocalVideoDetails(PlatformID("FILE", filePath, null, 0, -1),
name, Thumbnails(), PlatformAuthorLink.UNKNOWN, filePath, -1, mimeType, null);
}
fun fromContent(contentUrl: String, mimeType: String? = null) : LocalVideoDetails {
var nameToUse = getFileNameFromContentUrl(contentUrl) ?: "File";
return LocalVideoDetails(PlatformID("FILE", contentUrl, null, 0, -1),
nameToUse, Thumbnails(), PlatformAuthorLink.UNKNOWN, contentUrl, -1, mimeType, null);
}
@SuppressLint("Range")
private fun getFileNameFromContentUrl(url: String): String? {
val cursor = StateApp.instance.context.contentResolver.query(url.toUri(), null, null, null, null);
cursor?.moveToFirst();
val fileName = cursor?.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
cursor?.close();
return fileName;
}
}
}
@@ -3,11 +3,10 @@ package com.futo.platformplayer.api.media.models.video
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnail
import com.futo.platformplayer.api.media.models.Thumbnails import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.polycentric.core.combineHashCodes
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 kotlinx.serialization.json.JsonNames
@@ -18,7 +17,7 @@ open class SerializedPlatformVideo(
override val contentType: ContentType = ContentType.MEDIA, 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 = Thumbnails(),
override val author: PlatformAuthorLink, override val author: PlatformAuthorLink,
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
@JsonNames("datetime", "dateTime") @JsonNames("datetime", "dateTime")
@@ -33,6 +32,10 @@ open class SerializedPlatformVideo(
override val isLive: Boolean = false; override val isLive: Boolean = false;
override var playbackTime: Long = -1;
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override var playbackDate: OffsetDateTime? = null;
override fun toJson() : String { override fun toJson() : String {
return Json.encodeToString(this); return Json.encodeToString(this);
} }
@@ -13,7 +13,6 @@ import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.streams.sources.* import com.futo.platformplayer.api.media.models.streams.sources.*
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.time.OffsetDateTime import java.time.OffsetDateTime
@@ -43,6 +42,10 @@ open class SerializedPlatformVideoDetails(
) : IPlatformVideo, IPlatformVideoDetails { ) : IPlatformVideo, IPlatformVideoDetails {
final override val contentType: ContentType get() = ContentType.MEDIA; final override val contentType: ContentType get() = ContentType.MEDIA;
override var playbackTime: Long = -1;
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override var playbackDate: OffsetDateTime? = null;
override val isLive: Boolean get() = false; override val isLive: Boolean get() = false;
override val dash: IDashManifestSource? get() = null; override val dash: IDashManifestSource? get() = null;
@@ -56,6 +56,7 @@ class DevJSClient : JSClient {
override fun getCopy(privateCopy: Boolean, noSaveState: Boolean): JSClient { override fun getCopy(privateCopy: Boolean, noSaveState: Boolean): JSClient {
val client = DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, if (noSaveState) null else saveState(), devID); val client = DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, if (noSaveState) null else saveState(), devID);
client.setReloadData(getReloadData(true));
if (noSaveState) if (noSaveState)
client.initialize() client.initialize()
return client return client
@@ -23,6 +23,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
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.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.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.internal.JSCallDocs import com.futo.platformplayer.api.media.platforms.js.internal.JSCallDocs
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocsParameter import com.futo.platformplayer.api.media.platforms.js.internal.JSDocsParameter
@@ -43,6 +44,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaybackTracker import com.futo.platformplayer.api.media.platforms.js.models.JSPlaybackTracker
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistDetails import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistDetails
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistPager import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistPager
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoPager
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.constructs.Event1 import com.futo.platformplayer.constructs.Event1
@@ -53,15 +55,20 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptException import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.engine.exceptions.ScriptValidationException import com.futo.platformplayer.engine.exceptions.ScriptValidationException
import com.futo.platformplayer.engine.packages.PackageBridge
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable import com.futo.platformplayer.models.ImageVariable
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.platformplayer.states.StatePlugins import com.futo.platformplayer.states.StatePlugins
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.Random
import kotlin.Exception import kotlin.Exception
import kotlin.reflect.full.findAnnotations import kotlin.reflect.full.findAnnotations
import kotlin.reflect.jvm.kotlinFunction import kotlin.reflect.jvm.kotlinFunction
@@ -83,6 +90,8 @@ open class JSClient : IPlatformClient {
private var _channelCapabilities: ResultCapabilities? = null; private var _channelCapabilities: ResultCapabilities? = null;
private var _peekChannelTypes: List<String>? = null; private var _peekChannelTypes: List<String>? = null;
private var _usedReloadData: String? = null;
protected val _script: String; protected val _script: String;
private var _initialized: Boolean = false; private var _initialized: Boolean = false;
@@ -95,17 +104,17 @@ open class JSClient : IPlatformClient {
override val id: String get() = config.id; override val id: String get() = config.id;
override val name: String get() = config.name; override val name: String get() = config.name;
override val icon: ImageVariable; override val icon: ImageVariable get() = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null)
override var capabilities: PlatformClientCapabilities = PlatformClientCapabilities(); override var capabilities: PlatformClientCapabilities = PlatformClientCapabilities();
private val _busyLock = Object();
private var _busyCounter = 0;
private var _busyAction = ""; private var _busyAction = "";
val isBusy: Boolean get() = _busyCounter > 0; val isBusy: Boolean get() = _plugin.isBusy;
val isBusyAction: String get() { val isBusyAction: String get() {
return _busyAction; return _busyAction;
} }
val declareOnEnable = HashMap<String, String>();
val settings: HashMap<String, String?> get() = descriptor.settings; val settings: HashMap<String, String?> get() = descriptor.settings;
val flags: Array<String>; val flags: Array<String>;
@@ -118,6 +127,7 @@ open class JSClient : IPlatformClient {
val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true
val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true
val enableInShorts get() = descriptor.appSettings.tabEnabled.enableShorts ?: true
fun getSubscriptionRateLimit(): Int? { fun getSubscriptionRateLimit(): Int? {
val pluginRateLimit = config.subscriptionRateLimit; val pluginRateLimit = config.subscriptionRateLimit;
@@ -138,16 +148,16 @@ open class JSClient : IPlatformClient {
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String? = null) { constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String? = null) {
this._context = context; this._context = context;
this.config = descriptor.config; this.config = descriptor.config;
icon = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null);
this.descriptor = descriptor; this.descriptor = descriptor;
_injectedSaveState = saveState; _injectedSaveState = saveState;
_auth = descriptor.getAuth(); _auth = descriptor.getAuth();
_captcha = descriptor.getCaptchaData(); _captcha = descriptor.getCaptchaData();
flags = descriptor.flags.toTypedArray(); flags = descriptor.flags.toTypedArray();
_httpClient = JSHttpClient(this, null, _captcha); _httpClient = JSHttpClient(this, null, _captcha, config);
_httpClientAuth = JSHttpClient(this, _auth, _captcha); _httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth); _plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
_plugin.bridge.descriptor = descriptor;
_plugin.withDependency(context, "scripts/polyfil.js"); _plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js"); _plugin.withDependency(context, "scripts/source.js");
@@ -169,7 +179,6 @@ open class JSClient : IPlatformClient {
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String, withoutCredentials: Boolean = false) { constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String, withoutCredentials: Boolean = false) {
this._context = context; this._context = context;
this.config = descriptor.config; this.config = descriptor.config;
icon = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null);
this.descriptor = descriptor; this.descriptor = descriptor;
_injectedSaveState = saveState; _injectedSaveState = saveState;
if(!withoutCredentials) if(!withoutCredentials)
@@ -179,9 +188,10 @@ open class JSClient : IPlatformClient {
_captcha = descriptor.getCaptchaData(); _captcha = descriptor.getCaptchaData();
flags = descriptor.flags.toTypedArray(); flags = descriptor.flags.toTypedArray();
_httpClient = JSHttpClient(this, null, _captcha); _httpClient = JSHttpClient(this, null, _captcha, config);
_httpClientAuth = JSHttpClient(this, _auth, _captcha); _httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth); _plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
_plugin.bridge.descriptor = descriptor;
_plugin.withDependency(context, "scripts/polyfil.js"); _plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js"); _plugin.withDependency(context, "scripts/source.js");
_plugin.withScript(script); _plugin.withScript(script);
@@ -197,6 +207,7 @@ open class JSClient : IPlatformClient {
open fun getCopy(withoutCredentials: Boolean = false, noSaveState: Boolean = false): JSClient { open fun getCopy(withoutCredentials: Boolean = false, noSaveState: Boolean = false): JSClient {
val client = JSClient(_context, descriptor, if (noSaveState) null else saveState(), _script, withoutCredentials); val client = JSClient(_context, descriptor, if (noSaveState) null else saveState(), _script, withoutCredentials);
client.setReloadData(getReloadData(true));
if (noSaveState) if (noSaveState)
client.initialize() client.initialize()
return client return client
@@ -213,14 +224,31 @@ open class JSClient : IPlatformClient {
return plugin.httpClientOthers[id]; return plugin.httpClientOthers[id];
} }
fun setReloadData(data: String?) {
if(data == null) {
if(declareOnEnable.containsKey("__reloadData"))
declareOnEnable.remove("__reloadData");
}
else
declareOnEnable.put("__reloadData", data ?: "");
}
fun getReloadData(orLast: Boolean): String? {
if(declareOnEnable.containsKey("__reloadData"))
return declareOnEnable["__reloadData"];
else if(orLast)
return _usedReloadData;
return null;
}
override fun initialize() { override fun initialize() {
if (_initialized) return if (_initialized) return
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)}");
plugin.execute("plugin.settings = parseSettings(${Json.encodeToString(descriptor.getSettingsWithDefaults())})"); plugin.execute("plugin.settings = parseSettings(${Json.encodeToString(descriptor.getSettingsWithDefaults())})");
descriptor.appSettings.loadDefaults(descriptor.config); descriptor.appSettings.loadDefaults(descriptor.config);
_initialized = true; _initialized = true;
@@ -245,7 +273,8 @@ open class JSClient : IPlatformClient {
hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false, hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false,
hasPeekChannelContents = plugin.executeBoolean("!!source.peekChannelContents") ?: false, hasPeekChannelContents = plugin.executeBoolean("!!source.peekChannelContents") ?: false,
hasGetChannelPlaylists = plugin.executeBoolean("!!source.getChannelPlaylists") ?: false, hasGetChannelPlaylists = plugin.executeBoolean("!!source.getChannelPlaylists") ?: false,
hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false,
hasGetUserHistory = plugin.executeBoolean("!!source.getUserHistory") ?: false
); );
try { try {
@@ -260,19 +289,28 @@ open class JSClient : IPlatformClient {
} }
@JSDocs(0, "source.enable()", "Called when the plugin is enabled/started") @JSDocs(0, "source.enable()", "Called when the plugin is enabled/started")
fun enable() { fun enable() = isBusyWith("enable") {
if(!_initialized) if(!_initialized)
initialize(); initialize();
for(toDeclare in declareOnEnable) {
plugin.execute("var ${toDeclare.key} = " + Json.encodeToString(toDeclare.value));
}
plugin.execute("source.enable(${Json.encodeToString(config)}, parseSettings(${Json.encodeToString(descriptor.getSettingsWithDefaults())}), ${Json.encodeToString(_injectedSaveState)})"); plugin.execute("source.enable(${Json.encodeToString(config)}, parseSettings(${Json.encodeToString(descriptor.getSettingsWithDefaults())}), ${Json.encodeToString(_injectedSaveState)})");
if(declareOnEnable.containsKey("__reloadData")) {
Logger.i(TAG, "Plugin [${config.name}] enabled with reload data: ${declareOnEnable["__reloadData"]}");
_usedReloadData = declareOnEnable["__reloadData"];
declareOnEnable.remove("__reloadData");
}
_enabled = true; _enabled = true;
} }
@JSDocs(1, "source.saveState()", "Provide a string that is passed to enable for quicker startup of multiple instances") @JSDocs(1, "source.saveState()", "Provide a string that is passed to enable for quicker startup of multiple instances")
fun saveState(): String? { fun saveState(): String? = isBusyWith("saveState") {
ensureEnabled(); ensureEnabled();
if(!capabilities.hasSaveState) if(!capabilities.hasSaveState)
return null; return@isBusyWith null;
val resp = plugin.executeTyped<V8ValueString>("source.saveState()").value; val resp = plugin.executeTyped<V8ValueString>("source.saveState()").value;
return resp; return@isBusyWith resp;
} }
@JSDocs(1, "source.disable()", "Called before the plugin is disabled/stopped") @JSDocs(1, "source.disable()", "Called before the plugin is disabled/stopped")
@@ -295,6 +333,13 @@ open class JSClient : IPlatformClient {
plugin.executeTyped("source.getHome()")); plugin.executeTyped("source.getHome()"));
} }
@JSDocs(2, "source.getShorts()", "Gets the Shorts feed of the platform")
override fun getShorts(): IPager<IPlatformVideo> = isBusyWith("getShorts") {
ensureEnabled()
return@isBusyWith JSVideoPager(config, this,
plugin.executeTyped("source.getShorts()"))
}
@JSDocs(3, "source.searchSuggestions(query)", "Gets search suggestions for a given query") @JSDocs(3, "source.searchSuggestions(query)", "Gets search suggestions for a given query")
@JSDocsParameter("query", "Query to complete suggestions for") @JSDocsParameter("query", "Query to complete suggestions for")
override fun searchSuggestions(query: String): Array<String> = isBusyWith("searchSuggestions") { override fun searchSuggestions(query: String): Array<String> = isBusyWith("searchSuggestions") {
@@ -313,8 +358,10 @@ open class JSClient : IPlatformClient {
return _searchCapabilities!!; return _searchCapabilities!!;
} }
_searchCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchCapabilities()")); return busy {
return _searchCapabilities!!; _searchCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchCapabilities()"));
return@busy _searchCapabilities!!;
}
} }
catch(ex: Throwable) { catch(ex: Throwable) {
announcePluginUnhandledException("getSearchCapabilities", ex); announcePluginUnhandledException("getSearchCapabilities", ex);
@@ -342,8 +389,10 @@ open class JSClient : IPlatformClient {
if (_searchChannelContentsCapabilities != null) if (_searchChannelContentsCapabilities != null)
return _searchChannelContentsCapabilities!!; return _searchChannelContentsCapabilities!!;
_searchChannelContentsCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchChannelContentsCapabilities()")); return busy {
return _searchChannelContentsCapabilities!!; _searchChannelContentsCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchChannelContentsCapabilities()"));
return@busy _searchChannelContentsCapabilities!!;
}
} }
@JSDocs(5, "source.searchChannelContents(query)", "Searches for videos on the platform") @JSDocs(5, "source.searchChannelContents(query)", "Searches for videos on the platform")
@JSDocsParameter("channelUrl", "Channel url to search") @JSDocsParameter("channelUrl", "Channel url to search")
@@ -375,14 +424,14 @@ open class JSClient : IPlatformClient {
@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)")
override fun isChannelUrl(url: String): Boolean { override fun isChannelUrl(url: String): Boolean = isBusyWith("isChannelUrl") {
try { try {
return plugin.executeTyped<V8ValueBoolean>("source.isChannelUrl(${Json.encodeToString(url)})") return@isBusyWith plugin.executeTyped<V8ValueBoolean>("source.isChannelUrl(${Json.encodeToString(url)})")
.value; .value;
} }
catch(ex: Throwable) { catch(ex: Throwable) {
announcePluginUnhandledException("isChannelUrl", ex); announcePluginUnhandledException("isChannelUrl", ex);
return false; return@isBusyWith false;
} }
} }
@JSDocs(7, "source.getChannel(channelUrl)", "Gets a channel by its url") @JSDocs(7, "source.getChannel(channelUrl)", "Gets a channel by its url")
@@ -400,9 +449,10 @@ open class JSClient : IPlatformClient {
if (_channelCapabilities != null) { if (_channelCapabilities != null) {
return _channelCapabilities!!; return _channelCapabilities!!;
} }
return busy {
_channelCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getChannelCapabilities()")); _channelCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getChannelCapabilities()"));
return _channelCapabilities!!; return@busy _channelCapabilities!!;
};
} }
catch(ex: Throwable) { catch(ex: Throwable) {
announcePluginUnhandledException("getChannelCapabilities", ex); announcePluginUnhandledException("getChannelCapabilities", ex);
@@ -438,13 +488,14 @@ open class JSClient : IPlatformClient {
if (_peekChannelTypes != null) { if (_peekChannelTypes != null) {
return _peekChannelTypes!!; return _peekChannelTypes!!;
} }
val arr: V8ValueArray = plugin.executeTyped("source.getPeekChannelTypes()"); return busy {
val arr: V8ValueArray = plugin.executeTyped("source.getPeekChannelTypes()");
_peekChannelTypes = arr.keys.mapNotNull { _peekChannelTypes = arr.keys.mapNotNull {
val str = arr.get<V8ValueString>(it); val str = arr.get<V8ValueString>(it);
return@mapNotNull str.value; return@mapNotNull str.value;
}; };
return _peekChannelTypes ?: listOf(); return@busy _peekChannelTypes ?: listOf();
}
} }
catch(ex: Throwable) { catch(ex: Throwable) {
announcePluginUnhandledException("getPeekChannelTypes", ex); announcePluginUnhandledException("getPeekChannelTypes", ex);
@@ -473,10 +524,12 @@ open class JSClient : IPlatformClient {
if(!capabilities.hasGetChannelUrlByClaim) if(!capabilities.hasGetChannelUrlByClaim)
throw IllegalStateException("This plugin does not support channel url by claim"); throw IllegalStateException("This plugin does not support channel url by claim");
val value = plugin.executeTyped<V8Value>("source.getChannelUrlByClaim(${claimType}, ${Json.encodeToString(claimValues)})"); return busy {
if(value !is V8ValueString) val value = plugin.executeTyped<V8Value>("source.getChannelUrlByClaim(${claimType}, ${Json.encodeToString(claimValues)})");
return null; if(value !is V8ValueString)
return value.value; return@busy null;
return@busy value.value;
}
} }
@JSOptional @JSOptional
@JSDocs(12, "source.getChannelTemplateByClaimMap()", "Get a map for every supported claimtype mapping field to urls") @JSDocs(12, "source.getChannelTemplateByClaimMap()", "Get a map for every supported claimtype mapping field to urls")
@@ -486,41 +539,43 @@ open class JSClient : IPlatformClient {
if(!capabilities.hasGetChannelTemplateByClaimMap) if(!capabilities.hasGetChannelTemplateByClaimMap)
throw IllegalStateException("This plugin does not support channel template by claim map"); throw IllegalStateException("This plugin does not support channel template by claim map");
val value = plugin.executeTyped<V8Value>("source.getChannelTemplateByClaimMap()"); return busy {
if(value !is V8ValueObject) val value = plugin.executeTyped<V8Value>("source.getChannelTemplateByClaimMap()");
return mapOf(); if(value !is V8ValueObject)
return@busy mapOf();
val claimTypes = mutableMapOf<Int, Map<Int, String>>(); val claimTypes = mutableMapOf<Int, Map<Int, String>>();
val keys = value.ownPropertyNames; val keys = value.ownPropertyNames;
for(key in keys.toArray()) { for(key in keys.toArray()) {
if(key is V8ValueInteger) { if(key is V8ValueInteger) {
val map = value.get<V8ValueObject>(key); val map = value.get<V8ValueObject>(key);
val mapKeys = map.ownPropertyNames; val mapKeys = map.ownPropertyNames;
claimTypes[key.value] = mapKeys.toArray().filter { claimTypes[key.value] = mapKeys.toArray().filter {
it is V8ValueInteger it is V8ValueInteger
}.associate { }.associate {
val mapKey = (it as V8ValueInteger).value; val mapKey = (it as V8ValueInteger).value;
return@associate Pair(mapKey, map.getString(mapKey)); return@associate Pair(mapKey, map.getString(mapKey));
}; };
}
} }
channelClaimTemplates = claimTypes.toMap();
return@busy claimTypes;
} }
channelClaimTemplates = claimTypes.toMap();
return claimTypes;
} }
@JSDocs(13, "source.isContentDetailsUrl(url)", "Validates if an content url is for this platform") @JSDocs(13, "source.isContentDetailsUrl(url)", "Validates if an content url is for this platform")
@JSDocsParameter("url", "A content url (May not be your platform)") @JSDocsParameter("url", "A content url (May not be your platform)")
override fun isContentDetailsUrl(url: String): Boolean { override fun isContentDetailsUrl(url: String): Boolean = isBusyWith("isContentDetailsUrl") {
try { try {
return plugin.executeTyped<V8ValueBoolean>("source.isContentDetailsUrl(${Json.encodeToString(url)})") return@isBusyWith plugin.executeTyped<V8ValueBoolean>("source.isContentDetailsUrl(${Json.encodeToString(url)})")
.value; .value;
} }
catch(ex: Throwable) { catch(ex: Throwable) {
announcePluginUnhandledException("isContentDetailsUrl", ex); announcePluginUnhandledException("isContentDetailsUrl", ex);
return false; return@isBusyWith false;
} }
} }
@JSDocs(14, "source.getContentDetails(url)", "Gets content details by its url") @JSDocs(14, "source.getContentDetails(url)", "Gets content details by its url")
@@ -552,7 +607,7 @@ open class JSClient : IPlatformClient {
Logger.i(TAG, "JSClient.getPlaybackTracker(${url})"); Logger.i(TAG, "JSClient.getPlaybackTracker(${url})");
val tracker = plugin.executeTyped<V8Value>("source.getPlaybackTracker(${Json.encodeToString(url)})"); val tracker = plugin.executeTyped<V8Value>("source.getPlaybackTracker(${Json.encodeToString(url)})");
if(tracker is V8ValueObject) if(tracker is V8ValueObject)
return@isBusyWith JSPlaybackTracker(config, tracker); return@isBusyWith JSPlaybackTracker(this, tracker);
else else
return@isBusyWith null; return@isBusyWith null;
} }
@@ -594,7 +649,6 @@ open class JSClient : IPlatformClient {
plugin.executeTyped("source.getLiveEvents(${Json.encodeToString(url)})")); plugin.executeTyped("source.getLiveEvents(${Json.encodeToString(url)})"));
} }
@JSDocs(19, "source.getContentRecommendations(url)", "Gets recommendations of a content page") @JSDocs(19, "source.getContentRecommendations(url)", "Gets recommendations of a content page")
@JSDocsParameter("url", "Url of content") @JSDocsParameter("url", "Url of content")
override fun getContentRecommendations(url: String): IPager<IPlatformContent>? = isBusyWith("getContentRecommendations") { override fun getContentRecommendations(url: String): IPager<IPlatformContent>? = isBusyWith("getContentRecommendations") {
@@ -622,17 +676,19 @@ open class JSClient : IPlatformClient {
@JSOptional @JSOptional
@JSDocs(20, "source.isPlaylistUrl(url)", "Validates if a playlist url is for this platform") @JSDocs(20, "source.isPlaylistUrl(url)", "Validates if a playlist url is for this platform")
@JSDocsParameter("url", "Url of playlist") @JSDocsParameter("url", "Url of playlist")
override fun isPlaylistUrl(url: String): Boolean { override fun isPlaylistUrl(url: String): Boolean = isBusyWith("isPlaylistUrl") {
if (!capabilities.hasGetPlaylist) if (!capabilities.hasGetPlaylist)
return false; return@isBusyWith false;
try { try {
return plugin.executeTyped<V8ValueBoolean>("source.isPlaylistUrl(${Json.encodeToString(url)})") return@isBusyWith busy {
.value; return@busy plugin.executeTyped<V8ValueBoolean>("source.isPlaylistUrl(${Json.encodeToString(url)})")
.value;
}
} }
catch(ex: Throwable) { catch(ex: Throwable) {
announcePluginUnhandledException("isPlaylistUrl", ex); announcePluginUnhandledException("isPlaylistUrl", ex);
return false; return@isBusyWith false;
} }
} }
@JSOptional @JSOptional
@@ -647,20 +703,33 @@ open class JSClient : IPlatformClient {
@JSDocs(22, "source.getUserPlaylists()", "Gets the playlist of the current user") @JSDocs(22, "source.getUserPlaylists()", "Gets the playlist of the current user")
override fun getUserPlaylists(): Array<String> { override fun getUserPlaylists(): Array<String> {
ensureEnabled(); ensureEnabled();
return plugin.executeTyped<V8ValueArray>("source.getUserPlaylists()") return busy {
.toArray() return@busy plugin.executeTyped<V8ValueArray>("source.getUserPlaylists()")
.map { (it as V8ValueString).value } .toArray()
.toTypedArray(); .map { (it as V8ValueString).value }
.toTypedArray();
}
} }
@JSOptional @JSOptional
@JSDocs(23, "source.getUserSubscriptions()", "Gets the subscriptions of the current user") @JSDocs(23, "source.getUserSubscriptions()", "Gets the subscriptions of the current user")
override fun getUserSubscriptions(): Array<String> { override fun getUserSubscriptions(): Array<String> {
ensureEnabled(); ensureEnabled();
return plugin.executeTyped<V8ValueArray>("source.getUserSubscriptions()") return busy {
.toArray() return@busy plugin.executeTyped<V8ValueArray>("source.getUserSubscriptions()")
.map { (it as V8ValueString).value } .toArray()
.toTypedArray(); .map { (it as V8ValueString).value }
.toTypedArray();
}
}
@JSOptional
@JSDocs(23, "source.getUserHistory()", "Gets the history of the current user")
override fun getUserHistory(): IPager<IPlatformContent> {
ensureEnabled();
return isBusyWith("getUserHistory") {
return@isBusyWith JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()"));
}
} }
fun validate() { fun validate() {
@@ -734,19 +803,29 @@ open class JSClient : IPlatformClient {
return urls; return urls;
} }
fun <T> busy(handle: ()->T): T {
private fun <T> isBusyWith(actionName: String, handle: ()->T): T { return _plugin.busy {
try { return@busy handle();
synchronized(_busyLock) {
_busyCounter++;
}
_busyAction = actionName;
return handle();
} }
finally { }
_busyAction = ""; fun <T> busyBlockingSuspended(handle: suspend ()->T): T {
synchronized(_busyLock) { return _plugin.busy {
_busyCounter--; return@busy runBlocking {
return@runBlocking handle();
}
}
}
fun <T> isBusyWith(actionName: String, handle: ()->T): T {
//val busyId = kotlin.random.Random.nextInt(9999);
return busy {
try {
_busyAction = actionName;
return@busy handle();
}
finally {
_busyAction = "";
} }
} }
} }
@@ -826,4 +905,4 @@ open class JSClient : IPlatformClient {
return name; return name;
} }
} }
} }
@@ -5,9 +5,9 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) { data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf(), val userAgent: String? = null) {
override fun toString(): String { override fun toString(): String {
return "(headers: '$headers', cookieString: '$cookieMap')"; return "(headers: '$headers', cookieString: '$cookieMap', userAgent: '$userAgent')";
} }
fun toEncrypted(): String{ fun toEncrypted(): String{
@@ -15,23 +15,25 @@ data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? =
} }
private fun serialize(): String { private fun serialize(): String {
return Json.encodeToString(SerializedAuth(cookieMap, headers)); return Json.encodeToString(SerializedAuth(cookieMap, headers, userAgent));
} }
companion object { companion object {
val TAG = "SourceAuth"; val TAG = "SourceAuth";
private val _json = Json { ignoreUnknownKeys = true };
fun fromEncrypted(encrypted: String?): SourceAuth? { fun fromEncrypted(encrypted: String?): SourceAuth? {
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) }; return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
} }
private fun deserialize(str: String): SourceAuth { private fun deserialize(str: String): SourceAuth {
val data = Json.decodeFromString<SerializedAuth>(str); val data = _json.decodeFromString<SerializedAuth>(str);
return SourceAuth(data.cookieMap, data.headers); return SourceAuth(data.cookieMap, data.headers, data.userAgent);
} }
} }
@Serializable @Serializable
data class SerializedAuth(val cookieMap: HashMap<String, HashMap<String, String>>?, data class SerializedAuth(val cookieMap: HashMap<String, HashMap<String, String>>?,
val headers: Map<String, Map<String, String>> = mapOf()) val headers: Map<String, Map<String, String>> = mapOf(),
val userAgent: String? = null)
} }
@@ -5,9 +5,9 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) { data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf(), val userAgent: String? = null) {
override fun toString(): String { override fun toString(): String {
return "(headers: '$headers', cookieString: '$cookieMap')"; return "(headers: '$headers', cookieString: '$cookieMap', userAgent: '$userAgent')";
} }
fun toEncrypted(): String{ fun toEncrypted(): String{
@@ -15,23 +15,25 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
} }
private fun serialize(): String { private fun serialize(): String {
return Json.encodeToString(SerializedCaptchaData(cookieMap, headers)); return Json.encodeToString(SerializedCaptchaData(cookieMap, headers, userAgent));
} }
companion object { companion object {
val TAG = "SourceCaptchaData"; val TAG = "SourceCaptchaData";
private val _json = Json { ignoreUnknownKeys = true };
fun fromEncrypted(encrypted: String?): SourceCaptchaData? { fun fromEncrypted(encrypted: String?): SourceCaptchaData? {
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) }; return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
} }
fun deserialize(str: String): SourceCaptchaData { fun deserialize(str: String): SourceCaptchaData {
val data = Json.decodeFromString<SerializedCaptchaData>(str); val data = _json.decodeFromString<SerializedCaptchaData>(str);
return SourceCaptchaData(data.cookieMap, data.headers); return SourceCaptchaData(data.cookieMap, data.headers, data.userAgent);
} }
} }
@Serializable @Serializable
data class SerializedCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>?, data class SerializedCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>?,
val headers: Map<String, Map<String, String>> = mapOf()) val headers: Map<String, Map<String, String>> = mapOf(),
val userAgent: String? = null)
} }
@@ -1,6 +1,11 @@
package com.futo.platformplayer.api.media.platforms.js package com.futo.platformplayer.api.media.platforms.js
@kotlinx.serialization.Serializable import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.util.Dictionary
@Serializable
class SourcePluginAuthConfig( class SourcePluginAuthConfig(
val loginUrl: String, val loginUrl: String,
val completionUrl: String? = null, val completionUrl: String? = null,
@@ -11,5 +16,44 @@ class SourcePluginAuthConfig(
val userAgent: String? = null, val userAgent: String? = null,
val loginButton: String? = null, val loginButton: String? = null,
val domainHeadersToFind: Map<String, List<String>>? = null, val domainHeadersToFind: Map<String, List<String>>? = null,
val loginWarning: String? = null val loginWarning: String? = null,
) { } val loginWarnings: List<Warning>? = null,
val uiMods: List<UIMod>? = null
) {
@Serializable
class Warning(
val url: String,
val text: String?,
val details: String? = null,
val once: Boolean? = true
) {
@Transient
private var _regex: Regex? = null;
fun getRegex(): Regex {
return _regex ?: url.let {
val reg = Regex(it);
_regex = reg;
return reg;
}
}
}
@Serializable
class UIMod(
val url: String,
val scale: Float?,
val desktop: Boolean?
) {
@Contextual
private var _regex: Regex? = null;
fun getRegex(): Regex {
return _regex ?: url.let {
val reg = Regex(it);
_regex = reg;
return reg;
}
}
}
}
@@ -4,6 +4,7 @@ import android.net.Uri
import com.futo.platformplayer.SignatureProvider import com.futo.platformplayer.SignatureProvider
import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.matchesDomain import com.futo.platformplayer.matchesDomain
import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.states.StatePlugins
import kotlinx.serialization.Contextual import kotlinx.serialization.Contextual
@@ -22,7 +23,7 @@ class SourcePluginConfig(
//Script //Script
val repositoryUrl: String? = null, val repositoryUrl: String? = null,
val scriptUrl: String = "", val scriptUrl: String = "",
val version: Int = -1, var version: Int = -1,
val iconUrl: String? = null, val iconUrl: String? = null,
var id: String = UUID.randomUUID().toString(), var id: String = UUID.randomUUID().toString(),
@@ -47,6 +48,7 @@ class SourcePluginConfig(
var subscriptionRateLimit: Int? = null, var subscriptionRateLimit: Int? = null,
var enableInSearch: Boolean = true, var enableInSearch: Boolean = true,
var enableInHome: Boolean = true, var enableInHome: Boolean = true,
var enableInShorts: Boolean = true,
var supportedClaimTypes: List<Int> = listOf(), var supportedClaimTypes: List<Int> = listOf(),
var primaryClaimFieldType: Int? = null, var primaryClaimFieldType: Int? = null,
var developerSubmitUrl: String? = null, var developerSubmitUrl: String? = null,
@@ -59,6 +61,11 @@ class SourcePluginConfig(
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl); val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
val absoluteScriptUrl: String get() = resolveAbsoluteUrl(scriptUrl, sourceUrl)!!; val absoluteScriptUrl: String get() = resolveAbsoluteUrl(scriptUrl, sourceUrl)!!;
fun isOfficialAuthor(): Boolean {
return scriptSignature != null &&
scriptPublicKey == "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsoFJU4AReDyUnSQI9A99UjLCwkY8OH+1o8cdtf2EjSb+fO2qmP8MGMTAvfvgmq5d2QBJE2XHRkRO3JKcTlcc1j0WlOlU8P9W272DYCeX6oYaavpKNqGKoGEuodp9wtiyNwyH46++JfpU/uIUacZbZKkHv9gIGchmNvpKYZQjFd/8pUqXGpcXZP54tGSC9PLcY+5TozZThK7Oy1+3YEf1bZ44UinRYYATbLk/wNuAfsupvlt6nxZOcJhABhdo9V+gY0FE6Ayg5+1cd1noWhnRtLF+sPdEr3z8Nt15JEK5a/524t25FMhwz8yKxlGW5qW3QLJHSUgLQncL6a1zlZ1s8QIDAQAB"
}
private fun resolveAbsoluteUrl(url: String?, sourceUrl: String?): String? { private fun resolveAbsoluteUrl(url: String?, sourceUrl: String?): String? {
if(url == null) if(url == null)
return null; return null;
@@ -163,17 +170,28 @@ class SourcePluginConfig(
"Unrestricted Http Header access", "Unrestricted Http Header access",
"Allows this plugin to access all headers (including cookies and authorization headers) for unauthenticated requests." "Allows this plugin to access all headers (including cookies and authorization headers) for unauthenticated requests."
)) ))
if(packagesOptional.contains("Browser") || packages.contains("Browser")) {
list.add(Pair(
"Browser Interop",
"This plugin requires webbrowser interop. May access urls outside of the restricted urls. This will only work for official plugins and during development builds."
))
}
return list; return list;
} }
fun validate(text: String): Boolean { fun validate(text: String): Boolean {
if(scriptPublicKey.isNullOrEmpty()) try {
throw IllegalStateException("No public key present"); if (scriptPublicKey.isNullOrEmpty())
if(scriptSignature.isNullOrEmpty()) throw IllegalStateException("No public key present");
throw IllegalStateException("No signature present"); if (scriptSignature.isNullOrEmpty())
throw IllegalStateException("No signature present");
return SignatureProvider.verify(text, scriptSignature, scriptPublicKey); return SignatureProvider.verify(text, scriptSignature, scriptPublicKey);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to verify due to an unhandled exception", e)
return false
}
} }
fun isUrlAllowed(url: String): Boolean { fun isUrlAllowed(url: String): Boolean {
@@ -204,6 +222,8 @@ class SourcePluginConfig(
obj.sourceUrl = sourceUrl; obj.sourceUrl = sourceUrl;
return obj; return obj;
} }
private val TAG = "SourcePluginConfig"
} }
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
@@ -215,7 +235,8 @@ class SourcePluginConfig(
val variable: String? = null, val variable: String? = null,
val dependency: String? = null, val dependency: String? = null,
val warningDialog: String? = null, val warningDialog: String? = null,
val options: List<String>? = null val options: List<String>? = null,
val isAdvanced: Boolean? = null
) { ) {
val variableOrName: String get() = variable ?: name; val variableOrName: String get() = variable ?: name;
} }
@@ -5,10 +5,16 @@ import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
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.StateApp
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.fields.DropdownFieldOptions import com.futo.platformplayer.views.fields.DropdownFieldOptions
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
import com.futo.platformplayer.views.fields.FormFieldButton
import com.futo.platformplayer.views.fields.FormFieldWarning import com.futo.platformplayer.views.fields.FormFieldWarning
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@@ -103,12 +109,22 @@ class SourcePluginDescriptor {
@FormField(R.string.home, FieldForm.TOGGLE, R.string.show_content_in_home_tab, 1) @FormField(R.string.home, FieldForm.TOGGLE, R.string.show_content_in_home_tab, 1)
var enableHome: Boolean? = null; var enableHome: Boolean? = null;
@FormField(R.string.search, FieldForm.TOGGLE, R.string.show_content_in_search_results, 2) @FormField(R.string.search, FieldForm.TOGGLE, R.string.show_content_in_search_results, 2)
var enableSearch: Boolean? = null; var enableSearch: Boolean? = null;
@FormField(R.string.shorts, FieldForm.TOGGLE, R.string.show_content_in_shorts_tab, 3)
var enableShorts: Boolean? = null;
} }
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 3) @FormField(R.string.sync, "group", R.string.sync_desc, 3,"sync")
var sync = Sync();
@Serializable
class Sync {
@FormField(R.string.sync_history, FieldForm.TOGGLE, R.string.sync_history_desc, 1,"syncHistory")
var enableHistorySync: Boolean? = null;
}
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 4)
var rateLimit = RateLimit(); var rateLimit = RateLimit();
@Serializable @Serializable
class RateLimit { class RateLimit {
@@ -143,6 +159,8 @@ class SourcePluginDescriptor {
tabEnabled.enableHome = config.enableInHome tabEnabled.enableHome = config.enableInHome
if(tabEnabled.enableSearch == null) if(tabEnabled.enableSearch == null)
tabEnabled.enableSearch = config.enableInSearch tabEnabled.enableSearch = config.enableInSearch
if(tabEnabled.enableShorts == null)
tabEnabled.enableShorts = config.enableInShorts
} }
} }
@@ -23,6 +23,7 @@ import java.util.UUID
class JSHttpClient : ManagedHttpClient { class JSHttpClient : ManagedHttpClient {
private val _jsClient: JSClient?; private val _jsClient: JSClient?;
private val _jsConfig: SourcePluginConfig?; private val _jsConfig: SourcePluginConfig?;
val config get() = _jsConfig
private val _auth: SourceAuth?; private val _auth: SourceAuth?;
private val _captcha: SourceCaptchaData?; private val _captcha: SourceCaptchaData?;
@@ -67,6 +68,25 @@ class JSHttpClient : ManagedHttpClient {
} }
fun resetAuthCookies() {
_currentCookieMap.clear();
if(!_auth?.cookieMap.isNullOrEmpty()) {
for(domainCookies in _auth!!.cookieMap!!)
_currentCookieMap.put(domainCookies.key, HashMap(domainCookies.value));
}
if(!_captcha?.cookieMap.isNullOrEmpty()) {
for(domainCookies in _captcha!!.cookieMap!!) {
if(_currentCookieMap.containsKey(domainCookies.key))
_currentCookieMap[domainCookies.key]?.putAll(domainCookies.value);
else
_currentCookieMap.put(domainCookies.key, HashMap(domainCookies.value));
}
}
}
fun clearOtherCookies() {
_otherCookieMap.clear();
}
override fun clone(): ManagedHttpClient { override fun clone(): ManagedHttpClient {
val newClient = JSHttpClient(_jsClient, _auth); val newClient = JSHttpClient(_jsClient, _auth);
newClient._currentCookieMap = HashMap(_currentCookieMap.toList().associate { Pair(it.first, HashMap(it.second)) }) newClient._currentCookieMap = HashMap(_currentCookieMap.toList().associate { Pair(it.first, HashMap(it.second)) })
@@ -235,6 +255,76 @@ class JSHttpClient : ManagedHttpClient {
return resp; return resp;
} }
fun processRequest(method: String, responseCode: Int, url: Uri, headers: Map<String, List<String>>) {
if(doUpdateCookies) {
val domain = url.host?.lowercase() ?: return;
val domainParts = domain.split(".");
val defaultCookieDomain =
"." + domainParts.drop(domainParts.size - 2).joinToString(".");
for (header in headers) {
if(header.key.lowercase() == "set-cookie") {
var domainToUse = domain;
val cookie = cookieStringToPair(header.value.first());
var cookieValue = cookie.second;
if (cookie.first.isNotEmpty() && cookie.second.isNotEmpty()) {
val cookieParts = cookie.second.split(";");
if (cookieParts.size == 0)
continue;
cookieValue = cookieParts[0].trim();
val cookieVariables = cookieParts.drop(1).map {
val splitIndex = it.indexOf("=");
if (splitIndex < 0)
return@map Pair(it.trim().lowercase(), "");
return@map Pair<String, String>(
it.substring(0, splitIndex).lowercase().trim(),
it.substring(splitIndex + 1).trim()
);
}.toMap();
domainToUse = if (cookieVariables.containsKey("domain"))
cookieVariables["domain"]!!.lowercase();
else defaultCookieDomain;
//TODO: Make sure this has no negative effect besides apply cookies to root domain
if(!domainToUse.startsWith("."))
domainToUse = ".${domainToUse}";
}
if ((_auth != null || _currentCookieMap.isNotEmpty())) {
val cookieMap = if (_currentCookieMap.containsKey(domainToUse))
_currentCookieMap[domainToUse]!!;
else {
val newMap = hashMapOf<String, String>();
_currentCookieMap[domainToUse] = newMap
newMap;
}
if (cookieMap.containsKey(cookie.first) || doAllowNewCookies)
cookieMap[cookie.first] = cookieValue;
}
else {
val cookieMap = if (_otherCookieMap.containsKey(domainToUse))
_otherCookieMap[domainToUse]!!;
else {
val newMap = hashMapOf<String, String>();
_otherCookieMap[domainToUse] = newMap
newMap;
}
if (cookieMap.containsKey(cookie.first) || doAllowNewCookies)
cookieMap[cookie.first] = cookieValue;
}
}
}
}
if(_jsClient is DevJSClient) {
//val peekBody = resp.peekBody(1000 * 1000).string();
StateDeveloper.instance.addDevHttpExchange(
StateDeveloper.DevHttpExchange(
StateDeveloper.DevHttpRequest(method, url.toString(), mapOf(*headers.map { Pair(it.key, it.value.joinToString(";")) }.toTypedArray()), ""),
StateDeveloper.DevHttpRequest("RESP", url.toString(), mapOf(*headers.map { Pair(it.key, it.value.joinToString(";")) }.toTypedArray()), "", responseCode)
));
}
}
private fun cookieStringToPair(cookie: String): Pair<String, String> { private fun cookieStringToPair(cookie: String): Pair<String, String> {
val cookieKey = cookie.substring(0, cookie.indexOf("=")); val cookieKey = cookie.substring(0, cookie.indexOf("="));
@@ -6,6 +6,7 @@ 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
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.ensureIsBusy
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
@@ -13,6 +14,7 @@ interface IJSContent: IPlatformContent {
companion object { companion object {
fun fromV8(plugin: JSClient, obj: V8ValueObject): IPlatformContent { fun fromV8(plugin: JSClient, obj: V8ValueObject): IPlatformContent {
obj.ensureIsBusy();
val config = plugin.config; val config = plugin.config;
val type: Int = obj.getOrThrow(config, "contentType", "ContentItem"); val type: Int = obj.getOrThrow(config, "contentType", "ContentItem");
val pluginType: String? = obj.getOrDefault(config, "plugin_type", "ContentItem", null); val pluginType: String? = obj.getOrDefault(config, "plugin_type", "ContentItem", null);
@@ -6,12 +6,14 @@ 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.contents.IPlatformContentDetails
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.ensureIsBusy
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
interface IJSContentDetails: IPlatformContent { interface IJSContentDetails: IPlatformContent {
companion object { companion object {
fun fromV8(plugin: JSClient, obj: V8ValueObject): IPlatformContentDetails { fun fromV8(plugin: JSClient, obj: V8ValueObject): IPlatformContentDetails {
obj.ensureIsBusy();
val type: Int = obj.getOrThrow(plugin.config, "contentType", "ContentDetails"); val type: Int = obj.getOrThrow(plugin.config, "contentType", "ContentDetails");
return when(ContentType.fromInt(type)) { return when(ContentType.fromInt(type)) {
ContentType.MEDIA -> JSVideoDetails(plugin, obj); ContentType.MEDIA -> JSVideoDetails(plugin, obj);
@@ -21,19 +21,25 @@ import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
open class JSArticle : JSContent, IPlatformArticle, IPluginSourced { open class JSArticle(
final override val contentType: ContentType get() = ContentType.ARTICLE; config: SourcePluginConfig,
obj: V8ValueObject
) : JSContent(config, obj), IPlatformArticle, IPluginSourced {
override val summary: String; final override val contentType: ContentType = ContentType.ARTICLE
override val thumbnails: Thumbnails?;
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { override val summary: String =
val contextName = "PlatformArticle"; obj.getOrDefault<String>(config, "summary", "PlatformArticle", "") ?: ""
summary = _content.getOrDefault(config, "summary", contextName, "") ?: ""; override val thumbnails: Thumbnails? =
thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName)); if (obj.getSourcePlugin()?.busy { obj.has("thumbnails") } ?: obj.has("thumbnails"))
Thumbnails.fromV8(
} config,
} obj.getOrThrow<V8ValueObject>(config, "thumbnails", "PlatformArticle")
)
else
null
}
@@ -21,41 +21,50 @@ import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails { open class JSArticleDetails(
final override val contentType: ContentType get() = ContentType.ARTICLE; private val client: JSClient,
obj: V8ValueObject
) : JSContent(client.config, obj), IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
private val _hasGetComments: Boolean; final override val contentType: ContentType = ContentType.ARTICLE
private val _hasGetContentRecommendations: Boolean;
override val rating: IRating; private val _hasGetComments: Boolean = client.busy { _content.has("getComments") }
private val _hasGetContentRecommendations: Boolean = client.busy { _content.has("getContentRecommendations") }
override val summary: String; override val rating: IRating = client.busy {
override val thumbnails: Thumbnails?; obj.getOrDefault<V8ValueObject>(client.config, "rating", "PlatformArticle", null)
override val segments: List<IJSArticleSegment>; ?.let { IRating.fromV8(client.config, it, "PlatformArticle") }
?: RatingLikes(0)
}
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) { override val summary: String = client.busy {
val contextName = "PlatformArticle"; _content.getOrThrow(client.config, "summary", "PlatformArticle")
}
rating = obj.getOrDefault<V8ValueObject>(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0); override val thumbnails: Thumbnails? = client.busy {
summary = _content.getOrThrow(client.config, "summary", contextName); if (_content.has("thumbnails"))
if(_content.has("thumbnails")) Thumbnails.fromV8(
thumbnails = Thumbnails.fromV8(client.config, _content.getOrThrow(client.config, "thumbnails", contextName)); client.config,
_content.getOrThrow(client.config, "thumbnails", "PlatformArticle")
)
else else
thumbnails = null; null
}
override val segments: List<IJSArticleSegment> = client.busy {
segments = (obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", contextName) obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", "PlatformArticle")
?.map { fromV8Segment(client, it) } ?.mapNotNull { fromV8Segment(client, it) }
?.filterNotNull() ?: listOf()); ?: emptyList()
_hasGetComments = _content.has("getComments");
_hasGetContentRecommendations = _content.has("getContentRecommendations");
} }
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? { override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
if(!_hasGetComments || _content.isClosed) val canGetComments = this.client.busy {
_hasGetComments && !_content.isClosed
}
if(!canGetComments)
return null; return null;
if(client is DevJSClient) if(client is DevJSClient)
@@ -71,7 +80,10 @@ open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced
override fun getPlaybackTracker(): IPlaybackTracker? = null; override fun getPlaybackTracker(): IPlaybackTracker? = null;
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? { override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
if(!_hasGetContentRecommendations || _content.isClosed) val canGetContentRecommendations = this.client.busy {
_hasGetContentRecommendations && !_content.isClosed
}
if(!canGetContentRecommendations)
return null; return null;
if(client is DevJSClient) if(client is DevJSClient)
@@ -85,25 +97,31 @@ open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced
} }
private fun getContentRecommendationsJS(client: JSClient): JSContentPager { private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
val contentPager = _content.invoke<V8ValueObject>("getContentRecommendations", arrayOf<Any>()); return client.busy {
return JSContentPager(_pluginConfig, client, contentPager); val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return@busy JSContentPager(_pluginConfig, client, contentPager);
}
} }
private fun getCommentsJS(client: JSClient): JSCommentPager { private fun getCommentsJS(client: JSClient): JSCommentPager {
val commentPager = _content.invoke<V8ValueObject>("getComments", arrayOf<Any>()); return client.busy {
return JSCommentPager(_pluginConfig, client, commentPager); val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return@busy JSCommentPager(_pluginConfig, client, commentPager);
}
} }
companion object { companion object {
fun fromV8Segment(client: JSClient, obj: V8ValueObject): IJSArticleSegment? { fun fromV8Segment(client: JSClient, obj: V8ValueObject): IJSArticleSegment? {
if(!obj.has("type")) return client.busy {
throw IllegalArgumentException("Object missing type field"); if(!obj.has("type"))
return when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) { throw IllegalArgumentException("Object missing type field");
SegmentType.TEXT -> JSTextSegment(client, obj); return@busy when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) {
SegmentType.IMAGES -> JSImagesSegment(client, obj); SegmentType.TEXT -> JSTextSegment(client, obj);
SegmentType.HEADER -> JSHeaderSegment(client, obj); SegmentType.IMAGES -> JSImagesSegment(client, obj);
SegmentType.NESTED -> JSNestedSegment(client, obj); SegmentType.HEADER -> JSHeaderSegment(client, obj);
else -> null; SegmentType.NESTED -> JSNestedSegment(client, obj);
else -> null;
}
} }
} }
} }
@@ -174,4 +192,4 @@ class JSNestedSegment: IJSArticleSegment {
val nestedObj = obj.getOrThrow<V8ValueObject>(client.config, "nested", contextName, false); val nestedObj = obj.getOrThrow<V8ValueObject>(client.config, "nested", contextName, false);
nested = IJSContent.fromV8(client, nestedObj); nested = IJSContent.fromV8(client, nestedObj);
} }
} }
@@ -12,6 +12,7 @@ import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullable import com.futo.platformplayer.getOrThrowNullable
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime import java.time.OffsetDateTime
@@ -45,23 +46,45 @@ class JSComment : IPlatformComment {
_comment = obj; _comment = obj;
_plugin = plugin; _plugin = plugin;
val contextName = "Comment"; var parsedContextUrl: String? = null;
contextUrl = _comment!!.getOrThrow(config, "contextUrl", contextName); var parsedAuthor: PlatformAuthorLink? = null;
author = PlatformAuthorLink.fromV8(_config!!, _comment!!.getOrThrow(config, "author", contextName)); var parsedMessage: String? = null;
message = _comment!!.getOrThrow(config, "message", contextName); var parsedRating: IRating? = null;
rating = IRating.fromV8(config, _comment!!.getOrThrow(config, "rating", contextName)); var parsedDate: OffsetDateTime? = null;
date = _comment!!.getOrThrowNullable<Int>(config, "date", contextName)?.let { OffsetDateTime.of(LocalDateTime.ofEpochSecond(it.toLong(), 0, ZoneOffset.UTC), ZoneOffset.UTC) } var parsedReplyCount: Int? = null;
replyCount = _comment!!.getOrThrowNullable(config, "replyCount", contextName); var parsedContext: Map<String, String>? = null;
context = _comment!!.getOrDefault(config, "context", contextName, hashMapOf()) ?: hashMapOf(); var parsedHasGetReplies = false;
_hasGetReplies = _comment!!.has("getReplies");
plugin.busy {
val contextName = "Comment";
parsedContextUrl = _comment!!.getOrThrow(config, "contextUrl", contextName);
parsedAuthor = PlatformAuthorLink.fromV8(_config!!, _comment!!.getOrThrow(config, "author", contextName));
parsedMessage = _comment!!.getOrThrow(config, "message", contextName);
parsedRating = IRating.fromV8(config, _comment!!.getOrThrow(config, "rating", contextName));
parsedDate = _comment!!.getOrThrowNullable<Int>(config, "date", contextName)?.let { OffsetDateTime.of(LocalDateTime.ofEpochSecond(it.toLong(), 0, ZoneOffset.UTC), ZoneOffset.UTC) };
parsedReplyCount = _comment!!.getOrThrowNullable(config, "replyCount", contextName);
parsedContext = _comment!!.getOrDefault(config, "context", contextName, hashMapOf()) ?: hashMapOf();
parsedHasGetReplies = _comment!!.has("getReplies");
}
contextUrl = parsedContextUrl ?: "";
author = parsedAuthor ?: PlatformAuthorLink.UNKNOWN;
message = parsedMessage ?: "";
rating = parsedRating ?: throw IllegalStateException("Missing comment rating");
date = parsedDate;
replyCount = parsedReplyCount;
context = parsedContext ?: hashMapOf();
_hasGetReplies = parsedHasGetReplies;
} }
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment>? { override fun getReplies(client: IPlatformClient): IPager<IPlatformComment>? {
if(!_hasGetReplies) if(!_hasGetReplies)
return null; return null;
val obj = _comment!!.invoke<V8ValueObject>("getReplies", arrayOf<Any>());
val plugin = if(client is JSClient) client else throw NotImplementedError("Only implemented for JSClient"); val plugin = if(client is JSClient) client else throw NotImplementedError("Only implemented for JSClient");
return JSCommentPager(_config!!, plugin, obj); return plugin.busy {
val obj = _comment!!.invokeV8<V8ValueObject>("getReplies", arrayOf<Any>());
return@busy JSCommentPager(_config!!, plugin, obj);
}
} }
} }
@@ -11,56 +11,56 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.decodeUnicode import com.futo.platformplayer.decodeUnicode
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
open class JSContent : IPlatformContent, IPluginSourced { open class JSContent(
protected val _pluginConfig: SourcePluginConfig; protected val _pluginConfig: SourcePluginConfig,
protected val _content : V8ValueObject; protected val _content: V8ValueObject
) : IPlatformContent, IPluginSourced {
protected val _hasGetDetails: Boolean; override val contentType: ContentType = ContentType.UNKNOWN
override val contentType: ContentType get() = ContentType.UNKNOWN; protected val _hasGetDetails: Boolean =
_content.getSourcePlugin()?.busy { _content.has("getDetails") } ?: false
override val id: PlatformID; override val id: PlatformID =
override val name: String; PlatformID.fromV8(_pluginConfig, _content.getOrThrow(_pluginConfig, "id", CTX))
override val author: PlatformAuthorLink;
override val datetime: OffsetDateTime?;
override val url: String; override val name: String =
override val shareUrl: String; HtmlCompat.fromHtml(
_content.getOrThrow<String>(_pluginConfig, "name", CTX).decodeUnicode(),
HtmlCompat.FROM_HTML_MODE_LEGACY
).toString()
override val sourceConfig: SourcePluginConfig get() = _pluginConfig; override val author: PlatformAuthorLink =
_content.getOrDefault<V8ValueObject>(_pluginConfig, "author", CTX, null)
?.let { PlatformAuthorLink.fromV8(_pluginConfig, it) }
?: PlatformAuthorLink.UNKNOWN
constructor(config: SourcePluginConfig, obj: V8ValueObject) { private val _epoch: Long? =
_pluginConfig = config; _content.getOrDefault<Long>(_pluginConfig, "datetime", CTX, null)?.toLong()
_content = obj;
val contextName = "PlatformContent"; override val datetime: OffsetDateTime? =
_epoch?.takeIf { it != 0L }?.let {
OffsetDateTime.of(LocalDateTime.ofEpochSecond(it, 0, ZoneOffset.UTC), ZoneOffset.UTC)
}
id = PlatformID.fromV8(_pluginConfig, _content.getOrThrow(config, "id", contextName)); override val url: String =
name = HtmlCompat.fromHtml(_content.getOrThrow<String>(config, "name", contextName).decodeUnicode(), HtmlCompat.FROM_HTML_MODE_LEGACY).toString(); _content.getOrThrow<String>(_pluginConfig, "url", CTX)
val authorObj = _content.getOrDefault<V8ValueObject>(config, "author", contextName, null); override val shareUrl: String =
if(authorObj != null) _content.getOrDefault<String>(_pluginConfig, "shareUrl", CTX, null) ?: url
author = PlatformAuthorLink.fromV8(_pluginConfig, authorObj);
else
author = PlatformAuthorLink.UNKNOWN;
val datetimeInt = _content.getOrDefault<Int>(config, "datetime", contextName, null)?.toLong(); override val sourceConfig: SourcePluginConfig
if(datetimeInt == null || datetimeInt == 0.toLong()) get() = _pluginConfig
datetime = null;
else
datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
url = _content.getOrThrow(config, "url", contextName);
shareUrl = _content.getOrDefault<String>(config, "shareUrl", contextName, null) ?: url;
_hasGetDetails = _content.has("getDetails"); fun getUnderlyingObject(): V8ValueObject? = _content
companion object {
private const val CTX = "PlatformContent"
} }
}
fun getUnderlyingObject(): V8ValueObject? {
return _content;
}
}
@@ -15,7 +15,7 @@ class JSLiveEventPager : JSPager<IPlatformLiveEvent>, IPlatformLiveEventPager {
nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager"); nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager");
} }
override fun nextPage() { override fun nextPage() = plugin.isBusyWith("JSLiveEventPager.nextPage") {
super.nextPage(); super.nextPage();
nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager"); nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager");
} }
@@ -10,6 +10,7 @@ import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.warnIfMainThread import com.futo.platformplayer.warnIfMainThread
abstract class JSPager<T> : IPager<T> { abstract class JSPager<T> : IPager<T> {
@@ -18,8 +19,8 @@ abstract class JSPager<T> : IPager<T> {
protected var pager: V8ValueObject; protected var pager: V8ValueObject;
private var _lastResults: List<T>? = null; private var _lastResults: List<T>? = null;
private var _resultChanged: Boolean = true; protected var _resultChanged: Boolean = true;
private var _hasMorePages: Boolean = false; protected var _hasMorePages: Boolean = false;
//private var _morePagesWasFalse: Boolean = false; //private var _morePagesWasFalse: Boolean = false;
val isAvailable get() = plugin.getUnderlyingPlugin()._runtime?.let { !it.isClosed && !it.isDead } ?: false; val isAvailable get() = plugin.getUnderlyingPlugin()._runtime?.let { !it.isClosed && !it.isDead } ?: false;
@@ -29,7 +30,9 @@ abstract class JSPager<T> : IPager<T> {
this.pager = pager; this.pager = pager;
this.config = config; this.config = config;
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false; plugin.busy {
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
}
getResults(); getResults();
} }
@@ -38,17 +41,22 @@ abstract class JSPager<T> : IPager<T> {
} }
override fun hasMorePages(): Boolean { override fun hasMorePages(): Boolean {
return _hasMorePages; return plugin.getUnderlyingPlugin().busy {
_hasMorePages && !pager.isClosed;
}
} }
override fun nextPage() { override fun nextPage() {
warnIfMainThread("JSPager.nextPage"); warnIfMainThread("JSPager.nextPage");
pager = plugin.getUnderlyingPlugin().catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") { val pluginV8 = plugin.getUnderlyingPlugin();
pager.invoke("nextPage", arrayOf<Any>()); pluginV8.busy {
}; pager = pluginV8.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") {
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false; pager.invokeV8("nextPage", arrayOf<Any>());
_resultChanged = true; };
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
_resultChanged = true;
}
/* /*
try { try {
} }
@@ -70,16 +78,19 @@ abstract class JSPager<T> : IPager<T> {
return previousResults; return previousResults;
warnIfMainThread("JSPager.getResults"); warnIfMainThread("JSPager.getResults");
val items = pager.getOrThrow<V8ValueArray>(config, "results", "JSPager");
if(items.v8Runtime.isDead || items.v8Runtime.isClosed) return plugin.getUnderlyingPlugin().busy {
throw IllegalStateException("Runtime closed"); val items = pager.getOrThrow<V8ValueArray>(config, "results", "JSPager");
val newResults = items.toArray() if (items.v8Runtime.isDead || items.v8Runtime.isClosed)
.map { convertResult(it as V8ValueObject) } throw IllegalStateException("Runtime closed");
.toList(); val newResults = items.toArray()
_lastResults = newResults; .map { convertResult(it as V8ValueObject) }
_resultChanged = false; .toList();
return newResults; _lastResults = newResults;
_resultChanged = false;
return@busy newResults;
}
} }
abstract fun convertResult(obj: V8ValueObject): T; abstract fun convertResult(obj: V8ValueObject): T;
} }
@@ -2,37 +2,51 @@ 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.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.invokeV8Void
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.warnIfMainThread import com.futo.platformplayer.warnIfMainThread
class JSPlaybackTracker: IPlaybackTracker { class JSPlaybackTracker: IPlaybackTracker {
private val _config: IV8PluginConfig; private lateinit var _client: JSClient;
private val _obj: V8ValueObject; private lateinit var _config: IV8PluginConfig;
private lateinit var _obj: V8ValueObject;
private var _hasCalledInit: Boolean = false; private var _hasCalledInit: Boolean = false;
private val _hasInit: Boolean; private var _hasInit: Boolean = false;
private var _lastRequest: Long = Long.MIN_VALUE; private var _lastRequest: Long = Long.MIN_VALUE;
private val _hasOnConcluded: Boolean; private var _hasOnConcluded: Boolean = false;
override var nextRequest: Int = 1000 override var nextRequest: Int = 1000
private set; private set;
constructor(config: IV8PluginConfig, obj: V8ValueObject) { constructor(client: JSClient, obj: V8ValueObject) {
warnIfMainThread("JSPlaybackTracker.constructor"); warnIfMainThread("JSPlaybackTracker.constructor");
if(!obj.has("onProgress"))
throw ScriptImplementationException(config, "Missing onProgress on PlaybackTracker");
if(!obj.has("nextRequest"))
throw ScriptImplementationException(config, "Missing nextRequest on PlaybackTracker");
_hasOnConcluded = obj.has("onConcluded");
this._config = config; client.busy {
this._obj = obj; if (!obj.has("onProgress"))
this._hasInit = obj.has("onInit"); throw ScriptImplementationException(
client.config,
"Missing onProgress on PlaybackTracker"
);
if (!obj.has("nextRequest"))
throw ScriptImplementationException(
client.config,
"Missing nextRequest on PlaybackTracker"
);
_hasOnConcluded = obj.has("onConcluded");
this._client = client;
this._config = client.config;
this._obj = obj;
this._hasInit = obj.has("onInit");
}
} }
override fun onInit(seconds: Double) { override fun onInit(seconds: Double) {
@@ -40,12 +54,15 @@ class JSPlaybackTracker: IPlaybackTracker {
synchronized(_obj) { synchronized(_obj) {
if(_hasCalledInit) if(_hasCalledInit)
return; return;
if (_hasInit) {
Logger.i("JSPlaybackTracker", "onInit (${seconds})"); _client.busy {
_obj.invokeVoid("onInit", seconds); if (_hasInit) {
Logger.i("JSPlaybackTracker", "onInit (${seconds})");
_obj.invokeV8Void("onInit", seconds);
}
nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false));
_hasCalledInit = true;
} }
nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false));
_hasCalledInit = true;
} }
} }
@@ -55,10 +72,12 @@ class JSPlaybackTracker: IPlaybackTracker {
if(!_hasCalledInit && _hasInit) if(!_hasCalledInit && _hasInit)
onInit(seconds); onInit(seconds);
else { else {
Logger.i("JSPlaybackTracker", "onProgress (${seconds}, ${isPlaying})"); _client.busy {
_obj.invokeVoid("onProgress", Math.floor(seconds), isPlaying); Logger.i("JSPlaybackTracker", "onProgress (${seconds}, ${isPlaying})");
nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false)); _obj.invokeV8Void("onProgress", Math.floor(seconds), isPlaying);
_lastRequest = System.currentTimeMillis(); nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false));
_lastRequest = System.currentTimeMillis();
}
} }
} }
} }
@@ -67,7 +86,9 @@ class JSPlaybackTracker: IPlaybackTracker {
if(_hasOnConcluded) { if(_hasOnConcluded) {
synchronized(_obj) { synchronized(_obj) {
Logger.i("JSPlaybackTracker", "onConcluded"); Logger.i("JSPlaybackTracker", "onConcluded");
_obj.invokeVoid("onConcluded", -1); _client.busy {
_obj.invokeV8Void("onConcluded", -1);
}
} }
} }
} }
@@ -6,14 +6,16 @@ import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrDefault
open class JSPlaylist : JSContent, IPlatformPlaylist { open class JSPlaylist(
override val contentType: ContentType get() = ContentType.PLAYLIST; config: SourcePluginConfig,
override val thumbnail: String?; obj: V8ValueObject
override val videoCount: Int; ) : JSContent(config, obj), IPlatformPlaylist {
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) { override val contentType: ContentType = ContentType.PLAYLIST
val contextName = "Playlist";
thumbnail = obj.getOrDefault(config, "thumbnail", contextName, null); override val thumbnail: String? =
videoCount = obj.getOrDefault(config, "videoCount", contextName, -1)!!; _content.getOrDefault<String>(_pluginConfig, "thumbnail", "Playlist", null)
}
} override val videoCount: Int =
_content.getOrDefault<Int>(_pluginConfig, "videoCount", "Playlist", null)?.toInt() ?: -1
}
@@ -15,6 +15,8 @@ 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.getOrDefault import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails { class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails {
@@ -29,52 +31,79 @@ class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails {
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) { constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformPostDetails"; var parsedRating: IRating? = null;
var parsedTextType: TextType? = null;
var parsedContent: String? = null;
var parsedHasGetComments = false;
var parsedHasGetContentRecommendations = false;
rating = obj.getOrDefault<V8ValueObject>(config, "rating", contextName, null)?.let { IRating.fromV8(config, it, contextName) } ?: RatingLikes(0); val parse = {
textType = TextType.fromInt((obj.getOrDefault<Int>(config, "textType", contextName, null) ?: 0)); val contextName = "PlatformPostDetails";
content = obj.getOrDefault(config, "content", contextName, "") ?: "";
_hasGetComments = _content.has("getComments"); parsedRating = obj.getOrDefault<V8ValueObject>(config, "rating", contextName, null)?.let { IRating.fromV8(config, it, contextName) } ?: RatingLikes(0);
_hasGetContentRecommendations = _content.has("getContentRecommendations"); parsedTextType = TextType.fromInt((obj.getOrDefault<Int>(config, "textType", contextName, null) ?: 0));
parsedContent = obj.getOrDefault(config, "content", contextName, "") ?: "";
parsedHasGetComments = _content.has("getComments");
parsedHasGetContentRecommendations = _content.has("getContentRecommendations");
};
obj.getSourcePlugin()?.busy {
parse();
} ?: parse()
rating = parsedRating ?: RatingLikes(0);
textType = parsedTextType ?: TextType.RAW;
content = parsedContent ?: "";
_hasGetComments = parsedHasGetComments;
_hasGetContentRecommendations = parsedHasGetContentRecommendations;
} }
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? { override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
if(!_hasGetComments || _content.isClosed) val jsClient = client as? JSClient;
if(jsClient == null)
return null;
val canGetComments = jsClient.busy {
_hasGetComments && !_content.isClosed
}
if(!canGetComments)
return null; return null;
if(client is DevJSClient) if(client is DevJSClient)
return StateDeveloper.instance.handleDevCall(client.devID, "videoDetail.getComments()") { return StateDeveloper.instance.handleDevCall(client.devID, "videoDetail.getComments()") {
return@handleDevCall getCommentsJS(client); return@handleDevCall getCommentsJS(client);
} }
else if(client is JSClient) return getCommentsJS(jsClient);
return getCommentsJS(client);
return null;
} }
override fun getPlaybackTracker(): IPlaybackTracker? = null; override fun getPlaybackTracker(): IPlaybackTracker? = null;
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? { override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
if(!_hasGetContentRecommendations || _content.isClosed) val jsClient = client as? JSClient;
if(jsClient == null)
return null;
val canGetContentRecommendations = jsClient.busy {
_hasGetContentRecommendations && !_content.isClosed
}
if(!canGetContentRecommendations)
return null; return null;
if(client is DevJSClient) if(client is DevJSClient)
return StateDeveloper.instance.handleDevCall(client.devID, "postDetail.getContentRecommendations()") { return StateDeveloper.instance.handleDevCall(client.devID, "postDetail.getContentRecommendations()") {
return@handleDevCall getContentRecommendationsJS(client); return@handleDevCall getContentRecommendationsJS(client);
} }
else if(client is JSClient) return getContentRecommendationsJS(jsClient);
return getContentRecommendationsJS(client);
return null;
} }
private fun getContentRecommendationsJS(client: JSClient): JSContentPager { private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
val contentPager = _content.invoke<V8ValueObject>("getContentRecommendations", arrayOf<Any>()); return client.busy {
return JSContentPager(_pluginConfig, client, contentPager); val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return@busy JSContentPager(_pluginConfig, client, contentPager);
}
} }
private fun getCommentsJS(client: JSClient): JSCommentPager { private fun getCommentsJS(client: JSClient): JSCommentPager {
val commentPager = _content.invoke<V8ValueObject>("getComments", arrayOf<Any>()); return client.busy {
return JSCommentPager(_pluginConfig, client, commentPager); val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return@busy JSCommentPager(_pluginConfig, client, commentPager);
}
} }
} }

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