Compare commits

...

230 Commits

Author SHA1 Message Date
Koen J 32de3649ef Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2026-04-30 10:10:17 +02:00
Koen J 1a301236da Changed background download to default. 2026-04-30 10:04:44 +02:00
Koen J c695379885 Fix for casting downloaded videos (UMP, requires redownloading). 2026-04-29 16:53:24 +02:00
Koen J 73466892f7 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2026-04-29 13:13:37 +02:00
Koen J bb8a9d4dd7 Fixed notification system issues and plugin auto update. 2026-04-29 13:07:14 +02:00
Koen J 43ed2b16ab Hide browser interop warning. 2026-04-29 11:23:19 +02:00
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
austin d6468ba283 Merge branch 'master' into aw/polycentric-profiles 2025-11-12 19:23:30 -06: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
Kai 7c70e58129 use request modifier when downloading url sources
Changelog: changed
2025-08-18 10:40:47 -04:00
Chris Olin 09bc180d4f update configChanges so bluetooth keyboards don't recreate activity 2025-06-10 13:25:45 -04: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
292 changed files with 10729 additions and 5598 deletions
+39 -19
View File
@@ -1,37 +1,57 @@
variables:
GIT_SUBMODULE_STRATEGY: recursive
stages:
- buildAndDeployApkUnstable
- buildAndDeployApkStable
- buildAndDeployPlaystore
buildAndDeployApkUnstable:
stage: buildAndDeployApkUnstable
stage: build
script:
- sh deploy-unstable.sh
only:
- tags
except:
- ^(dev)
when: manual
needs: []
allow_failure: true
artifacts:
when: always
expire_in: 30 days
paths:
- app/build/outputs/apk/unstable/release/*.apk
buildAndDeployApkStable:
stage: buildAndDeployApkStable
stage: build
script:
- sh deploy-stable.sh
only:
- tags
except:
- branches
when: manual
needs: []
artifacts:
when: always
expire_in: 30 days
paths:
- app/build/outputs/apk/stable/release/*.apk
buildAndDeployPlaystore:
stage: buildAndDeployPlaystore
stage: deploy
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:
- tags
except:
- branches
when: manual
when: on_success
needs:
- 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
+18 -6
View File
@@ -64,12 +64,6 @@
[submodule "app/src/stable/assets/sources/bilibili"]
path = app/src/stable/assets/sources/bilibili
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"]
path = app/src/stable/assets/sources/bitchute
url = ../plugins/bitchute.git
@@ -112,3 +106,21 @@
[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
+9 -14
View File
@@ -181,16 +181,16 @@ dependencies {
implementation 'com.google.code.gson:gson:2.13.2' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
//JS
implementation 'com.caoccao.javet:javet-v8-android:5.0.1'
implementation 'com.caoccao.javet:javet-v8-android:4.1.5'
//Exoplayer
implementation 'androidx.media3:media3-exoplayer:1.8.0'
implementation 'androidx.media3:media3-exoplayer-dash:1.8.0'
implementation 'androidx.media3:media3-ui:1.8.0'
implementation 'androidx.media3:media3-exoplayer-hls:1.8.0'
implementation 'androidx.media3:media3-exoplayer-rtsp:1.8.0'
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.8.0'
implementation 'androidx.media3:media3-transformer:1.8.0'
implementation 'androidx.media3:media3-exoplayer:1.9.0'
implementation 'androidx.media3:media3-exoplayer-dash:1.9.0'
implementation 'androidx.media3:media3-ui:1.9.0'
implementation 'androidx.media3:media3-exoplayer-hls:1.9.0'
implementation 'androidx.media3:media3-exoplayer-rtsp:1.9.0'
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.9.0'
implementation 'androidx.media3:media3-transformer:1.9.0'
implementation 'androidx.navigation:navigation-fragment-ktx:2.9.6'
implementation 'androidx.navigation:navigation-ui-ktx:2.9.6'
implementation 'androidx.media:media:1.7.1'
@@ -206,6 +206,7 @@ dependencies {
implementation 'com.google.zxing:core:3.5.3'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
implementation 'com.caverock:androidsvg-aar:1.4'
implementation 'androidx.webkit:webkit:1.15.0'
//Protobuf
implementation 'com.google.protobuf:protobuf-javalite:4.33.0'
@@ -230,10 +231,4 @@ dependencies {
testImplementation "org.mockito:mockito-core:5.20.0"
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
//Rust casting SDK
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.3.1') {
// Polycentricandroid includes this
exclude group: 'net.java.dev.jna'
}
}
@@ -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)
}
}
+24 -1
View File
@@ -29,6 +29,8 @@
android:supportsRtl="true"
android:theme="@style/Theme.FutoVideo"
android:usesCleartextTraffic="true"
tools:replace="android:enableOnBackInvokedCallback"
android:enableOnBackInvokedCallback="false"
tools:targetApi="31"
android:largeHeap="true">
<provider
@@ -58,9 +60,10 @@
<activity
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:theme="@style/Theme.FutoVideo.NoActionBar"
android:windowSoftInputMode="adjustPan"
android:launchMode="singleInstance"
android:resizeableActivity="true"
android:supportsPictureInPicture="true">
@@ -245,5 +248,25 @@
android:name=".activities.PolycentricModerationActivity"
android:exported="false"
android:screenOrientation="portrait" />
<activity
android:name=".activities.QRCodeFullscreenActivity"
android:screenOrientation="sensorPortrait"
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>
</manifest>
+15 -12
View File
@@ -1025,18 +1025,21 @@
let settingsToUse = __DEV_SETTINGS ?? {};
if (true) {
for (let setting of this.Plugin?.currentPlugin?.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;
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;
}
}
}
}
+7
View File
@@ -415,6 +415,8 @@ class VideoUrlSource {
this.url = obj.url;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
}
}
class VideoUrlWidevineSource extends VideoUrlSource {
@@ -512,6 +514,8 @@ class HLSSource {
this.language = obj.language;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
}
}
class DashSource {
@@ -525,6 +529,8 @@ class DashSource {
this.language = obj.language;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
this.language = obj?.language;
this.original = obj?.original;
}
}
class DashWidevineSource extends DashSource {
@@ -550,6 +556,7 @@ class DashManifestRawSource {
this.language = obj.language ?? Language.UNKNOWN;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
this.original = obj?.original;
}
}
@@ -118,14 +118,13 @@ inline fun <reified T> V8ValueArray.expectV8Variants(config: IV8PluginConfig, co
inline fun V8Plugin.ensureIsBusy() {
this.let {
if (!it.isThreadAlreadyBusy()) {
//throw IllegalStateException("Tried to access V8Plugin without busy");
val stacktrace = Thread.currentThread().stackTrace;
Logger.w("Extensions_V8",
"V8 USE OUTSIDE BUSY: " + stacktrace.drop(3)?.firstOrNull().toString() +
", " + stacktrace.drop(4)?.firstOrNull().toString() +
", " + stacktrace.drop(5)?.firstOrNull()?.toString() +
", " + stacktrace.drop(6)?.firstOrNull()?.toString()
);
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);
}
}
}
@@ -136,8 +135,7 @@ inline fun V8Value.ensureIsBusy() {
}
inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextName: String): T {
if(false)
ensureIsBusy();
ensureIsBusy();
return when(T::class) {
String::class -> this.expectOrThrow<V8ValueString>(config, contextName).value as T;
Int::class -> {
@@ -186,10 +184,14 @@ inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextN
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> {
if(obj == null)
return hashMapOf();
obj.ensureIsBusy();
val map = hashMapOf<String, String>();
for(prop in obj.ownPropertyNames.keys.map { obj.ownPropertyNames.get<V8Value>(it).toString() })
map.put(prop, obj.getString(prop));
@@ -203,21 +205,27 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
plugin.busy {
this.register(object: IV8ValuePromise.IListener {
override fun onFulfilled(p0: V8Value?) {
if(p0 is V8ValueError)
promiseException = ScriptExecutionException(plugin.config, p0.message);
else {
if(p0 is V8ValueObject)
p0.setWeak();
promiseResult = p0 as T;
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?) {
promiseException = p0?.toException(plugin.config);
plugin.busy {
promiseException = p0?.toException(plugin.config);
}
latch.countDown();
}
override fun onCatch(p0: V8Value?) {
promiseException = p0?.toException(plugin.config);
plugin.busy {
promiseException = p0?.toException(plugin.config);
}
latch.countDown();
}
});
@@ -229,20 +237,23 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
}
//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());
if(!promise.isPending) {
try {
Logger.i("V8", "V8Promise resolved synchronously");
if(promise.isFulfilled)
promiseResult = promise.getResult<T>();
else
promiseException = promise.getResult<V8Value>().toException(plugin.config);
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;
}
}
catch(ex: Throwable) {
promiseException = ex;
}
}
else {
} else {
plugin.unbusy {
latch.await();
}
@@ -266,15 +277,19 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
plugin.busy {
this.register(object: IV8ValuePromise.IListener {
override fun onFulfilled(p0: V8Value?) {
plugin.resolvePromise(promise);
underlyingDef.complete(p0 as T);
plugin.busy {
plugin.resolvePromise(promise);
underlyingDef.complete(p0 as T);
}
}
override fun onRejected(p0: V8Value?) {
try {
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));
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);
@@ -282,9 +297,11 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
}
override fun onCatch(p0: V8Value?) {
try {
plugin.resolvePromise(promise);
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onCatch promise not implemented..");
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
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);
@@ -300,6 +317,7 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
}
fun V8Value.toException(config: IV8PluginConfig): Throwable {
ensureIsBusy();
val p0 = this;
if(p0 is V8ValueObject) {
return V8Plugin.getExceptionFromPlugin(config, p0, null, null, null, "P:");
@@ -349,6 +367,7 @@ class V8Deferred<T>(val deferred: Deferred<T>, val estDuration: Int = -1): Defer
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()!!);
@@ -356,6 +375,7 @@ fun <T: V8Value> V8ValueObject.invokeV8(method: String, vararg obj: Any?): T {
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()!!);
@@ -363,6 +383,7 @@ fun <T: V8Value> V8ValueObject.invokeV8Async(method: String, vararg obj: Any?):
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()!!);
@@ -370,6 +391,7 @@ fun V8ValueObject.invokeV8Void(method: String, vararg obj: Any?): V8Value {
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()!!);
@@ -399,4 +421,4 @@ fun <T> IPager<T>.toList(): List<T> {
}
return list.toList();
}
}
@@ -6,6 +6,7 @@ import android.content.Intent
import android.net.Uri
import android.webkit.CookieManager
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.ManageTabsActivity
import com.futo.platformplayer.activities.PolycentricHomeActivity
@@ -312,7 +313,7 @@ class Settings : FragmentedStorageFileJson() {
var showSubscriptionGroups: Boolean = true;
@FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6)
var useSubscriptionExchange: Boolean = false;
var useSubscriptionExchange: Boolean = true;
@AdvancedField
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
@@ -387,7 +388,7 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.audio_languages)
var primaryLanguage: Int = 0;
fun getPrimaryLanguage(context: Context): String? {
fun getPrimaryLanguage(context: Context? = null): String? {
return when(primaryLanguage) {
0 -> "en";
1 -> "es";
@@ -400,10 +401,11 @@ class Settings : FragmentedStorageFileJson() {
8 -> "id";
9 -> "hi";
10 -> "ar";
11 -> "tu";
11 -> "tr";
12 -> "ru";
13 -> "pt";
14 -> "zh";
15 -> "it";
else -> null
}
}
@@ -429,6 +431,9 @@ class Settings : FragmentedStorageFileJson() {
6 -> 1.75f;
7 -> 2.0f;
8 -> 2.25f;
9 -> 2.5f;
10 -> 2.75f;
11 -> 3.0f;
else -> 1.0f;
};
@@ -722,11 +727,6 @@ class Settings : FragmentedStorageFileJson() {
@Serializable(with = FlexibleBooleanSerializer::class)
var allowLinkLocalIpv4: Boolean = false;
@AdvancedField
@FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6)
@Serializable(with = FlexibleBooleanSerializer::class)
var experimentalCasting: Boolean = false
/*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@DropdownFieldOptionsId(R.array.preferred_quality_array)
@@ -789,7 +789,6 @@ class Settings : FragmentedStorageFileJson() {
}
@FormField(R.string.plugins, FieldForm.GROUP, -1, 13)
@Transient
var plugins = Plugins();
@Serializable
class Plugins {
@@ -798,6 +797,9 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1)
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
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
var clearCookiesOnLogout: Boolean = true;
@@ -807,6 +809,12 @@ class Settings : FragmentedStorageFileJson() {
val cookieManager: CookieManager = CookieManager.getInstance();
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)
fun reinstallEmbedded() {
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
@@ -872,9 +880,9 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.auto_update_when_array)
var check: Int = 0;
@FormField(R.string.background_download, FieldForm.DROPDOWN, R.string.configure_if_background_download_should_be_used, 1)
@DropdownFieldOptionsId(R.array.background_download)
var backgroundDownload: Int = 0;
@FormField(R.string.background_download, FieldForm.TOGGLE, R.string.configure_if_background_download_should_be_used, 1)
//@DropdownFieldOptionsId(R.array.background_download)
var shouldBackgroundDownload: Boolean = true;
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
@DropdownFieldOptionsId(R.array.when_download)
@@ -954,18 +962,31 @@ class Settings : FragmentedStorageFileJson() {
class Backup {
@Serializable(with = OffsetDateTimeSerializer::class)
var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN;
var didAskAutoBackup: Boolean = true;
var didAskAutoBackup: Boolean = false;
var autoBackupEnabled: Boolean = false
var autoBackupPassword: String? = null;
fun shouldAutomaticBackup() = autoBackupPassword != null;
fun shouldAutomaticBackup() = autoBackupEnabled
@FormField(R.string.automatic_backup, FieldForm.READONLYTEXT, -1, 0)
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)
fun configureAutomaticBackup() {
UIDialogs.showAutomaticBackupDialog(StateApp.instance.activity!!, autoBackupPassword != null) {
SettingsFragment.currentView?.reloadSettings();
};
StateApp.instance.activity?.let { activity ->
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)
fun restoreAutomaticBackup() {
@@ -1049,6 +1070,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
var polycentricLocalCache: Boolean = true;
var showPrivacyModeDialog: Boolean = true;
}
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
@@ -19,6 +19,7 @@ import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@@ -165,27 +166,42 @@ class UIDialogs {
dialog.show()
}
fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) {
val dialogAction: ()->Unit = {
val dialog = AutomaticBackupDialog(context);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog); onClosed?.invoke() };
dialog.show();
};
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,
UIDialogs.Action(context.getString(R.string.cancel), {}), //To nothing
UIDialogs.Action(context.getString(R.string.override), {
dialogAction();
fun showAutomaticBackupDialog(context: Context, onClosed: (() -> Unit)? = null) {
val dialogAction: () -> Unit = {
val dialog = AutomaticBackupDialog(context)
registerDialogOpened(dialog)
dialog.setOnDismissListener {
registerDialogClosed(dialog)
onClosed?.invoke()
}
dialog.show()
}
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.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)
);
else {
dialogAction();
)
} else {
dialogAction()
}
}
fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) {
val dialog = AutomaticRestoreDialog(context, scope);
registerDialogOpened(dialog);
@@ -370,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 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 cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
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) {
@@ -403,13 +421,6 @@ class UIDialogs {
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) {
if(!store.hasMissingReconstructions())
onConcluded();
@@ -5,6 +5,7 @@ import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.view.View
import android.view.ViewGroup
import androidx.annotation.OptIn
@@ -74,6 +75,8 @@ import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream
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 {
companion object {
@@ -573,6 +576,51 @@ class UISlideOverlays {
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,
listOf((if (audioSources != null) listOf(SlideUpMenuItem(
container.context,
@@ -609,7 +657,13 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download));
},
invokeParent = false
)
).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
}
is JSDashManifestRawSource -> {
@@ -629,7 +683,13 @@ class UISlideOverlays {
menu?.setOk(container.context.getString(R.string.download));
},
invokeParent = false
)
).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
}
is IHLSManifestSource -> {
@@ -643,7 +703,13 @@ class UISlideOverlays {
showHlsPicker(video, it, it.url, container)
},
invokeParent = false
)
).apply {
videoSourceItems.add(this);
if(selectedLanguage != null) {
if(it.language != selectedLanguage)
this.visibility = View.GONE;
}
}
}
else -> {
@@ -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.res.Resources
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.icu.util.Output
import android.os.Build
import android.os.Looper
import android.os.OperationCanceledException
@@ -44,6 +42,9 @@ import java.util.*
import java.util.concurrent.ThreadLocalRandom
import java.util.zip.GZIPInputStream
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 ";
fun getRandomString(sizeOfRandomString: Int): String {
@@ -101,7 +102,7 @@ fun String.isHexColor(): Boolean {
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.getOutputStream(context: Context) = context.contentResolver.openOutputStream(this.uri);
@@ -114,23 +115,6 @@ fun DocumentFile.writeBytes(context: Context, byteArray: ByteArray) = context.co
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) {
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()
}
@@ -68,11 +68,15 @@ class CaptchaActivity : AppCompatActivity() {
intent.getStringExtra("body");
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.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 ->
_callback?.let {
_callback = 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)
}
}
}
@@ -61,11 +61,15 @@ class LoginActivity : AppCompatActivity() {
else throw IllegalStateException("No valid configuration?");
//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.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 ->
_callback?.let {
@@ -8,6 +8,7 @@ import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
@@ -33,7 +34,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.withStateAtLeast
import androidx.media3.common.util.UnstableApi
import com.curlbind.Libcurl
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.RootInsetsController
@@ -67,6 +67,7 @@ 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.PlaylistFragment
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsFragment
@@ -109,6 +110,7 @@ import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView
import com.futo.platformplayer.views.notification.NotificationOverlayView
import com.futo.polycentric.core.ApiMethods
import com.google.gson.JsonParser
import com.google.zxing.integration.android.IntentIntegrator
@@ -200,8 +202,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
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;
@@ -210,7 +214,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//State
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;
var fragBeforeOverlay: MainFragment? = null; private set;
@@ -243,17 +247,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
}
private val _notifPermission = "android.permission.POST_NOTIFICATIONS";
private val _notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
if (isGranted)
UIDialogs.toast(this, "Notification permission granted");
else
UIDialogs.toast(this, "Notification permission denied");
};
fun requestNotificationPermissions() {
_notificationPermissionLauncher?.launch(_notifPermission);
}
val mainId = UUID.randomUUID().toString().substring(0, 5)
@@ -329,6 +322,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
//Preload common files to memory
FragmentedStorage.get<SubscriptionStorage>();
FragmentedStorage.get<Settings>();
@@ -394,8 +391,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragLibraryVideos = LibraryVideosFragment.newInstance();
_fragLibraryFiles = LibraryFilesFragment.newInstance();
_fragLibrarySearch = LibrarySearchFragment.newInstance();
_fragNotifications = NotificationOverlayView.Frag();
_fragSettings = SettingsFragment.newInstance();
_fragDeveloper = DeveloperFragment.newInstance();
_fragLogin = LoginFragment.newInstance();
_fragBrowser = BrowserFragment.newInstance();
@@ -414,12 +413,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
updateSegmentPaddings();
};
_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 =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics);
else
}
else {
Logger.i(TAG, "onTransition Setting elevation lower");
_fragContainerOverlay.elevation =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
}
}
_fragVideoDetail.onCloseEvent.subscribe {
@@ -537,6 +541,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragLibrarySearch.topBar = _fragTopBarSearch;
_fragSettings.topBar = _fragTopBarNavigation;
_fragDeveloper.topBar = _fragTopBarNavigation;
_fragNotifications.topBar = _fragTopBarGeneral;
_fragBrowser.topBar = _fragTopBarNavigation;
@@ -562,7 +567,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
defaultTab.action(_fragBotBarMenu);
StateSubscriptions.instance;
fragCurrent.onShown(null, false);
fragCurrent?.onShown(null, false);
//Other stuff
rootView.progress = 0f;
@@ -617,6 +622,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
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 numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
@@ -1149,7 +1158,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
return;
if (!fragCurrent.onBackPressed())
if (!(fragCurrent?.onBackPressed() ?: true))
closeSegment();
}
@@ -1200,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
* A parameter can be provided which becomes available in the onShow of said fragment
@@ -1222,27 +1236,27 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return;
}
fragCurrent.onHide();
fragCurrent?.onHide();
if (segment.isMainView) {
var transaction = supportFragmentManager.beginTransaction();
if (segment.topBar != null) {
if (segment.topBar != fragCurrent.topBar) {
if (segment.topBar != fragCurrent?.topBar) {
transaction = transaction
.show(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)
transaction.hide(fragCurrent.topBar as Fragment);
} else if (fragCurrent?.topBar != null)
transaction.hide(fragCurrent?.topBar as Fragment);
transaction = transaction.replace(R.id.fragment_main, segment);
if (segment.hasBottomBar) {
if (!fragCurrent.hasBottomBar)
if (!(fragCurrent?.hasBottomBar ?: false))
transaction = transaction.show(_fragBotBarMenu);
} else {
if (fragCurrent.hasBottomBar)
if (fragCurrent?.hasBottomBar ?: false)
transaction = transaction.hide(_fragBotBarMenu);
}
transaction.commitNow();
@@ -1255,10 +1269,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
if (fragCurrent.isHistory && withHistory && _queue.lastOrNull() != fragCurrent)
_queue.add(Pair(fragCurrent, _parameterCurrent));
if (fragCurrent?.isHistory ?: false && withHistory && _queue.lastOrNull() != fragCurrent)
_queue.add(Pair(fragCurrent!!, _parameterCurrent));
if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory)
if (segment.isOverlay && !(fragCurrent?.isOverlay ?: false) && withHistory)// && fragCurrent.isHistory)
fragBeforeOverlay = fragCurrent;
fragCurrent = segment;
@@ -1289,11 +1303,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
navigate(last.first, last.second, false, true);
} else {
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
Logger.i(TAG, "Closing activity because _fragVideoDetail.state == closed");
finish();
} 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?", {
finish();
})
*/
}
}
}
@@ -1345,8 +1372,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
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");
}
}
@@ -1354,7 +1383,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private fun updateSegmentPaddings() {
var paddingBottom = 0f;
if (fragCurrent.hasBottomBar)
if (fragCurrent?.hasBottomBar ?: false)
paddingBottom += HEIGHT_MENU_DP;
_fragContainerOverlay.setPadding(
@@ -1371,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 requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
@@ -1497,4 +1543,4 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
return sourcesIntent;
}
}
}
}
@@ -13,15 +13,18 @@ import android.view.View
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateApp.Companion.withContext
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.activities.QRCodeFullscreenActivity
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.SignedEvent
import com.futo.polycentric.core.StorageTypeCRDTItem
@@ -29,8 +32,10 @@ import com.futo.polycentric.core.StorageTypeCRDTSetItem
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.toBase64Url
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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -41,11 +46,27 @@ import userpackage.Protocol.URLInfo
class PolycentricBackupActivity : AppCompatActivity() {
private lateinit var _buttonShare: BigButton;
private lateinit var _buttonCopy: BigButton;
private lateinit var _buttonExportFile: BigButton;
private lateinit var _imageQR: ImageView;
private lateinit var _exportBundle: String;
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?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
@@ -57,8 +78,10 @@ class PolycentricBackupActivity : AppCompatActivity() {
_buttonShare = findViewById(R.id.button_share)
_buttonCopy = findViewById(R.id.button_copy)
_buttonExportFile = findViewById(R.id.button_export_file)
_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 {
finish();
@@ -66,14 +89,23 @@ class PolycentricBackupActivity : AppCompatActivity() {
_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
lifecycleScope.launch {
val bundle = withContext(Dispatchers.IO) { createExportBundle() }
_exportBundle = bundle
Logger.i(TAG, "Export bundle created, length: ${bundle.length}")
try {
val pair = withContext(Dispatchers.IO) {
val bundle = createExportBundle()
if (!isContentSuitableForQRCode(bundle)) {
throw Exception("Data too big for QR code generation")
}
val dimension = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics
).toInt()
@@ -81,18 +113,35 @@ class PolycentricBackupActivity : AppCompatActivity() {
Pair(bundle, qr)
}
_exportBundle = pair.first
_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) {
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e)
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
_textQR.visibility = View.INVISIBLE
_buttonShare.visibility = View.INVISIBLE
_buttonCopy.visibility = View.INVISIBLE
} finally {
_loader.visibility = View.GONE
}
@@ -108,11 +157,29 @@ class PolycentricBackupActivity : AppCompatActivity() {
val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle);
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 {
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height);
return bitMatrixToBitmap(bitMatrix);
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 {
@@ -203,7 +270,8 @@ class PolycentricBackupActivity : AppCompatActivity() {
.setBody(exportBundle.toByteString())
.build();
return "polycentric://" + urlInfo.toByteArray().toBase64Url()
val data = urlInfo.toByteArray()
return "polycentric://" + data.toBase64Url()
}
companion object {
@@ -32,100 +32,166 @@ import userpackage.Protocol
import userpackage.Protocol.ExportBundle
class PolycentricImportProfileActivity : AppCompatActivity() {
private lateinit var _buttonHelp: ImageButton;
private lateinit var _buttonScanProfile: LinearLayout;
private lateinit var _buttonImportProfile: LinearLayout;
private lateinit var _editProfile: EditText;
private lateinit var _loaderOverlay: LoaderOverlay;
private lateinit var _buttonHelp: ImageButton
private lateinit var _buttonScanProfile: LinearLayout
private lateinit var _buttonImportFile: LinearLayout
private lateinit var _buttonImportProfile: LinearLayout
private lateinit var _editProfile: EditText
private lateinit var _loaderOverlay: LoaderOverlay
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
scanResult?.let {
if (it.contents != null) {
val scannedUrl = it.contents
import(scannedUrl)
private val _qrCodeResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult =
IntentIntegrator.parseActivityResult(result.resultCode, result.data)
scanResult?.let {
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?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_import_profile);
setNavigationBarColorAndIcons();
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_polycentric_import_profile)
setNavigationBarColorAndIcons()
_buttonHelp = findViewById(R.id.button_help);
_buttonScanProfile = findViewById(R.id.button_scan_profile);
_buttonImportProfile = findViewById(R.id.button_import_profile);
_loaderOverlay = findViewById(R.id.loader_overlay);
_editProfile = findViewById(R.id.edit_profile);
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
finish();
};
_buttonHelp = findViewById(R.id.button_help)
_buttonScanProfile = findViewById(R.id.button_scan_profile)
_buttonImportFile = findViewById(R.id.button_import_file)
_buttonImportProfile = findViewById(R.id.button_import_profile)
_loaderOverlay = findViewById(R.id.loader_overlay)
_editProfile = findViewById(R.id.edit_profile)
findViewById<ImageButton>(R.id.button_back).setOnClickListener { finish() }
_buttonHelp.setOnClickListener {
startActivity(Intent(this, PolycentricWhyActivity::class.java));
};
startActivity(Intent(this, PolycentricWhyActivity::class.java))
}
_buttonScanProfile.setOnClickListener {
val integrator = IntentIntegrator(this)
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt(getString(R.string.scan_a_qr_code))
integrator.setOrientationLocked(true);
integrator.setOrientationLocked(true)
integrator.setCameraId(0)
integrator.setBeepEnabled(false)
integrator.setBarcodeImageEnabled(true)
integrator.setCaptureActivity(QRCaptureActivity::class.java);
integrator.setCaptureActivity(QRCaptureActivity::class.java)
_qrCodeResultLauncher.launch(integrator.createScanIntent())
};
}
_buttonImportFile.setOnClickListener { _filePickerLauncher.launch("text/plain") }
_buttonImportProfile.setOnClickListener {
if (_editProfile.text.isEmpty()) {
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data));
return@setOnClickListener;
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data))
return@setOnClickListener
}
import(_editProfile.text.toString());
};
import(_editProfile.text.toString())
}
val url = intent.getStringExtra("url");
val url = intent.getStringExtra("url")
if (url != null) {
import(url);
import(url)
}
}
private fun import(url: String) {
if (!url.startsWith("polycentric://")) {
UIDialogs.toast(this, getString(R.string.not_a_valid_url));
return;
UIDialogs.toast(this, getString(R.string.not_a_valid_url))
return
}
_loaderOverlay.show()
lifecycleScope.launch(Dispatchers.IO) {
try {
val data = url.substring("polycentric://".length).base64UrlToByteArray();
val urlInfo = Protocol.URLInfo.parseFrom(data);
val data = url.substring("polycentric://".length).base64UrlToByteArray()
val urlInfo = Protocol.URLInfo.parseFrom(data)
if (urlInfo.urlType != 3L) {
throw Exception("Expected urlInfo struct of type ExportBundle")
}
val exportBundle = ExportBundle.parseFrom(urlInfo.body);
val keyPair = KeyPair.fromProto(exportBundle.keyPair);
val exportBundle = ExportBundle.parseFrom(urlInfo.body)
val keyPair = KeyPair.fromProto(exportBundle.keyPair)
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey)
if (existingProcessSecret != null) {
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());
Store.instance.addProcessSecret(processSecret);
val processSecret = ProcessSecret(keyPair, Process.random())
Store.instance.addProcessSecret(processSecret)
try {
PolycentricStorage.instance.addProcessSecret(processSecret)
@@ -133,37 +199,43 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
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) {
try {
val se = SignedEvent.fromProto(e);
Store.instance.putSignedEvent(se);
val se = SignedEvent.fromProto(e)
Store.instance.putSignedEvent(se)
} catch (e: Throwable) {
Logger.w(TAG, "Ignored invalid event", e);
Logger.w(TAG, "Ignored invalid event", e)
}
}
StatePolycentric.instance.setProcessHandle(processHandle);
processHandle.fullyBackfillClient(ApiMethods.SERVER);
StatePolycentric.instance.setProcessHandle(processHandle)
processHandle.fullyBackfillClient(ApiMethods.SERVER)
withContext(Dispatchers.Main) {
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
finish();
startActivity(
Intent(
this@PolycentricImportProfileActivity,
PolycentricProfileActivity::class.java
)
)
finish()
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to import profile", e);
Logger.w(TAG, "Failed to import profile", e)
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 {
withContext(Dispatchers.Main) {
_loaderOverlay.hide();
}
withContext(Dispatchers.Main) { _loaderOverlay.hide() }
}
}
}
companion object {
private const val TAG = "PolycentricImportProfileActivity";
private const val TAG = "PolycentricImportProfileActivity"
}
}
}
@@ -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
}
}
@@ -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.HttpHeaders
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.parsers.HttpResponseParser
import com.futo.platformplayer.readLine
@@ -27,6 +28,7 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
private var _injectReferer = false;
private val _client = ManagedHttpClient();
private var _requestModifier: ((String, Map<String, String>) -> IRequest)? = null;
override fun handle(context: HttpContext) {
if (useTcp) {
@@ -43,21 +45,33 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
for (injectHeader in _injectRequestHeader)
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)
proxyHeaders.put("Host", parsed.host!!);
if(_injectReferer)
proxyHeaders.put("Referer", targetUrl);
proxyHeaders.put("Referer", url);
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"));
val resp = when (useMethod) {
"GET" -> _client.get(targetUrl, proxyHeaders);
"POST" -> _client.post(targetUrl, content ?: "", proxyHeaders);
"HEAD" -> _client.head(targetUrl, proxyHeaders)
else -> _client.requestMethod(useMethod, targetUrl, proxyHeaders);
"GET" -> _client.get(url, proxyHeaders);
"POST" -> _client.post(url, content ?: "", proxyHeaders);
"HEAD" -> _client.head(url, proxyHeaders)
else -> _client.requestMethod(useMethod, url, proxyHeaders);
};
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)
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)
proxyHeaders.put("Host", parsed.host!!);
if(_injectReferer)
proxyHeaders.put("Referer", targetUrl);
proxyHeaders.put("Referer", url);
val useMethod = if (method == "inherit") context.method else method;
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");
return this;
}
fun withRequestModifier(modifier: (String, Map<String, String>) -> IRequest) : HttpProxyHandler {
_requestModifier = modifier;
return this;
}
companion object {
private const val TAG = "HttpProxyHandler"
@@ -12,6 +12,9 @@ class DashManifestSource : IVideoSource, IDashManifestSource {
override var priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
constructor(url : String) {
this.url = url;
}
@@ -12,6 +12,9 @@ class HLSManifestSource : IVideoSource, IHLSManifestSource {
override var priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
constructor(url : String) {
this.url = url;
}
@@ -14,6 +14,9 @@ class HLSVariantVideoUrlSource(
override val priority: Boolean,
val url: String
) : IVideoUrlSource {
override val language: String? = null;
override val original: Boolean? = false;
override fun getVideoUrl(): String {
return url
}
@@ -9,4 +9,6 @@ interface IVideoSource {
val bitrate : Int?;
val duration: Long;
val priority: Boolean;
val language: String?;
val original: Boolean?;
}
@@ -16,6 +16,10 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
override var priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
val filePath : String;
val fileSize : Long;
@@ -19,6 +19,9 @@ open class VideoUrlSource(
) : IVideoUrlSource, IStreamMetaDataSource {
override var streamMetaData: StreamMetaData? = null;
override val language: String? = null;
override val original: Boolean? = false;
override fun getVideoUrl() : String {
return url;
}
@@ -73,10 +73,10 @@ open class LocalVideoDetails(
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
(LocalVideoUnMuxedSourceDescriptor(
arrayOf(),
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name))
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name, duration))
))
else (LocalVideoMuxedSourceDescriptor(
LocalVideoContentSource(url, mimeType ?: "", name)
LocalVideoContentSource(url, mimeType ?: "", name, duration)
))
);
override val preview: ISerializedVideoSourceDescriptor? = null;
@@ -55,6 +55,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.engine.exceptions.ScriptValidationException
import com.futo.platformplayer.engine.packages.PackageBridge
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.states.AnnouncementType
@@ -153,9 +154,10 @@ open class JSClient : IPlatformClient {
_captcha = descriptor.getCaptchaData();
flags = descriptor.flags.toTypedArray();
_httpClient = JSHttpClient(this, null, _captcha);
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
_httpClient = JSHttpClient(this, null, _captcha, config);
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
_plugin.bridge.descriptor = descriptor;
_plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js");
@@ -186,9 +188,10 @@ open class JSClient : IPlatformClient {
_captcha = descriptor.getCaptchaData();
flags = descriptor.flags.toTypedArray();
_httpClient = JSHttpClient(this, null, _captcha);
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
_httpClient = JSHttpClient(this, null, _captcha, config);
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
_plugin.bridge.descriptor = descriptor;
_plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js");
_plugin.withScript(script);
@@ -485,13 +488,14 @@ open class JSClient : IPlatformClient {
if (_peekChannelTypes != null) {
return _peekChannelTypes!!;
}
val arr: V8ValueArray = plugin.executeTyped("source.getPeekChannelTypes()");
_peekChannelTypes = arr.keys.mapNotNull {
val str = arr.get<V8ValueString>(it);
return@mapNotNull str.value;
};
return _peekChannelTypes ?: listOf();
return busy {
val arr: V8ValueArray = plugin.executeTyped("source.getPeekChannelTypes()");
_peekChannelTypes = arr.keys.mapNotNull {
val str = arr.get<V8ValueString>(it);
return@mapNotNull str.value;
};
return@busy _peekChannelTypes ?: listOf();
}
}
catch(ex: Throwable) {
announcePluginUnhandledException("getPeekChannelTypes", ex);
@@ -520,10 +524,12 @@ open class JSClient : IPlatformClient {
if(!capabilities.hasGetChannelUrlByClaim)
throw IllegalStateException("This plugin does not support channel url by claim");
val value = plugin.executeTyped<V8Value>("source.getChannelUrlByClaim(${claimType}, ${Json.encodeToString(claimValues)})");
if(value !is V8ValueString)
return null;
return value.value;
return busy {
val value = plugin.executeTyped<V8Value>("source.getChannelUrlByClaim(${claimType}, ${Json.encodeToString(claimValues)})");
if(value !is V8ValueString)
return@busy null;
return@busy value.value;
}
}
@JSOptional
@JSDocs(12, "source.getChannelTemplateByClaimMap()", "Get a map for every supported claimtype mapping field to urls")
@@ -533,28 +539,30 @@ open class JSClient : IPlatformClient {
if(!capabilities.hasGetChannelTemplateByClaimMap)
throw IllegalStateException("This plugin does not support channel template by claim map");
val value = plugin.executeTyped<V8Value>("source.getChannelTemplateByClaimMap()");
if(value !is V8ValueObject)
return mapOf();
return busy {
val value = plugin.executeTyped<V8Value>("source.getChannelTemplateByClaimMap()");
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;
for(key in keys.toArray()) {
if(key is V8ValueInteger) {
val map = value.get<V8ValueObject>(key);
val mapKeys = map.ownPropertyNames;
val keys = value.ownPropertyNames;
for(key in keys.toArray()) {
if(key is V8ValueInteger) {
val map = value.get<V8ValueObject>(key);
val mapKeys = map.ownPropertyNames;
claimTypes[key.value] = mapKeys.toArray().filter {
it is V8ValueInteger
}.associate {
val mapKey = (it as V8ValueInteger).value;
return@associate Pair(mapKey, map.getString(mapKey));
};
claimTypes[key.value] = mapKeys.toArray().filter {
it is V8ValueInteger
}.associate {
val mapKey = (it as V8ValueInteger).value;
return@associate Pair(mapKey, map.getString(mapKey));
};
}
}
channelClaimTemplates = claimTypes.toMap();
return@busy claimTypes;
}
channelClaimTemplates = claimTypes.toMap();
return claimTypes;
}
@@ -695,27 +703,33 @@ open class JSClient : IPlatformClient {
@JSDocs(22, "source.getUserPlaylists()", "Gets the playlist of the current user")
override fun getUserPlaylists(): Array<String> {
ensureEnabled();
return plugin.executeTyped<V8ValueArray>("source.getUserPlaylists()")
.toArray()
.map { (it as V8ValueString).value }
.toTypedArray();
return busy {
return@busy plugin.executeTyped<V8ValueArray>("source.getUserPlaylists()")
.toArray()
.map { (it as V8ValueString).value }
.toTypedArray();
}
}
@JSOptional
@JSDocs(23, "source.getUserSubscriptions()", "Gets the subscriptions of the current user")
override fun getUserSubscriptions(): Array<String> {
ensureEnabled();
return plugin.executeTyped<V8ValueArray>("source.getUserSubscriptions()")
.toArray()
.map { (it as V8ValueString).value }
.toTypedArray();
return busy {
return@busy plugin.executeTyped<V8ValueArray>("source.getUserSubscriptions()")
.toArray()
.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 JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()"));
return isBusyWith("getUserHistory") {
return@isBusyWith JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()"));
}
}
fun validate() {
@@ -891,4 +905,4 @@ open class JSClient : IPlatformClient {
return name;
}
}
}
}
@@ -5,9 +5,9 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
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 {
return "(headers: '$headers', cookieString: '$cookieMap')";
return "(headers: '$headers', cookieString: '$cookieMap', userAgent: '$userAgent')";
}
fun toEncrypted(): String{
@@ -15,23 +15,25 @@ data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? =
}
private fun serialize(): String {
return Json.encodeToString(SerializedAuth(cookieMap, headers));
return Json.encodeToString(SerializedAuth(cookieMap, headers, userAgent));
}
companion object {
val TAG = "SourceAuth";
private val _json = Json { ignoreUnknownKeys = true };
fun fromEncrypted(encrypted: String?): SourceAuth? {
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
}
private fun deserialize(str: String): SourceAuth {
val data = Json.decodeFromString<SerializedAuth>(str);
return SourceAuth(data.cookieMap, data.headers);
val data = _json.decodeFromString<SerializedAuth>(str);
return SourceAuth(data.cookieMap, data.headers, data.userAgent);
}
}
@Serializable
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.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 {
return "(headers: '$headers', cookieString: '$cookieMap')";
return "(headers: '$headers', cookieString: '$cookieMap', userAgent: '$userAgent')";
}
fun toEncrypted(): String{
@@ -15,23 +15,25 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
}
private fun serialize(): String {
return Json.encodeToString(SerializedCaptchaData(cookieMap, headers));
return Json.encodeToString(SerializedCaptchaData(cookieMap, headers, userAgent));
}
companion object {
val TAG = "SourceCaptchaData";
private val _json = Json { ignoreUnknownKeys = true };
fun fromEncrypted(encrypted: String?): SourceCaptchaData? {
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
}
fun deserialize(str: String): SourceCaptchaData {
val data = Json.decodeFromString<SerializedCaptchaData>(str);
return SourceCaptchaData(data.cookieMap, data.headers);
val data = _json.decodeFromString<SerializedCaptchaData>(str);
return SourceCaptchaData(data.cookieMap, data.headers, data.userAgent);
}
}
@Serializable
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)
}
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.platforms.js
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.util.Dictionary
@Serializable
@@ -27,7 +28,7 @@ class SourcePluginAuthConfig(
val details: String? = null,
val once: Boolean? = true
) {
@Contextual
@Transient
private var _regex: Regex? = null;
fun getRegex(): Regex {
@@ -23,7 +23,7 @@ class SourcePluginConfig(
//Script
val repositoryUrl: String? = null,
val scriptUrl: String = "",
val version: Int = -1,
var version: Int = -1,
val iconUrl: String? = null,
var id: String = UUID.randomUUID().toString(),
@@ -61,6 +61,11 @@ class SourcePluginConfig(
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, 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? {
if(url == null)
return null;
@@ -165,6 +170,12 @@ class SourcePluginConfig(
"Unrestricted Http Header access",
"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;
}
@@ -224,7 +235,8 @@ class SourcePluginConfig(
val variable: String? = null,
val dependency: 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;
}
@@ -23,6 +23,7 @@ import java.util.UUID
class JSHttpClient : ManagedHttpClient {
private val _jsClient: JSClient?;
private val _jsConfig: SourcePluginConfig?;
val config get() = _jsConfig
private val _auth: SourceAuth?;
private val _captcha: SourceCaptchaData?;
@@ -254,6 +255,76 @@ class JSHttpClient : ManagedHttpClient {
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> {
val cookieKey = cookie.substring(0, cookie.indexOf("="));
@@ -21,6 +21,7 @@ import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.states.StateDeveloper
open class JSArticle(
@@ -34,7 +35,7 @@ open class JSArticle(
obj.getOrDefault<String>(config, "summary", "PlatformArticle", "") ?: ""
override val thumbnails: Thumbnails? =
if (obj.has("thumbnails"))
if (obj.getSourcePlugin()?.busy { obj.has("thumbnails") } ?: obj.has("thumbnails"))
Thumbnails.fromV8(
config,
obj.getOrThrow<V8ValueObject>(config, "thumbnails", "PlatformArticle")
@@ -31,18 +31,20 @@ open class JSArticleDetails(
final override val contentType: ContentType = ContentType.ARTICLE
private val _hasGetComments: Boolean = _content.has("getComments")
private val _hasGetContentRecommendations: Boolean = _content.has("getContentRecommendations")
private val _hasGetComments: Boolean = client.busy { _content.has("getComments") }
private val _hasGetContentRecommendations: Boolean = client.busy { _content.has("getContentRecommendations") }
override val rating: IRating =
override val rating: IRating = client.busy {
obj.getOrDefault<V8ValueObject>(client.config, "rating", "PlatformArticle", null)
?.let { IRating.fromV8(client.config, it, "PlatformArticle") }
?: RatingLikes(0)
}
override val summary: String =
override val summary: String = client.busy {
_content.getOrThrow(client.config, "summary", "PlatformArticle")
}
override val thumbnails: Thumbnails? =
override val thumbnails: Thumbnails? = client.busy {
if (_content.has("thumbnails"))
Thumbnails.fromV8(
client.config,
@@ -50,14 +52,19 @@ open class JSArticleDetails(
)
else
null
}
override val segments: List<IJSArticleSegment> =
override val segments: List<IJSArticleSegment> = client.busy {
obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", "PlatformArticle")
?.mapNotNull { fromV8Segment(client, it) }
?: emptyList()
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
if(!_hasGetComments || _content.isClosed)
val canGetComments = this.client.busy {
_hasGetComments && !_content.isClosed
}
if(!canGetComments)
return null;
if(client is DevJSClient)
@@ -73,7 +80,10 @@ open class JSArticleDetails(
override fun getPlaybackTracker(): IPlaybackTracker? = null;
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
if(!_hasGetContentRecommendations || _content.isClosed)
val canGetContentRecommendations = this.client.busy {
_hasGetContentRecommendations && !_content.isClosed
}
if(!canGetContentRecommendations)
return null;
if(client is DevJSClient)
@@ -87,25 +97,31 @@ open class JSArticleDetails(
}
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return JSContentPager(_pluginConfig, client, contentPager);
return client.busy {
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return@busy JSContentPager(_pluginConfig, client, contentPager);
}
}
private fun getCommentsJS(client: JSClient): JSCommentPager {
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return JSCommentPager(_pluginConfig, client, commentPager);
return client.busy {
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return@busy JSCommentPager(_pluginConfig, client, commentPager);
}
}
companion object {
fun fromV8Segment(client: JSClient, obj: V8ValueObject): IJSArticleSegment? {
if(!obj.has("type"))
throw IllegalArgumentException("Object missing type field");
return when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) {
SegmentType.TEXT -> JSTextSegment(client, obj);
SegmentType.IMAGES -> JSImagesSegment(client, obj);
SegmentType.HEADER -> JSHeaderSegment(client, obj);
SegmentType.NESTED -> JSNestedSegment(client, obj);
else -> null;
return client.busy {
if(!obj.has("type"))
throw IllegalArgumentException("Object missing type field");
return@busy when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) {
SegmentType.TEXT -> JSTextSegment(client, obj);
SegmentType.IMAGES -> JSImagesSegment(client, obj);
SegmentType.HEADER -> JSHeaderSegment(client, obj);
SegmentType.NESTED -> JSNestedSegment(client, obj);
else -> null;
}
}
}
}
@@ -176,4 +192,4 @@ class JSNestedSegment: IJSArticleSegment {
val nestedObj = obj.getOrThrow<V8ValueObject>(client.config, "nested", contextName, false);
nested = IJSContent.fromV8(client, nestedObj);
}
}
}
@@ -46,23 +46,45 @@ class JSComment : IPlatformComment {
_comment = obj;
_plugin = plugin;
val contextName = "Comment";
contextUrl = _comment!!.getOrThrow(config, "contextUrl", contextName);
author = PlatformAuthorLink.fromV8(_config!!, _comment!!.getOrThrow(config, "author", contextName));
message = _comment!!.getOrThrow(config, "message", contextName);
rating = IRating.fromV8(config, _comment!!.getOrThrow(config, "rating", contextName));
date = _comment!!.getOrThrowNullable<Int>(config, "date", contextName)?.let { OffsetDateTime.of(LocalDateTime.ofEpochSecond(it.toLong(), 0, ZoneOffset.UTC), ZoneOffset.UTC) }
replyCount = _comment!!.getOrThrowNullable(config, "replyCount", contextName);
context = _comment!!.getOrDefault(config, "context", contextName, hashMapOf()) ?: hashMapOf();
_hasGetReplies = _comment!!.has("getReplies");
var parsedContextUrl: String? = null;
var parsedAuthor: PlatformAuthorLink? = null;
var parsedMessage: String? = null;
var parsedRating: IRating? = null;
var parsedDate: OffsetDateTime? = null;
var parsedReplyCount: Int? = null;
var parsedContext: Map<String, String>? = null;
var parsedHasGetReplies = false;
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>? {
if(!_hasGetReplies)
return null;
val obj = _comment!!.invokeV8<V8ValueObject>("getReplies", arrayOf<Any>());
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,6 +11,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.decodeUnicode
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.logging.Logger
import java.time.LocalDateTime
import java.time.OffsetDateTime
@@ -23,7 +24,8 @@ open class JSContent(
override val contentType: ContentType = ContentType.UNKNOWN
protected val _hasGetDetails: Boolean = _content.has("getDetails")
protected val _hasGetDetails: Boolean =
_content.getSourcePlugin()?.busy { _content.has("getDetails") } ?: false
override val id: PlatformID =
PlatformID.fromV8(_pluginConfig, _content.getOrThrow(_pluginConfig, "id", CTX))
@@ -41,7 +41,9 @@ abstract class JSPager<T> : IPager<T> {
}
override fun hasMorePages(): Boolean {
return _hasMorePages && !pager.isClosed;
return plugin.getUnderlyingPlugin().busy {
_hasMorePages && !pager.isClosed;
}
}
override fun nextPage() {
@@ -91,4 +93,4 @@ abstract class JSPager<T> : IPager<T> {
}
abstract fun convertResult(obj: V8ValueObject): T;
}
}
@@ -15,6 +15,7 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.states.StateDeveloper
@@ -30,52 +31,79 @@ class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails {
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);
textType = TextType.fromInt((obj.getOrDefault<Int>(config, "textType", contextName, null) ?: 0));
content = obj.getOrDefault(config, "content", contextName, "") ?: "";
val parse = {
val contextName = "PlatformPostDetails";
_hasGetComments = _content.has("getComments");
_hasGetContentRecommendations = _content.has("getContentRecommendations");
parsedRating = obj.getOrDefault<V8ValueObject>(config, "rating", contextName, null)?.let { IRating.fromV8(config, it, contextName) } ?: RatingLikes(0);
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>? {
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;
if(client is DevJSClient)
return StateDeveloper.instance.handleDevCall(client.devID, "videoDetail.getComments()") {
return@handleDevCall getCommentsJS(client);
}
else if(client is JSClient)
return getCommentsJS(client);
return null;
return getCommentsJS(jsClient);
}
override fun getPlaybackTracker(): IPlaybackTracker? = null;
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;
if(client is DevJSClient)
return StateDeveloper.instance.handleDevCall(client.devID, "postDetail.getContentRecommendations()") {
return@handleDevCall getContentRecommendationsJS(client);
}
else if(client is JSClient)
return getContentRecommendationsJS(client);
return null;
return getContentRecommendationsJS(jsClient);
}
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return JSContentPager(_pluginConfig, client, contentPager);
return client.busy {
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
return@busy JSContentPager(_pluginConfig, client, contentPager);
}
}
private fun getCommentsJS(client: JSClient): JSCommentPager {
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return JSCommentPager(_pluginConfig, client, commentPager);
return client.busy {
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
return@busy JSCommentPager(_pluginConfig, client, commentPager);
}
}
}
}
@@ -17,11 +17,14 @@ import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.invokeV8Void
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDeveloper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import java.util.Base64
class JSRequestExecutor {
class JSRequestExecutor: AutoCloseable {
private val _plugin: JSClient;
private val _config: IV8PluginConfig;
private var _executor: V8ValueObject;
@@ -29,26 +32,35 @@ class JSRequestExecutor {
private val hasCleanup: Boolean;
private var _cleanLock = Any();
private var _cleaned: Boolean = false;
constructor(plugin: JSClient, executor: V8ValueObject) {
this._plugin = plugin;
this._executor = executor;
this._config = plugin.config;
val config = plugin.config;
urlPrefix = executor.getOrDefault(config, "urlPrefix", "RequestExecutor", null);
var parsedUrlPrefix: String? = null;
var parsedHasCleanup = false;
plugin.busy {
parsedUrlPrefix = executor.getOrDefault(config, "urlPrefix", "RequestExecutor", null);
if(!executor.has("executeRequest"))
throw ScriptImplementationException(config, "RequestExecutor is missing executeRequest", null);
hasCleanup = executor.has("cleanup");
if(!executor.has("executeRequest"))
throw ScriptImplementationException(config, "RequestExecutor is missing executeRequest", null);
parsedHasCleanup = executor.has("cleanup");
}
urlPrefix = parsedUrlPrefix;
hasCleanup = parsedHasCleanup;
}
//TODO: Executor properties?
@Throws(ScriptException::class)
open fun executeRequest(method: String, url: String, body: ByteArray?, headers: Map<String, String>): ByteArray {
if (_executor.isClosed)
throw IllegalStateException("Executor object is closed");
return _plugin.getUnderlyingPlugin().busy {
if (_executor.isClosed)
throw IllegalStateException("Executor object is closed");
val result = if(_plugin is DevJSClient)
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
@@ -102,8 +114,14 @@ class JSRequestExecutor {
open fun cleanup() {
if (!hasCleanup || _executor.isClosed)
return;
_plugin.busy {
synchronized(_cleanLock) {
if (!hasCleanup || _executor.isClosed || _cleaned)
return@busy;
_cleaned = true;
}
}
Logger.i("JSRequestExecutor", "JSRequestExecutor cleanup requested");
_plugin.busy {
if(_plugin is DevJSClient)
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
@@ -125,9 +143,25 @@ class JSRequestExecutor {
}
}
protected fun finalize() {
override fun close() {
cleanup();
}
fun closeAsync() {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
try {
close();
}
catch(ex: Throwable) {
Logger.e("JSRequestExecutor", "Cleanup failed");
}
}
}
/*
protected fun finalize() {
cleanup();
}*/
}
//TODO: are these available..?
@@ -137,4 +171,4 @@ class ExecutorParameters {
var rangeEnd: Int = -1;
var segment: Int = -1;
}
}
@@ -36,11 +36,11 @@ class JSRequestModifier: IRequestModifier {
}
override fun modifyRequest(url: String, headers: Map<String, String>): IRequest {
if (_modifier.isClosed) {
return Request(url, headers);
}
return _plugin.busy {
if (_modifier.isClosed) {
return@busy Request(url, headers);
}
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSRequestModifier", "builder.modifyRequest()") {
_modifier.invokeV8("modifyRequest", url, headers);
} as V8ValueObject;
@@ -53,4 +53,4 @@ class JSRequestModifier: IRequestModifier {
data class Request(override val url: String, override val headers: Map<String, String>) : IRequest;
}
}
@@ -5,6 +5,7 @@ import com.caoccao.javet.values.primitive.V8ValueString
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getSourcePlugin
import com.futo.platformplayer.invokeV8
@@ -28,12 +29,28 @@ class JSSubtitleSource : ISubtitleSource {
constructor(config: SourcePluginConfig, v8Value: V8ValueObject) {
_obj = v8Value;
val context = "JSSubtitles";
name = v8Value.getOrThrow(config, "name", context, false);
language = v8Value.getOrThrow(config, "language", context, false);
url = v8Value.getOrThrow(config, "url", context, true);
format = v8Value.getOrThrow(config, "format", context, true);
hasFetch = v8Value.has("getSubtitles");
var parsedName: String? = null;
var parsedLanguage: String? = null;
var parsedUrl: String? = null;
var parsedFormat: String? = null;
var parsedHasFetch = false;
val parse = {
val context = "JSSubtitles";
parsedName = v8Value.getOrThrow(config, "name", context, false);
parsedLanguage = v8Value.getOrDefault(config, "language", context, null);
parsedUrl = v8Value.getOrThrow(config, "url", context, true);
parsedFormat = v8Value.getOrThrow(config, "format", context, true);
parsedHasFetch = v8Value.has("getSubtitles");
};
v8Value.getSourcePlugin()?.busy {
parse();
} ?: parse()
name = parsedName ?: "";
language = parsedLanguage;
url = parsedUrl;
format = parsedFormat;
hasFetch = parsedHasFetch;
}
override fun getSubtitles(): String {
@@ -68,4 +85,4 @@ class JSSubtitleSource : ISubtitleSource {
return JSSubtitleSource(config, value);
}
}
}
}
@@ -52,34 +52,63 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
override val subtitles: List<ISubtitleSource>;
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin.config, obj) {
val contextName = "VideoDetails";
_plugin = plugin;
val config = plugin.config;
description = _content.getOrThrow(config, "description", contextName);
video = JSVideoSourceDescriptor.fromV8(plugin, _content.getOrThrow(config, "video", contextName));
dash = JSSource.fromV8DashNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "dash", contextName));
hls = JSSource.fromV8HLSNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "hls", contextName));
live = JSSource.fromV8VideoNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "live", contextName));
rating = IRating.fromV8OrDefault(config, _content.getOrDefault<V8ValueObject>(config, "rating", contextName, null), RatingLikes(0));
var parsedDescription: String? = null;
var parsedVideo: IVideoSourceDescriptor? = null;
var parsedDash: IDashManifestSource? = null;
var parsedHls: IHLSManifestSource? = null;
var parsedLive: IVideoSource? = null;
var parsedRating: IRating? = null;
var parsedSubtitles: List<ISubtitleSource>? = null;
var parsedHasGetComments = false;
var parsedHasGetPlaybackTracker = false;
var parsedHasGetContentRecommendations = false;
var parsedHasGetVODEvents = false;
if(!_content.has("subtitles"))
subtitles = listOf();
else {
val subArrs = _content.getOrThrowNullable<V8ValueArray>(config, "subtitles", contextName);
if(subArrs != null)
subtitles = subArrs.keys.map { JSSubtitleSource.fromV8(config, subArrs.get(it)) };
else
subtitles = listOf();
plugin.busy {
val contextName = "VideoDetails";
val config = plugin.config;
parsedDescription = _content.getOrThrow(config, "description", contextName);
parsedVideo = JSVideoSourceDescriptor.fromV8(plugin, _content.getOrThrow(config, "video", contextName));
parsedDash = JSSource.fromV8DashNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "dash", contextName));
parsedHls = JSSource.fromV8HLSNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "hls", contextName));
parsedLive = JSSource.fromV8VideoNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "live", contextName));
parsedRating = IRating.fromV8OrDefault(config, _content.getOrDefault<V8ValueObject>(config, "rating", contextName, null), RatingLikes(0));
if(!_content.has("subtitles"))
parsedSubtitles = listOf();
else {
val subArrs = _content.getOrThrowNullable<V8ValueArray>(config, "subtitles", contextName);
if(subArrs != null)
parsedSubtitles = subArrs.keys.map { JSSubtitleSource.fromV8(config, subArrs.get(it)) };
else
parsedSubtitles = listOf();
}
parsedHasGetComments = _content.has("getComments");
parsedHasGetPlaybackTracker = _content.has("getPlaybackTracker");
parsedHasGetContentRecommendations = _content.has("getContentRecommendations");
parsedHasGetVODEvents = _content.has("getVODEvents");
}
_hasGetComments = _content.has("getComments");
_hasGetPlaybackTracker = _content.has("getPlaybackTracker");
_hasGetContentRecommendations = _content.has("getContentRecommendations");
_hasGetVODEvents = _content.has("getVODEvents");
description = parsedDescription ?: "";
video = parsedVideo ?: throw IllegalStateException("Missing video source descriptor");
dash = parsedDash;
hls = parsedHls;
live = parsedLive;
rating = parsedRating ?: RatingLikes(0);
subtitles = parsedSubtitles ?: listOf();
_hasGetComments = parsedHasGetComments;
_hasGetPlaybackTracker = parsedHasGetPlaybackTracker;
_hasGetContentRecommendations = parsedHasGetContentRecommendations;
_hasGetVODEvents = parsedHasGetVODEvents;
}
override fun getPlaybackTracker(): IPlaybackTracker? {
if(!_hasGetPlaybackTracker || _content.isClosed)
val canGetPlaybackTracker = _plugin.busy {
_hasGetPlaybackTracker && !_content.isClosed
}
if(!canGetPlaybackTracker)
return null;
if(_pluginConfig.id == StateDeveloper.DEV_ID)
return StateDeveloper.instance.handleDevCall(_pluginConfig.id, "videoDetail.getComments()") {
@@ -102,7 +131,10 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
}
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
if(!_hasGetContentRecommendations || _content.isClosed)
val canGetContentRecommendations = _plugin.busy {
_hasGetContentRecommendations && !_content.isClosed
}
if(!canGetContentRecommendations)
return null;
if(client is DevJSClient)
@@ -122,7 +154,12 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
if(client !is JSClient || !_hasGetComments || _content.isClosed)
if(client !is JSClient)
return null;
val canGetComments = _plugin.busy {
_hasGetComments && !_content.isClosed
}
if(!canGetComments)
return null;
if(client is DevJSClient)
@@ -153,4 +190,4 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
return@busy JSVODEventPager(_plugin.config, _plugin,
_content.invokeV8<V8ValueObject>("getVODEvents", arrayOf<Any>()));
}
}
}
@@ -39,10 +39,10 @@ open class JSAudioUrlSource(
?: "$container $bitrate"
override var priority: Boolean =
if (_obj.has("priority")) _obj.getOrThrow<Boolean>(cfg, "priority", ctx) else false
plugin.busy { if (_obj.has("priority")) _obj.getOrThrow<Boolean>(cfg, "priority", ctx) else false }
override var original: Boolean =
if (_obj.has("original")) _obj.getOrThrow<Boolean>(cfg, "original", ctx) else false
plugin.busy { if (_obj.has("original")) _obj.getOrThrow<Boolean>(cfg, "original", ctx) else false }
override fun getAudioUrl(): String = url
@@ -19,21 +19,23 @@ class JSAudioUrlWidevineSource : JSAudioUrlSource, IAudioUrlWidevineSource {
val config = plugin.config
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
hasLicenseRequestExecutor = plugin.busy { obj.has("getLicenseRequestExecutor") }
}
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return null
return _plugin.busy {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return@busy null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return@busy null
return@busy JSRequestExecutor(_plugin, result)
}
if (result !is V8ValueObject)
return null
return JSRequestExecutor(_plugin, result)
}
override fun toString(): String {
@@ -54,8 +54,8 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN;
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
hasGenerate = _obj.has("generate");
original = obj.getOrNull(config, "original", contextName) ?: false;
hasGenerate = plugin.busy { _obj.has("generate") };
}
private var _pregenerate: V8Deferred<String?>? = null;
@@ -67,7 +67,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
if(!hasGenerate)
return V8Deferred(CompletableDeferred(manifest));
if(_obj.isClosed)
if(_plugin.busy { _obj.isClosed })
throw IllegalStateException("Source object already closed");
val pregenerated = _pregenerate;
@@ -111,7 +111,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
override fun generate(): String? {
if(!hasGenerate)
return manifest;
if(_obj.isClosed)
if(_plugin.busy { _obj.isClosed })
throw IllegalStateException("Source object already closed");
val plugin = _plugin.getUnderlyingPlugin();
@@ -145,4 +145,4 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
}
return result;
}
}
}
@@ -39,6 +39,10 @@ open class JSDashManifestRawSource(
private val ctx = "DashRawSource"
private val cfg = plugin.config
override val language: String? = _obj.getOrDefault(cfg, "language", ctx, null);
override val original: Boolean? = _obj.getOrDefault(cfg, "original", ctx, null);
override val container: String =
_obj.getOrDefault<String>(cfg, "container", ctx, null) ?: "application/dash+xml"
@@ -69,12 +73,13 @@ open class JSDashManifestRawSource(
override var manifest: String? =
_obj.getOrDefault<String>(cfg, "manifest", ctx, null)
override val hasGenerate: Boolean = _obj.has("generate")
override val hasGenerate: Boolean = plugin.busy { _obj.has("generate") }
val canMerge: Boolean =
_obj.getOrDefault<Boolean>(cfg, "canMerge", ctx, false) ?: false
override var streamMetaData: StreamMetaData? = null
var audioStreamMetaData: StreamMetaData? = null
private var _pregenerate: V8Deferred<String?>? = null
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
@@ -85,7 +90,7 @@ open class JSDashManifestRawSource(
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
if(!hasGenerate)
return V8Deferred(CompletableDeferred(manifest));
if(_obj.isClosed)
if(_plugin.busy { _obj.isClosed })
throw IllegalStateException("Source object already closed");
val pregenerated = _pregenerate;
if(pregenerated != null) {
@@ -121,6 +126,14 @@ open class JSDashManifestRawSource(
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
}
val audioInitStart = _obj.getOrDefault<Int>(_config, "audioInitStart", "JSDashManifestRawSource", null) ?: 0;
val audioInitEnd = _obj.getOrDefault<Int>(_config, "audioInitEnd", "JSDashManifestRawSource", null) ?: 0;
val audioIndexStart = _obj.getOrDefault<Int>(_config, "audioIndexStart", "JSDashManifestRawSource", null) ?: 0;
val audioIndexEnd = _obj.getOrDefault<Int>(_config, "audioIndexEnd", "JSDashManifestRawSource", null) ?: 0;
if(audioInitEnd > 0 && audioIndexStart > 0 && audioIndexEnd > 0) {
audioStreamMetaData = StreamMetaData(audioInitStart, audioInitEnd, audioIndexStart, audioIndexEnd);
}
return@busy result.convert {
it.value
};
@@ -129,7 +142,7 @@ open class JSDashManifestRawSource(
override open fun generate(): String? {
if(!hasGenerate)
return manifest;
if(_obj.isClosed)
if(_plugin.busy { _obj.isClosed })
throw IllegalStateException("Source object already closed");
var result: String? = null;
@@ -158,6 +171,14 @@ open class JSDashManifestRawSource(
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
}
val audioInitStart = _obj.getOrDefault<Int>(_config, "audioInitStart", "JSDashManifestRawSource", null) ?: 0;
val audioInitEnd = _obj.getOrDefault<Int>(_config, "audioInitEnd", "JSDashManifestRawSource", null) ?: 0;
val audioIndexStart = _obj.getOrDefault<Int>(_config, "audioIndexStart", "JSDashManifestRawSource", null) ?: 0;
val audioIndexEnd = _obj.getOrDefault<Int>(_config, "audioIndexEnd", "JSDashManifestRawSource", null) ?: 0;
if(audioInitEnd > 0 && audioIndexStart > 0 && audioIndexEnd > 0) {
audioStreamMetaData = StreamMetaData(audioInitStart, audioInitEnd, audioIndexStart, audioIndexEnd);
}
}
}
return result;
@@ -185,6 +206,9 @@ class JSDashManifestMergingRawSource(
override val priority: Boolean
get() = video.priority;
override val language: String? get() = audio.language
override val original: Boolean? get() = audio.original;
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
val videoDashDef = video.generateAsync(scope);
val audioDashDef = audio.generateAsync(scope);
@@ -234,4 +258,4 @@ class JSDashManifestMergingRawSource(
companion object {
private val adaptationSetRegex = Regex("<AdaptationSet.*?>.*?<\\/AdaptationSet>", RegexOption.DOT_MATCHES_ALL);
}
}
}
@@ -21,6 +21,9 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
override var priority: Boolean = false;
override val language: String?;
override val original: Boolean?;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
val contextName = "DashSource";
val config = plugin.config;
@@ -29,6 +32,9 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
duration = _obj.getOrThrow(config, "duration", contextName);
priority = obj.getOrNull(config, "priority", contextName) ?: false;
language = obj.getOrNull(config, "language", contextName);
original = obj.getOrNull(config, "original", contextName);
}
override fun getVideoUrl(): String {
@@ -28,6 +28,9 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
override val licenseUri: String
override val hasLicenseRequestExecutor: Boolean
override val language: String?;
override val original: Boolean?;
@Suppress("ConvertSecondaryConstructorToPrimary")
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
val contextName = "DashWidevineSource"
@@ -39,24 +42,29 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
priority = obj.getOrNull(config, "priority", contextName) ?: false
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
hasLicenseRequestExecutor = plugin.busy { obj.has("getLicenseRequestExecutor") }
language = _obj.getOrNull(config, "language", contextName);
original = _obj.getOrNull(config, "original", contextName);
}
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return null
return _plugin.busy {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return@busy null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSDashManifestWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSDashManifestWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return@busy null
return@busy JSRequestExecutor(_plugin, result)
}
if (result !is V8ValueObject)
return null
return JSRequestExecutor(_plugin, result)
}
override fun getVideoUrl(): String {
return url
}
}
}
@@ -34,7 +34,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
language = _obj.getOrThrow(config, "language", contextName);
priority = obj.getOrNull(config, "priority", contextName) ?: false;
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
original = obj.getOrNull(config, "original", contextName) ?: false;
}
@@ -21,6 +21,9 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
override var priority: Boolean = false;
override val language: String?;
override val original: Boolean?;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
val contextName = "HLSSource";
val config = plugin.config;
@@ -30,5 +33,8 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
duration = _obj.getOrThrow<Int>(config, "duration", contextName).toLong();
priority = obj.getOrNull(config, "priority", contextName) ?: false;
language = _obj.getOrNull(config, "language", contextName);
original = _obj.getOrNull(config, "original", contextName);
}
}
@@ -44,15 +44,26 @@ abstract class JSSource {
this._obj = obj;
this.type = type;
_requestModifier = obj.getOrDefault<V8ValueObject>(_config, "requestModifier", "JSSource.requestModifier", null)?.let {
JSRequest(plugin, it, null, null, true);
}
hasRequestModifier = _requestModifier != null || obj.has("getRequestModifier");
var parsedRequestModifier: JSRequest? = null;
var parsedHasRequestModifier = false;
var parsedRequestExecutor: JSRequest? = null;
var parsedHasRequestExecutor = false;
plugin.busy {
parsedRequestModifier = obj.getOrDefault<V8ValueObject>(_config, "requestModifier", "JSSource.requestModifier", null)?.let {
JSRequest(plugin, it, null, null, true);
};
parsedHasRequestModifier = parsedRequestModifier != null || obj.has("getRequestModifier");
_requestExecutor = obj.getOrDefault<V8ValueObject>(_config, "requestExecutor", "JSSource.requestExecutor", null)?.let {
JSRequest(plugin, it, null, null, true);
parsedRequestExecutor = obj.getOrDefault<V8ValueObject>(_config, "requestExecutor", "JSSource.requestExecutor", null)?.let {
JSRequest(plugin, it, null, null, true);
};
parsedHasRequestExecutor = parsedRequestExecutor != null || obj.has("getRequestExecutor");
}
hasRequestExecutor = _requestExecutor != null || obj.has("getRequestExecutor");
_requestModifier = parsedRequestModifier;
hasRequestModifier = parsedHasRequestModifier;
_requestExecutor = parsedRequestExecutor;
hasRequestExecutor = parsedHasRequestExecutor;
}
fun getRequestModifier(): IRequestModifier? = _plugin.isBusyWith("getRequestModifier") {
@@ -166,4 +177,4 @@ abstract class JSSource {
}
}
}
}
}
@@ -44,6 +44,9 @@ open class JSVideoUrlSource(
override var priority: Boolean =
_obj.getOrDefault<Boolean>(cfg, "priority", ctx, false) ?: false
override val language: String? = _obj.getOrDefault(cfg, "language", ctx, null);
override val original: Boolean? = _obj.getOrDefault(cfg, "original", ctx, null);
override fun getVideoUrl(): String = url
override fun toString(): String =
@@ -18,25 +18,27 @@ class JSVideoUrlWidevineSource : JSVideoUrlSource, IVideoUrlWidevineSource {
val config = plugin.config
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
hasLicenseRequestExecutor = plugin.busy { obj.has("getLicenseRequestExecutor") }
}
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return null
return _plugin.busy {
if (!hasLicenseRequestExecutor || _obj.isClosed)
return@busy null
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
}
if (result !is V8ValueObject)
return@busy null
return@busy JSRequestExecutor(_plugin, result)
}
if (result !is V8ValueObject)
return null
return JSRequestExecutor(_plugin, result)
}
override fun toString(): String {
val url = getVideoUrl()
return "(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url, hasLicenseRequestExecutor=$hasLicenseRequestExecutor, licenseUri=$licenseUri)"
}
}
}
@@ -23,10 +23,10 @@ class LocalAudioContentSource : IAudioSource {
var contentUrl: String;
constructor(contentUrl: String, mime: String, name: String? = null) {
constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
this.name = name ?: "File";
container = mime;
duration = 0;
this.duration = duration;
this.contentUrl = contentUrl;
}
@@ -20,14 +20,17 @@ class LocalVideoContentSource: IVideoSource {
override val duration: Long;
override val priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = false;
var contentUrl: String;
constructor(contentUrl: String, mime: String, name: String? = null) {
constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
this.name = name ?: "File";
width = 0;
height = 0;
container = mime;
duration = 0;
this.duration = duration;
this.contentUrl = contentUrl;
}
}
@@ -20,6 +20,9 @@ class LocalVideoFileSource: IVideoSource {
override val duration: Long;
override val priority: Boolean = false;
override val language: String? = null;
override val original: Boolean? = null;
var file: File;
constructor(file: File) {
@@ -1,330 +0,0 @@
package com.futo.platformplayer.casting
import android.os.Looper
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.net.InetAddress
import java.util.UUID
class AirPlayCastingDevice : CastingDeviceLegacy {
//See for more info: https://nto.github.io/AirPlay
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY;
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = false;
override val canSetSpeed: Boolean get() = true;
var addresses: Array<InetAddress>? = null;
var port: Int = 0;
private var _scopeIO: CoroutineScope? = null;
private var _started: Boolean = false;
private var _sessionId: String? = null;
private val _client = ManagedHttpClient();
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
this.addresses = addresses;
this.port = port;
}
constructor(deviceInfo: CastingDeviceInfo) : super() {
this.name = deviceInfo.name;
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
this.port = deviceInfo.port;
}
override fun getAddresses(): List<InetAddress> {
return addresses?.toList() ?: listOf();
}
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
return;
}
Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
setTime(resumePosition);
setDuration(duration);
if (resumePosition > 0.0) {
val pos = resumePosition / duration;
Logger.i(TAG, "resumePosition: $resumePosition, duration: ${duration}, pos: $pos")
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: $pos");
} else {
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: 0");
}
if (speed != null) {
changeSpeed(speed)
}
}
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
throw NotImplementedError();
}
override fun seekVideo(timeSeconds: Double) {
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
return;
}
post("scrub?position=${timeSeconds}");
}
override fun resumeVideo() {
if (invokeInIOScopeIfRequired(::resumeVideo)) {
return;
}
isPlaying = true;
post("rate?value=1.000000");
}
override fun pauseVideo() {
if (invokeInIOScopeIfRequired(::pauseVideo)) {
return;
}
isPlaying = false;
post("rate?value=0.000000");
}
override fun stopVideo() {
if (invokeInIOScopeIfRequired(::stopVideo)) {
return;
}
post("stop");
}
override fun stopCasting() {
if (invokeInIOScopeIfRequired(::stopCasting)) {
return;
}
post("stop");
stop();
}
override fun start() {
val adrs = addresses ?: return;
if (_started) {
return;
}
_started = true;
_scopeIO?.cancel();
_scopeIO = CoroutineScope(Dispatchers.IO);
Logger.i(TAG, "Starting...");
_scopeIO?.launch {
try {
connectionState = CastConnectionState.CONNECTING;
while (_scopeIO?.isActive == true) {
try {
val connectedSocket = getConnectedSocket(adrs.toList(), port);
if (connectedSocket == null) {
delay(1000);
continue;
}
usedRemoteAddress = connectedSocket.inetAddress;
localAddress = connectedSocket.localAddress;
connectedSocket.close();
_sessionId = UUID.randomUUID().toString();
break;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
delay(1000);
}
}
while (_scopeIO?.isActive == true) {
try {
val progressInfo = getProgress();
if (progressInfo == null) {
connectionState = CastConnectionState.CONNECTING;
Logger.i(TAG, "Failed to retrieve progress from AirPlay device.");
delay(1000);
continue;
}
connectionState = CastConnectionState.CONNECTED;
val progressIndex = progressInfo.lowercase().indexOf("position: ");
if (progressIndex == -1) {
delay(1000);
continue;
}
val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue;
setTime(progress);
val durationIndex = progressInfo.lowercase().indexOf("duration: ");
if (durationIndex == -1) {
delay(1000);
continue;
}
val duration = progressInfo.substring(durationIndex + "duration: ".length).toDoubleOrNull() ?: continue;
setDuration(duration);
delay(1000);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get server info from AirPlay device.", e)
}
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to setup AirPlay device connection.", e)
}
};
Logger.i(TAG, "Started.");
}
override fun stop() {
Logger.i(TAG, "Stopping...");
connectionState = CastConnectionState.DISCONNECTED;
usedRemoteAddress = null;
localAddress = null;
_started = false;
_scopeIO?.cancel();
_scopeIO = null;
}
override fun changeSpeed(speed: Double) {
setSpeed(speed)
post("rate?value=$speed")
}
override fun getDeviceInfo(): CastingDeviceInfo {
return CastingDeviceInfo(name!!, CastProtocolType.AIRPLAY, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
}
private fun getProgress(): String? {
val info = get("scrub");
Logger.i(TAG, "Progress: ${info ?: "null"}");
return info;
}
private fun getPlaybackInfo(): String? {
val playbackInfo = get("playback-info");
Logger.i(TAG, "Playback info: ${playbackInfo ?: "null"}");
return playbackInfo;
}
private fun getServerInfo(): String? {
val serverInfo = get("server-info");
Logger.i(TAG, "Server info: ${serverInfo ?: "null"}");
return serverInfo;
}
private fun post(path: String): Boolean {
try {
val sessionId = _sessionId ?: return false;
val headers = hashMapOf(
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
"User-Agent" to "MediaControl/1.0",
"Content-Length" to "0",
"X-Apple-Session-ID" to sessionId
);
val url = "http://${usedRemoteAddress}:${port}/${path}";
Logger.i(TAG, "POST $url");
val response = _client.post(url, headers);
if (!response.isOk) {
return false;
}
return true;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to POST $path");
return false;
}
}
private fun post(path: String, contentType: String, body: String): Boolean {
try {
val sessionId = _sessionId ?: return false;
val headers = hashMapOf(
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
"User-Agent" to "MediaControl/1.0",
"X-Apple-Session-ID" to sessionId,
"Content-Type" to contentType
);
val url = "http://${usedRemoteAddress}:${port}/${path}";
Logger.i(TAG, "POST $url:\n$body");
val response = _client.post(url, body, headers);
if (!response.isOk) {
return false;
}
return true;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to POST $path $body");
return false;
}
}
private fun get(path: String): String? {
val sessionId = _sessionId ?: return null;
try {
val headers = hashMapOf(
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
"Content-Length" to "0",
"User-Agent" to "MediaControl/1.0",
"X-Apple-Session-ID" to sessionId
);
val url = "http://${usedRemoteAddress}:${port}/${path}";
Logger.i(TAG, "GET $url");
val response = _client.get(url, headers);
if (!response.isOk) {
return null;
}
if (response.body == null) {
return null;
}
return response.body.string();
} catch (e: Throwable) {
Logger.w(TAG, "Failed to GET $path");
return null;
}
}
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
if(Looper.getMainLooper().thread == Thread.currentThread()) {
_scopeIO?.launch { action(); }
return true;
}
return false;
}
companion object {
val TAG = "AirPlayCastingDevice";
}
}
@@ -1,60 +1,217 @@
package com.futo.platformplayer.casting
import android.os.Build
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import org.fcast.sender_sdk.Metadata
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.fcast.sender_sdk.ApplicationInfo
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice
import org.fcast.sender_sdk.KeyEvent
import org.fcast.sender_sdk.MediaEvent
import java.net.InetAddress
import org.fcast.sender_sdk.PlaybackState
import org.fcast.sender_sdk.Source
import org.fcast.sender_sdk.urlFormatIpAddr
import java.net.Inet4Address
import java.net.Inet6Address
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
import org.fcast.sender_sdk.DeviceConnectionState
import org.fcast.sender_sdk.DeviceFeature
import org.fcast.sender_sdk.EventSubscription
import org.fcast.sender_sdk.IpAddr
import org.fcast.sender_sdk.LoadRequest
import org.fcast.sender_sdk.MediaItemEventType
import org.fcast.sender_sdk.Metadata
import org.fcast.sender_sdk.ProtocolType
abstract class CastingDevice {
abstract val isReady: Boolean
abstract val usedRemoteAddress: InetAddress?
abstract val localAddress: InetAddress?
abstract val name: String?
abstract val onConnectionStateChanged: Event1<CastConnectionState>
abstract val onPlayChanged: Event1<Boolean>
abstract val onTimeChanged: Event1<Double>
abstract val onDurationChanged: Event1<Double>
abstract val onVolumeChanged: Event1<Double>
abstract val onSpeedChanged: Event1<Double>
abstract var connectionState: CastConnectionState
abstract val protocolType: CastProtocolType
abstract var isPlaying: Boolean
abstract val expectedCurrentTime: Double
abstract var speed: Double
abstract var time: Double
abstract var duration: Double
abstract var volume: Double
abstract fun canSetVolume(): Boolean
abstract fun canSetSpeed(): Boolean
enum class CastConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED
}
@Throws
abstract fun resumePlayback()
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
enum class CastProtocolType {
CHROMECAST,
AIRPLAY,
FCAST;
@Throws
abstract fun pausePlayback()
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
@Throws
abstract fun stopPlayback()
override fun serialize(encoder: Encoder, value: CastProtocolType) {
encoder.encodeString(value.name)
}
@Throws
abstract fun seekTo(timeSeconds: Double)
override fun deserialize(decoder: Decoder): CastProtocolType {
val name = decoder.decodeString()
return when (name) {
"FASTCAST" -> FCAST // Handle the renamed case
else -> CastProtocolType.valueOf(name)
}
}
}
}
@Throws
abstract fun changeVolume(timeSeconds: Double)
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
is IpAddr.V4 -> Inet4Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte()
)
)
@Throws
abstract fun changeSpeed(speed: Double)
is IpAddr.V6 -> Inet6Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte(),
addr.o5.toByte(),
addr.o6.toByte(),
addr.o7.toByte(),
addr.o8.toByte(),
addr.o9.toByte(),
addr.o10.toByte(),
addr.o11.toByte(),
addr.o12.toByte(),
addr.o13.toByte(),
addr.o14.toByte(),
addr.o15.toByte(),
addr.o16.toByte()
)
)
}
@Throws
abstract fun connect()
class CastingDevice(val device: RsCastingDevice) {
class EventHandler : RsDeviceEventHandler {
var onConnectionStateChanged = Event1<DeviceConnectionState>();
var onPlayChanged = Event1<Boolean>()
var onTimeChanged = Event1<Double>()
var onDurationChanged = Event1<Double>()
var onVolumeChanged = Event1<Double>()
var onSpeedChanged = Event1<Double>()
var onMediaItemEnd = Event0()
@Throws
abstract fun disconnect()
abstract fun getDeviceInfo(): CastingDeviceInfo
abstract fun getAddresses(): List<InetAddress>
override fun connectionStateChanged(state: DeviceConnectionState) {
onConnectionStateChanged.emit(state)
}
@Throws
abstract fun loadVideo(
override fun volumeChanged(volume: Double) {
onVolumeChanged.emit(volume)
}
override fun timeChanged(time: Double) {
onTimeChanged.emit(time)
}
override fun playbackStateChanged(state: PlaybackState) {
onPlayChanged.emit(state == PlaybackState.PLAYING)
}
override fun durationChanged(duration: Double) {
onDurationChanged.emit(duration)
}
override fun speedChanged(speed: Double) {
onSpeedChanged.emit(speed)
}
override fun sourceChanged(source: Source) {
// TODO
}
override fun keyEvent(event: KeyEvent) {
// Unreachable
}
override fun mediaEvent(event: MediaEvent) {
if (event.type == MediaItemEventType.END) {
onMediaItemEnd.emit()
}
}
override fun playbackError(message: String) {
Logger.e(TAG, "Playback error: $message")
}
}
val eventHandler = EventHandler()
val isReady: Boolean
get() = device.isReady()
val name: String
get() = device.name()
var usedRemoteAddress: InetAddress? = null
var localAddress: InetAddress? = null
fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
val onConnectionStateChanged =
Event1<CastConnectionState>()
val onPlayChanged: Event1<Boolean>
get() = eventHandler.onPlayChanged
val onTimeChanged: Event1<Double>
get() = eventHandler.onTimeChanged
val onDurationChanged: Event1<Double>
get() = eventHandler.onDurationChanged
val onVolumeChanged: Event1<Double>
get() = eventHandler.onVolumeChanged
val onSpeedChanged: Event1<Double>
get() = eventHandler.onSpeedChanged
val onMediaItemEnd: Event0
get() = eventHandler.onMediaItemEnd
fun resumePlayback() = device.resumePlayback()
fun pausePlayback() = device.pausePlayback()
fun stopPlayback() = device.stopPlayback()
fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
fun changeVolume(newVolume: Double) {
device.changeVolume(newVolume)
volume = newVolume
}
fun changeSpeed(speed: Double) = device.changeSpeed(speed)
fun connect() = device.connect(
ApplicationInfo(
"Grayjay Android",
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
"${Build.MANUFACTURER} ${Build.MODEL}"
),
eventHandler,
1000.toULong()
)
fun disconnect() = device.disconnect()
fun getDeviceInfo(): CastingDeviceInfo {
val info = device.getDeviceInfo()
return CastingDeviceInfo(
info.name,
when (info.protocol) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
},
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
port = info.port.toInt(),
)
}
fun getAddresses(): List<InetAddress> = device.getAddresses().map {
ipAddrToInetAddress(it)
}
fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
@@ -62,18 +219,107 @@ abstract class CastingDevice {
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Video(
contentType = contentType,
url = contentId,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata,
requestHeaders = null,
)
)
@Throws
abstract fun loadContent(
fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Content(
contentType = contentType,
content = content,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata,
requestHeaders = null,
)
)
abstract fun ensureThreadStarted()
}
var connectionState = CastConnectionState.DISCONNECTED
val protocolType: CastProtocolType
get() = when (device.castingProtocol()) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
}
var volume: Double = 1.0
var duration: Double = 0.0
private var lastTimeChangeTime_ms: Long = 0
var time: Double = 0.0
var speed: Double = 0.0
var isPlaying: Boolean = false
val expectedCurrentTime: Double
get() {
val diff =
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff
}
init {
eventHandler.onConnectionStateChanged.subscribe { newState ->
when (newState) {
is DeviceConnectionState.Connected -> {
if (device.supportsFeature(DeviceFeature.MEDIA_EVENT_SUBSCRIPTION)) {
try {
device.subscribeEvent(EventSubscription.MediaItemEnd)
} catch (e: Exception) {
Logger.e(TAG, "Failed to subscribe to MediaItemEnd events: $e")
}
}
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
localAddress = ipAddrToInetAddress(newState.localAddr)
connectionState = CastConnectionState.CONNECTED
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
}
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
connectionState = CastConnectionState.CONNECTING
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
}
DeviceConnectionState.Disconnected -> {
connectionState = CastConnectionState.DISCONNECTED
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
}
}
if (newState == DeviceConnectionState.Disconnected) {
try {
Logger.i(TAG, "Stopping device")
device.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to stop device: $e")
}
}
}
eventHandler.onPlayChanged.subscribe { isPlaying = it }
eventHandler.onTimeChanged.subscribe {
lastTimeChangeTime_ms = System.currentTimeMillis()
time = it
}
eventHandler.onDurationChanged.subscribe { duration = it }
eventHandler.onVolumeChanged.subscribe { volume = it }
eventHandler.onSpeedChanged.subscribe { speed = it }
}
fun ensureThreadStarted() {}
companion object {
private val TAG = "CastingDeviceExp"
}
}
@@ -1,271 +0,0 @@
package com.futo.platformplayer.casting
import android.os.Build
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import org.fcast.sender_sdk.ApplicationInfo
import org.fcast.sender_sdk.GenericKeyEvent
import org.fcast.sender_sdk.GenericMediaEvent
import org.fcast.sender_sdk.PlaybackState
import org.fcast.sender_sdk.Source
import java.net.InetAddress
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice;
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
import org.fcast.sender_sdk.DeviceConnectionState
import org.fcast.sender_sdk.DeviceFeature
import org.fcast.sender_sdk.IpAddr
import org.fcast.sender_sdk.LoadRequest
import org.fcast.sender_sdk.Metadata
import org.fcast.sender_sdk.ProtocolType
import org.fcast.sender_sdk.urlFormatIpAddr
import java.net.Inet4Address
import java.net.Inet6Address
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
is IpAddr.V4 -> Inet4Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte()
)
)
is IpAddr.V6 -> Inet6Address.getByAddress(
byteArrayOf(
addr.o1.toByte(),
addr.o2.toByte(),
addr.o3.toByte(),
addr.o4.toByte(),
addr.o5.toByte(),
addr.o6.toByte(),
addr.o7.toByte(),
addr.o8.toByte(),
addr.o9.toByte(),
addr.o10.toByte(),
addr.o11.toByte(),
addr.o12.toByte(),
addr.o13.toByte(),
addr.o14.toByte(),
addr.o15.toByte(),
addr.o16.toByte()
)
)
}
class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
class EventHandler : RsDeviceEventHandler {
var onConnectionStateChanged = Event1<DeviceConnectionState>();
var onPlayChanged = Event1<Boolean>()
var onTimeChanged = Event1<Double>()
var onDurationChanged = Event1<Double>()
var onVolumeChanged = Event1<Double>()
var onSpeedChanged = Event1<Double>()
override fun connectionStateChanged(state: DeviceConnectionState) {
onConnectionStateChanged.emit(state)
}
override fun volumeChanged(volume: Double) {
onVolumeChanged.emit(volume)
}
override fun timeChanged(time: Double) {
onTimeChanged.emit(time)
}
override fun playbackStateChanged(state: PlaybackState) {
onPlayChanged.emit(state == PlaybackState.PLAYING)
}
override fun durationChanged(duration: Double) {
onDurationChanged.emit(duration)
}
override fun speedChanged(speed: Double) {
onSpeedChanged.emit(speed)
}
override fun sourceChanged(source: Source) {
// TODO
}
override fun keyEvent(event: GenericKeyEvent) {
// Unreachable
}
override fun mediaEvent(event: GenericMediaEvent) {
// Unreachable
}
override fun playbackError(message: String) {
Logger.e(TAG, "Playback error: $message")
}
}
val eventHandler = EventHandler()
override val isReady: Boolean
get() = device.isReady()
override val name: String
get() = device.name()
override var usedRemoteAddress: InetAddress? = null
override var localAddress: InetAddress? = null
override fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
override fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
override val onConnectionStateChanged =
Event1<CastConnectionState>()
override val onPlayChanged: Event1<Boolean>
get() = eventHandler.onPlayChanged
override val onTimeChanged: Event1<Double>
get() = eventHandler.onTimeChanged
override val onDurationChanged: Event1<Double>
get() = eventHandler.onDurationChanged
override val onVolumeChanged: Event1<Double>
get() = eventHandler.onVolumeChanged
override val onSpeedChanged: Event1<Double>
get() = eventHandler.onSpeedChanged
override fun resumePlayback() = device.resumePlayback()
override fun pausePlayback() = device.pausePlayback()
override fun stopPlayback() = device.stopPlayback()
override fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
override fun changeVolume(newVolume: Double) {
device.changeVolume(newVolume)
volume = newVolume
}
override fun changeSpeed(speed: Double) = device.changeSpeed(speed)
override fun connect() = device.connect(
ApplicationInfo(
"Grayjay Android",
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
"${Build.MANUFACTURER} ${Build.MODEL}"
),
eventHandler,
1000.toULong()
)
override fun disconnect() = device.disconnect()
override fun getDeviceInfo(): CastingDeviceInfo {
val info = device.getDeviceInfo()
return CastingDeviceInfo(
info.name,
when (info.protocol) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
},
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
port = info.port.toInt(),
)
}
override fun getAddresses(): List<InetAddress> = device.getAddresses().map {
ipAddrToInetAddress(it)
}
override fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Video(
contentType = contentType,
url = contentId,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata
)
)
override fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = device.load(
LoadRequest.Content(
contentType = contentType,
content = content,
resumePosition = resumePosition,
speed = speed,
volume = volume,
metadata = metadata,
)
)
override var connectionState = CastConnectionState.DISCONNECTED
override val protocolType: CastProtocolType
get() = when (device.castingProtocol()) {
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
ProtocolType.F_CAST -> CastProtocolType.FCAST
}
override var volume: Double = 1.0
override var duration: Double = 0.0
private var lastTimeChangeTime_ms: Long = 0
override var time: Double = 0.0
override var speed: Double = 0.0
override var isPlaying: Boolean = false
override val expectedCurrentTime: Double
get() {
val diff =
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff
}
init {
eventHandler.onConnectionStateChanged.subscribe { newState ->
when (newState) {
is DeviceConnectionState.Connected -> {
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
localAddress = ipAddrToInetAddress(newState.localAddr)
connectionState = CastConnectionState.CONNECTED
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
}
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
connectionState = CastConnectionState.CONNECTING
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
}
DeviceConnectionState.Disconnected -> {
connectionState = CastConnectionState.CONNECTING
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
}
}
if (newState == DeviceConnectionState.Disconnected) {
try {
Logger.i(TAG, "Stopping device")
device.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to stop device: $e")
}
}
}
eventHandler.onPlayChanged.subscribe { isPlaying = it }
eventHandler.onTimeChanged.subscribe {
lastTimeChangeTime_ms = System.currentTimeMillis()
time = it
}
eventHandler.onDurationChanged.subscribe { duration = it }
eventHandler.onVolumeChanged.subscribe { volume = it }
eventHandler.onSpeedChanged.subscribe { speed = it }
}
override fun ensureThreadStarted() {}
companion object {
private val TAG = "CastingDeviceExp"
}
}
@@ -1,242 +0,0 @@
package com.futo.platformplayer.casting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import org.fcast.sender_sdk.Metadata
import java.net.InetAddress
enum class CastConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED
}
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
enum class CastProtocolType {
CHROMECAST,
AIRPLAY,
FCAST;
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: CastProtocolType) {
encoder.encodeString(value.name)
}
override fun deserialize(decoder: Decoder): CastProtocolType {
val name = decoder.decodeString()
return when (name) {
"FASTCAST" -> FCAST // Handle the renamed case
else -> CastProtocolType.valueOf(name)
}
}
}
}
abstract class CastingDeviceLegacy {
abstract val protocol: CastProtocolType;
abstract val isReady: Boolean;
abstract var usedRemoteAddress: InetAddress?;
abstract var localAddress: InetAddress?;
abstract val canSetVolume: Boolean;
abstract val canSetSpeed: Boolean;
var name: String? = null;
var isPlaying: Boolean = false
set(value) {
val changed = value != field;
field = value;
if (changed) {
onPlayChanged.emit(value);
}
};
private var lastTimeChangeTime_ms: Long = 0
var time: Double = 0.0
private set
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
time = value
lastTimeChangeTime_ms = changeTime_ms
onTimeChanged.emit(value)
}
}
private var lastDurationChangeTime_ms: Long = 0
var duration: Double = 0.0
private set
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
duration = value
lastDurationChangeTime_ms = changeTime_ms
onDurationChanged.emit(value)
}
}
private var lastVolumeChangeTime_ms: Long = 0
var volume: Double = 1.0
private set
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
volume = value
lastVolumeChangeTime_ms = changeTime_ms
onVolumeChanged.emit(value)
}
}
private var lastSpeedChangeTime_ms: Long = 0
var speed: Double = 1.0
private set
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
speed = value
lastSpeedChangeTime_ms = changeTime_ms
onSpeedChanged.emit(value)
}
}
val expectedCurrentTime: Double
get() {
val diff =
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
return time + diff;
};
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
set(value) {
val changed = value != field;
field = value;
if (changed) {
onConnectionStateChanged.emit(value);
}
};
var onConnectionStateChanged = Event1<CastConnectionState>();
var onPlayChanged = Event1<Boolean>();
var onTimeChanged = Event1<Double>();
var onDurationChanged = Event1<Double>();
var onVolumeChanged = Event1<Double>();
var onSpeedChanged = Event1<Double>();
abstract fun stopCasting();
abstract fun seekVideo(timeSeconds: Double);
abstract fun stopVideo();
abstract fun pauseVideo();
abstract fun resumeVideo();
abstract fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?
);
abstract fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?
);
open fun changeVolume(volume: Double) {
throw NotImplementedError()
}
open fun changeSpeed(speed: Double) {
throw NotImplementedError()
}
abstract fun start();
abstract fun stop();
abstract fun getDeviceInfo(): CastingDeviceInfo;
abstract fun getAddresses(): List<InetAddress>;
}
class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice() {
override val isReady: Boolean get() = inner.isReady
override val usedRemoteAddress: InetAddress? get() = inner.usedRemoteAddress
override val localAddress: InetAddress? get() = inner.localAddress
override val name: String? get() = inner.name
override val onConnectionStateChanged: Event1<CastConnectionState> get() = inner.onConnectionStateChanged
override val onPlayChanged: Event1<Boolean> get() = inner.onPlayChanged
override val onTimeChanged: Event1<Double> get() = inner.onTimeChanged
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
override var connectionState: CastConnectionState
get() = inner.connectionState
set(_) = Unit
override val protocolType: CastProtocolType get() = inner.protocol
override var isPlaying: Boolean
get() = inner.isPlaying
set(_) = Unit
override val expectedCurrentTime: Double
get() = inner.expectedCurrentTime
override var speed: Double
get() = inner.speed
set(_) = Unit
override var time: Double
get() = inner.time
set(_) = Unit
override var duration: Double
get() = inner.duration
set(_) = Unit
override var volume: Double
get() = inner.volume
set(_) = Unit
override fun canSetVolume(): Boolean = inner.canSetVolume
override fun canSetSpeed(): Boolean = inner.canSetSpeed
override fun resumePlayback() = inner.resumeVideo()
override fun pausePlayback() = inner.pauseVideo()
override fun stopPlayback() = inner.stopVideo()
override fun seekTo(timeSeconds: Double) = inner.seekVideo(timeSeconds)
override fun changeVolume(timeSeconds: Double) = inner.changeVolume(timeSeconds)
override fun changeSpeed(speed: Double) = inner.changeSpeed(speed)
override fun connect() = inner.start()
override fun disconnect() = inner.stop()
override fun getDeviceInfo(): CastingDeviceInfo = inner.getDeviceInfo()
override fun getAddresses(): List<InetAddress> = inner.getAddresses()
override fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = inner.loadVideo(streamType, contentType, contentId, resumePosition, duration, speed)
override fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
) = inner.loadContent(contentType, content, resumePosition, duration, speed)
override fun ensureThreadStarted() = when (inner) {
is FCastCastingDevice -> inner.ensureThreadStarted()
is ChromecastCastingDevice -> inner.ensureThreadsStarted()
else -> {}
}
}
@@ -1,736 +0,0 @@
package com.futo.platformplayer.casting
import android.os.Looper
import android.util.Log
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.protos.ChromeCast
import com.futo.platformplayer.toHexString
import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.io.DataInputStream
import java.io.DataOutputStream
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocket
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
class ChromecastCastingDevice : CastingDeviceLegacy {
//See for more info: https://developers.google.com/cast/docs/media/messages
override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST;
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = true;
override val canSetSpeed: Boolean get() = true;
var addresses: Array<InetAddress>? = null;
var port: Int = 0;
private var _streamType: String? = null;
private var _contentType: String? = null;
private var _contentId: String? = null;
private var _socket: SSLSocket? = null;
private var _outputStream: DataOutputStream? = null;
private var _outputStreamLock = Object();
private var _inputStream: DataInputStream? = null;
private var _inputStreamLock = Object();
private var _scopeIO: CoroutineScope? = null;
private var _requestId = 1;
private var _started: Boolean = false;
private var _sessionId: String? = null;
private var _transportId: String? = null;
private var _launching = false;
private var _mediaSessionId: Int? = null;
private var _thread: Thread? = null;
private var _pingThread: Thread? = null;
private var _launchRetries = 0
private val MAX_LAUNCH_RETRIES = 3
private var _lastLaunchTime_ms = 0L
private var _retryJob: Job? = null
private var _autoLaunchEnabled = true
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
this.addresses = addresses;
this.port = port;
}
constructor(deviceInfo: CastingDeviceInfo) : super() {
this.name = deviceInfo.name;
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
this.port = deviceInfo.port;
}
override fun getAddresses(): List<InetAddress> {
return addresses?.toList() ?: listOf();
}
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
return;
}
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
setTime(resumePosition);
setDuration(duration);
_streamType = streamType;
_contentType = contentType;
_contentId = contentId;
playVideo();
}
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
//TODO: Can maybe be implemented by sending data:contentType,base64...
throw NotImplementedError();
}
private fun connectMediaChannel(transportId: String) {
val connectObject = JSONObject();
connectObject.put("type", "CONNECT");
connectObject.put("connType", 0);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
}
private fun requestMediaStatus() {
val transportId = _transportId ?: return;
val loadObject = JSONObject();
loadObject.put("type", "GET_STATUS");
loadObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
private fun playVideo() {
val transportId = _transportId ?: return;
val contentId = _contentId ?: return;
val streamType = _streamType ?: return;
val contentType = _contentType ?: return;
val loadObject = JSONObject();
loadObject.put("type", "LOAD");
val mediaObject = JSONObject();
mediaObject.put("contentId", contentId);
mediaObject.put("streamType", streamType);
mediaObject.put("contentType", contentType);
if (time > 0.0) {
val seekTime = time;
loadObject.put("currentTime", seekTime);
}
loadObject.put("media", mediaObject);
loadObject.put("requestId", _requestId++);
//TODO: This replace is necessary to get rid of backward slashes added by the JSON Object serializer
val json = loadObject.toString().replace("\\/","/");
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", json);
}
override fun changeSpeed(speed: Double) {
if (invokeInIOScopeIfRequired { changeSpeed(speed) }) return
val speedClamped = speed.coerceAtLeast(1.0).coerceAtLeast(1.0).coerceAtMost(2.0)
setSpeed(speedClamped)
val mediaSessionId = _mediaSessionId ?: return
val transportId = _transportId ?: return
val setSpeedObject = JSONObject().apply {
put("type", "SET_PLAYBACK_RATE")
put("mediaSessionId", mediaSessionId)
put("playbackRate", speedClamped)
put("requestId", _requestId++)
}
sendChannelMessage(sourceId = "sender-0", destinationId = transportId, namespace = "urn:x-cast:com.google.cast.media", json = setSpeedObject.toString())
}
override fun changeVolume(volume: Double) {
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
return;
}
setVolume(volume)
val setVolumeObject = JSONObject();
setVolumeObject.put("type", "SET_VOLUME");
val volumeObject = JSONObject();
volumeObject.put("level", volume)
setVolumeObject.put("volume", volumeObject);
setVolumeObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", setVolumeObject.toString());
}
override fun seekVideo(timeSeconds: Double) {
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
return;
}
val transportId = _transportId ?: return;
val mediaSessionId = _mediaSessionId ?: return;
val loadObject = JSONObject();
loadObject.put("type", "SEEK");
loadObject.put("mediaSessionId", mediaSessionId);
loadObject.put("requestId", _requestId++);
loadObject.put("currentTime", timeSeconds);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
override fun resumeVideo() {
if (invokeInIOScopeIfRequired(::resumeVideo)) {
return;
}
val transportId = _transportId ?: return;
val mediaSessionId = _mediaSessionId ?: return;
val loadObject = JSONObject();
loadObject.put("type", "PLAY");
loadObject.put("mediaSessionId", mediaSessionId);
loadObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
override fun pauseVideo() {
if (invokeInIOScopeIfRequired(::pauseVideo)) {
return;
}
val transportId = _transportId ?: return;
val mediaSessionId = _mediaSessionId ?: return;
val loadObject = JSONObject();
loadObject.put("type", "PAUSE");
loadObject.put("mediaSessionId", mediaSessionId);
loadObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
override fun stopVideo() {
if (invokeInIOScopeIfRequired(::stopVideo)) {
return;
}
val transportId = _transportId ?: return;
val mediaSessionId = _mediaSessionId ?: return;
_contentId = null;
_contentType = null;
_streamType = null;
val loadObject = JSONObject();
loadObject.put("type", "STOP");
loadObject.put("mediaSessionId", mediaSessionId);
loadObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
}
private fun launchPlayer() {
if (invokeInIOScopeIfRequired(::launchPlayer)) {
return;
}
val launchObject = JSONObject();
launchObject.put("type", "LAUNCH");
launchObject.put("appId", "CC1AD845");
launchObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
_lastLaunchTime_ms = System.currentTimeMillis()
}
private fun getStatus() {
if (invokeInIOScopeIfRequired(::getStatus)) {
return;
}
val launchObject = JSONObject();
launchObject.put("type", "GET_STATUS");
launchObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
}
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
if(Looper.getMainLooper().thread == Thread.currentThread()) {
_scopeIO?.launch { action(); }
return true;
}
return false;
}
override fun stopCasting() {
if (invokeInIOScopeIfRequired(::stopCasting)) {
return;
}
val sessionId = _sessionId;
if (sessionId != null) {
val launchObject = JSONObject();
launchObject.put("type", "STOP");
launchObject.put("sessionId", sessionId);
launchObject.put("requestId", _requestId++);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
_contentId = null;
_contentType = null;
_streamType = null;
_sessionId = null;
_launchRetries = 0
_transportId = null;
}
Logger.i(TAG, "Stopping active device because stopCasting was called.")
stop();
}
override fun start() {
if (_started) {
return;
}
_autoLaunchEnabled = true
_started = true;
_sessionId = null;
_launchRetries = 0
_mediaSessionId = null;
Logger.i(TAG, "Starting...");
_launching = true;
ensureThreadsStarted();
Logger.i(TAG, "Started.");
}
fun ensureThreadsStarted() {
val adrs = addresses ?: return;
val thread = _thread
val pingThread = _pingThread
if (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive) {
Log.i(TAG, "Restarting threads because one of the threads has died")
_scopeIO?.cancel();
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
_scopeIO = CoroutineScope(Dispatchers.IO);
_thread = Thread {
connectionState = CastConnectionState.CONNECTING;
var connectedSocket: Socket? = null
while (_scopeIO?.isActive == true) {
try {
val resultSocket = getConnectedSocket(adrs.toList(), port);
if (resultSocket == null) {
Thread.sleep(1000);
continue;
}
connectedSocket = resultSocket
usedRemoteAddress = connectedSocket.inetAddress;
localAddress = connectedSocket.localAddress;
break;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
Thread.sleep(1000);
}
}
val sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAllCerts, null);
val factory = sslContext.socketFactory;
val address = InetSocketAddress(usedRemoteAddress, port)
//Connection loop
while (_scopeIO?.isActive == true) {
_sessionId = null;
_launchRetries = 0
_mediaSessionId = null;
Logger.i(TAG, "Connecting to Chromecast.");
connectionState = CastConnectionState.CONNECTING;
try {
_socket?.close()
if (connectedSocket != null) {
Logger.i(TAG, "Using connected socket.")
_socket = factory.createSocket(connectedSocket, connectedSocket.inetAddress.hostAddress, connectedSocket.port, true) as SSLSocket
connectedSocket = null
} else {
Logger.i(TAG, "Using new socket.")
val s = Socket().apply { this.connect(address, 2000) }
_socket = factory.createSocket(s, s.inetAddress.hostAddress, s.port, true) as SSLSocket
}
_socket?.startHandshake();
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
try {
_outputStream = DataOutputStream(_socket?.outputStream);
_inputStream = DataInputStream(_socket?.inputStream);
} catch (e: Throwable) {
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
}
} catch (e: Throwable) {
_socket?.close();
Logger.i(TAG, "Failed to connect to Chromecast.", e);
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
continue;
}
localAddress = _socket?.localAddress;
try {
val connectObject = JSONObject();
connectObject.put("type", "CONNECT");
connectObject.put("connType", 0);
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
} catch (e: Throwable) {
Logger.i(TAG, "Failed to send connect message to Chromecast.", e);
_socket?.close();
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
continue;
}
getStatus();
val buffer = ByteArray(409600);
Logger.i(TAG, "Started receiving.");
while (_scopeIO?.isActive == true) {
try {
val inputStream = _inputStream ?: break;
val message = synchronized(_inputStreamLock)
{
Log.d(TAG, "Receiving next packet...");
val b1 = inputStream.readUnsignedByte();
val b2 = inputStream.readUnsignedByte();
val b3 = inputStream.readUnsignedByte();
val b4 = inputStream.readUnsignedByte();
val size =
((b1.toLong() shl 24) or (b2.toLong() shl 16) or (b3.toLong() shl 8) or b4.toLong()).toInt();
if (size > buffer.size) {
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
inputStream.skip(size.toLong());
return@synchronized null
}
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
inputStream.read(buffer, 0, size);
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
val msg = ChromeCast.CastMessage.parseFrom(messageBytes);
if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
Logger.i(TAG, "Received message: $msg");
}
return@synchronized msg
}
if (message != null) {
try {
handleMessage(message);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to handle message.", e);
break
}
}
} catch (e: java.net.SocketException) {
Logger.e(TAG, "Socket exception while receiving.", e);
break;
} catch (e: Throwable) {
Logger.e(TAG, "Exception while receiving.", e);
break;
}
}
_socket?.close();
Logger.i(TAG, "Socket disconnected.");
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
}
Logger.i(TAG, "Stopped connection loop.");
connectionState = CastConnectionState.DISCONNECTED;
}.apply { start() };
//Start ping loop
_pingThread = Thread {
Logger.i(TAG, "Started ping loop.")
val pingObject = JSONObject();
pingObject.put("type", "PING");
while (_scopeIO?.isActive == true) {
try {
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.heartbeat", pingObject.toString());
} catch (e: Throwable) {
Log.w(TAG, "Failed to send ping.");
}
Thread.sleep(5000);
}
Logger.i(TAG, "Stopped ping loop.");
}.apply { start() };
} else {
Log.i(TAG, "Threads still alive, not restarted")
}
}
private fun sendChannelMessage(sourceId: String, destinationId: String, namespace: String, json: String) {
try {
val castMessage = ChromeCast.CastMessage.newBuilder()
.setProtocolVersion(ChromeCast.CastMessage.ProtocolVersion.CASTV2_1_0)
.setSourceId(sourceId)
.setDestinationId(destinationId)
.setNamespace(namespace)
.setPayloadType(ChromeCast.CastMessage.PayloadType.STRING)
.setPayloadUtf8(json)
.build();
sendMessage(castMessage.toByteArray());
if (namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
//Log.d(TAG, "Sent channel message: $castMessage");
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to send channel message (sourceId: $sourceId, destinationId: $destinationId, namespace: $namespace, json: $json)", e);
_socket?.close();
Logger.i(TAG, "Socket disconnected.");
connectionState = CastConnectionState.CONNECTING;
}
}
private fun handleMessage(message: ChromeCast.CastMessage) {
if (message.payloadType == ChromeCast.CastMessage.PayloadType.STRING) {
val jsonObject = JSONObject(message.payloadUtf8);
val type = jsonObject.getString("type");
if (type == "RECEIVER_STATUS") {
val status = jsonObject.getJSONObject("status");
var sessionIsRunning = false;
if (status.has("applications")) {
val applications = status.getJSONArray("applications");
for (i in 0 until applications.length()) {
val applicationUpdate = applications.getJSONObject(i);
val appId = applicationUpdate.getString("appId");
Logger.i(TAG, "Status update received appId (appId: $appId)");
if (appId == "CC1AD845") {
sessionIsRunning = true;
_autoLaunchEnabled = false
if (_sessionId == null) {
connectionState = CastConnectionState.CONNECTED;
_sessionId = applicationUpdate.getString("sessionId");
_launchRetries = 0
val transportId = applicationUpdate.getString("transportId");
connectMediaChannel(transportId);
Logger.i(TAG, "Connected to media channel $transportId");
_transportId = transportId;
requestMediaStatus();
}
}
}
}
if (!sessionIsRunning) {
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
_sessionId = null
_mediaSessionId = null
_transportId = null
if (_autoLaunchEnabled) {
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
_launchRetries++
launchPlayer()
} else {
// Maybe the first GET_STATUS came back empty; still try launching
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
_launching = true
_launchRetries++
launchPlayer()
}
} else {
Logger.e(TAG, "Player not found ($_launchRetries, _autoLaunchEnabled = $_autoLaunchEnabled); giving up.")
Logger.i(TAG, "Unable to start media receiver on device")
stop()
}
} else {
if (_retryJob == null) {
Logger.i(TAG, "Scheduled retry job over 5 seconds")
_retryJob = _scopeIO?.launch(Dispatchers.IO) {
delay(5000)
getStatus()
_retryJob = null
}
}
}
} else {
_launching = false
_launchRetries = 0
_autoLaunchEnabled = false
}
val volume = status.getJSONObject("volume");
//val volumeControlType = volume.getString("controlType");
val volumeLevel = volume.getString("level").toDouble();
val volumeMuted = volume.getBoolean("muted");
//val volumeStepInterval = volume.getString("stepInterval").toFloat();
setVolume(if (volumeMuted) 0.0 else volumeLevel);
Logger.i(TAG, "Status update received volume (level: $volumeLevel, muted: $volumeMuted)");
} else if (type == "MEDIA_STATUS") {
val statuses = jsonObject.getJSONArray("status");
for (i in 0 until statuses.length()) {
val status = statuses.getJSONObject(i);
_mediaSessionId = status.getInt("mediaSessionId");
val playerState = status.getString("playerState");
val currentTime = status.getDouble("currentTime");
if (status.has("media")) {
val media = status.getJSONObject("media")
if (media.has("duration")) {
setDuration(media.getDouble("duration"))
}
}
isPlaying = playerState == "PLAYING";
if (isPlaying || playerState == "PAUSED") {
setTime(currentTime);
}
val playbackRate = status.getInt("playbackRate");
Logger.i(TAG, "Media update received (mediaSessionId: $_mediaSessionId, playedState: $playerState, currentTime: $currentTime, playbackRate: $playbackRate)");
if (_contentType == null) {
stopVideo();
}
}
val needsLoad = statuses.length() == 0 || (statuses.getJSONObject(0).getString("playerState") == "IDLE")
if (needsLoad && _contentId != null && _mediaSessionId == null) {
Logger.i(TAG, "Receiver idle, sending initial LOAD")
playVideo()
}
} else if (type == "CLOSE") {
if (message.sourceId == "receiver-0") {
Logger.i(TAG, "Close received.");
stopCasting();
} else if (_transportId == message.sourceId) {
throw Exception("Transport id closed.")
}
}
} else {
throw Exception("Payload type ${message.payloadType} is not implemented.");
}
}
private fun sendMessage(data: ByteArray) {
val outputStream = _outputStream;
if (outputStream == null) {
Logger.w(TAG, "Failed to send ${data.size} bytes, output stream is null.");
return;
}
synchronized(_outputStreamLock)
{
val serializedSizeBE = ByteArray(4);
serializedSizeBE[0] = (data.size shr 24 and 0xff).toByte();
serializedSizeBE[1] = (data.size shr 16 and 0xff).toByte();
serializedSizeBE[2] = (data.size shr 8 and 0xff).toByte();
serializedSizeBE[3] = (data.size and 0xff).toByte();
outputStream.write(serializedSizeBE);
outputStream.write(data);
}
//Log.d(TAG, "Sent ${data.size} bytes.");
}
override fun stop() {
Logger.i(TAG, "Stopping...");
usedRemoteAddress = null;
localAddress = null;
_started = false;
_contentId = null
_contentType = null
_streamType = null
_retryJob?.cancel()
_retryJob = null
val socket = _socket;
val scopeIO = _scopeIO;
if (scopeIO != null && socket != null) {
Logger.i(TAG, "Cancelling scopeIO with open socket.")
scopeIO.launch {
socket.close();
connectionState = CastConnectionState.DISCONNECTED;
scopeIO.cancel();
Logger.i(TAG, "Cancelled scopeIO with open socket.")
}
} else {
scopeIO?.cancel();
Logger.i(TAG, "Cancelled scopeIO without open socket.")
}
_pingThread = null;
_thread = null;
_scopeIO = null;
_socket = null;
_outputStream = null;
_inputStream = null;
_mediaSessionId = null;
connectionState = CastConnectionState.DISCONNECTED;
}
override fun getDeviceInfo(): CastingDeviceInfo {
return CastingDeviceInfo(name!!, CastProtocolType.CHROMECAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
}
companion object {
val TAG = "ChromecastCastingDevice";
val trustAllCerts: Array<TrustManager> = arrayOf<TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
override fun getAcceptedIssuers(): Array<X509Certificate> { return emptyArray(); }
});
}
}
@@ -1,636 +0,0 @@
package com.futo.platformplayer.casting
import android.os.Looper
import android.util.Base64
import android.util.Log
import com.futo.platformplayer.UIDialogs
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 com.futo.platformplayer.casting.models.FCastPlaybackErrorMessage
import com.futo.platformplayer.casting.models.FCastPlaybackUpdateMessage
import com.futo.platformplayer.casting.models.FCastSeekMessage
import com.futo.platformplayer.casting.models.FCastSetSpeedMessage
import com.futo.platformplayer.casting.models.FCastSetVolumeMessage
import com.futo.platformplayer.casting.models.FCastVersionMessage
import com.futo.platformplayer.casting.models.FCastVolumeUpdateMessage
import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.toHexString
import com.futo.platformplayer.toInetAddress
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.math.BigInteger
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.PrivateKey
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher
import javax.crypto.KeyAgreement
import javax.crypto.spec.DHParameterSpec
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
enum class Opcode(val value: Byte) {
None(0),
Play(1),
Pause(2),
Resume(3),
Stop(4),
Seek(5),
PlaybackUpdate(6),
VolumeUpdate(7),
SetVolume(8),
PlaybackError(9),
SetSpeed(10),
Version(11),
Ping(12),
Pong(13);
companion object {
private val _map = entries.associateBy { it.value }
fun find(value: Byte): Opcode = _map[value] ?: Opcode.None
}
}
class FCastCastingDevice : CastingDeviceLegacy {
//See for more info: TODO
override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
override var usedRemoteAddress: InetAddress? = null;
override var localAddress: InetAddress? = null;
override val canSetVolume: Boolean get() = true;
override val canSetSpeed: Boolean get() = true;
var addresses: Array<InetAddress>? = null;
var port: Int = 0;
private var _socket: Socket? = null;
private var _outputStream: OutputStream? = null;
private var _inputStream: InputStream? = null;
private var _scopeIO: CoroutineScope? = null;
private var _started: Boolean = false;
private var _version: Long = 1;
private var _thread: Thread? = null
private var _pingThread: Thread? = null
@Volatile private var _lastPongTime = System.currentTimeMillis()
private var _outputStreamLock = Object()
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name;
this.addresses = addresses;
this.port = port;
}
constructor(deviceInfo: CastingDeviceInfo) : super() {
this.name = deviceInfo.name;
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
this.port = deviceInfo.port;
}
override fun getAddresses(): List<InetAddress> {
return addresses?.toList() ?: listOf();
}
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
return;
}
//TODO: Remove this later, temporary for the transition
if (_version <= 1L) {
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
}
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
setTime(resumePosition);
setDuration(duration);
send(Opcode.Play, FCastPlayMessage(
container = contentType,
url = contentId,
time = resumePosition,
speed = speed
));
setSpeed(speed ?: 1.0);
}
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
if (invokeInIOScopeIfRequired({ loadContent(contentType, content, resumePosition, duration, speed) })) {
return;
}
//TODO: Remove this later, temporary for the transition
if (_version <= 1L) {
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
}
Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
setTime(resumePosition);
setDuration(duration);
send(Opcode.Play, FCastPlayMessage(
container = contentType,
content = content,
time = resumePosition,
speed = speed
));
setSpeed(speed ?: 1.0);
}
override fun changeVolume(volume: Double) {
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
return;
}
setVolume(volume);
send(Opcode.SetVolume, FCastSetVolumeMessage(volume))
}
override fun changeSpeed(speed: Double) {
if (invokeInIOScopeIfRequired({ changeSpeed(speed) })) {
return;
}
setSpeed(speed);
send(Opcode.SetSpeed, FCastSetSpeedMessage(speed))
}
override fun seekVideo(timeSeconds: Double) {
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
return;
}
send(Opcode.Seek, FCastSeekMessage(
time = timeSeconds
));
}
override fun resumeVideo() {
if (invokeInIOScopeIfRequired(::resumeVideo)) {
return;
}
send(Opcode.Resume);
}
override fun pauseVideo() {
if (invokeInIOScopeIfRequired(::pauseVideo)) {
return;
}
send(Opcode.Pause);
}
override fun stopVideo() {
if (invokeInIOScopeIfRequired(::stopVideo)) {
return;
}
send(Opcode.Stop);
}
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
if(Looper.getMainLooper().thread == Thread.currentThread()) {
_scopeIO?.launch {
try {
action();
} catch (e: Throwable) {
Logger.e(TAG, "Failed to invoke in IO scope.", e)
}
}
return true;
}
return false;
}
override fun stopCasting() {
if (invokeInIOScopeIfRequired(::stopCasting)) {
return;
}
stopVideo();
Logger.i(TAG, "Stopping active device because stopCasting was called.")
stop();
}
override fun start() {
if (_started) {
return;
}
_started = true;
Logger.i(TAG, "Starting...");
ensureThreadStarted();
Logger.i(TAG, "Started.");
}
fun ensureThreadStarted() {
val adrs = addresses ?: return;
val thread = _thread
val pingThread = _pingThread
if (_started && (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive)) {
Log.i(TAG, "(Re)starting thread because the thread has died")
_scopeIO?.let {
it.cancel()
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
}
_scopeIO = CoroutineScope(Dispatchers.IO);
_thread = Thread {
connectionState = CastConnectionState.CONNECTING;
Log.i(TAG, "Connection thread started.")
var connectedSocket: Socket? = null
while (_scopeIO?.isActive == true) {
try {
Log.i(TAG, "getConnectedSocket (adrs = [ ${adrs.joinToString(", ")} ], port = ${port}).")
val resultSocket = getConnectedSocket(adrs.toList(), port);
if (resultSocket == null) {
Log.i(TAG, "Connection failed, waiting 1 seconds.")
Thread.sleep(1000);
continue;
}
Log.i(TAG, "Connection succeeded.")
connectedSocket = resultSocket
usedRemoteAddress = connectedSocket.inetAddress
localAddress = connectedSocket.localAddress
break;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
Thread.sleep(1000);
}
}
val address = InetSocketAddress(usedRemoteAddress, port)
//Connection loop
while (_scopeIO?.isActive == true) {
Logger.i(TAG, "Connecting to FastCast.");
connectionState = CastConnectionState.CONNECTING;
try {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
if (connectedSocket != null) {
Logger.i(TAG, "Using connected socket.");
_socket = connectedSocket
connectedSocket = null
} else {
Logger.i(TAG, "Using new socket.");
_socket = Socket().apply { this.connect(address, 2000) };
}
Logger.i(TAG, "Successfully connected to FastCast at $usedRemoteAddress:$port");
_outputStream = _socket?.outputStream;
_inputStream = _socket?.inputStream;
} catch (e: IOException) {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
Logger.i(TAG, "Failed to connect to FastCast.", e);
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
continue;
}
localAddress = _socket?.localAddress
_lastPongTime = System.currentTimeMillis()
connectionState = CastConnectionState.CONNECTED
val buffer = ByteArray(4096);
Logger.i(TAG, "Started receiving.");
while (_scopeIO?.isActive == true) {
try {
val inputStream = _inputStream ?: break;
Log.d(TAG, "Receiving next packet...");
var headerBytesRead = 0
while (headerBytesRead < 4) {
val read = inputStream.read(buffer, headerBytesRead, 4 - headerBytesRead)
if (read == -1)
throw Exception("Stream closed")
headerBytesRead += read
}
val size = ((buffer[3].toUByte().toLong() shl 24) or (buffer[2].toUByte().toLong() shl 16) or (buffer[1].toUByte().toLong() shl 8) or buffer[0].toUByte().toLong()).toInt();
if (size > buffer.size) {
Logger.w(TAG, "Packets larger than $size bytes are not supported.")
break
}
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
var bytesRead = 0
while (bytesRead < size) {
val read = inputStream.read(buffer, bytesRead, size - bytesRead)
if (read == -1)
throw Exception("Stream closed")
bytesRead += read
}
val messageBytes = buffer.sliceArray(IntRange(0, size));
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
val opcode = messageBytes[0];
var json: String? = null;
if (size > 1) {
json = messageBytes.sliceArray(IntRange(1, size - 1)).decodeToString();
}
try {
handleMessage(Opcode.find(opcode), json);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to handle message.", e)
break
}
} catch (e: java.net.SocketException) {
Logger.e(TAG, "Socket exception while receiving.", e);
break
} catch (e: Throwable) {
Logger.e(TAG, "Exception while receiving.", e);
break
}
}
try {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
Logger.i(TAG, "Socket disconnected.");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to close socket.", e)
}
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(1000);
}
Logger.i(TAG, "Stopped connection loop.");
connectionState = CastConnectionState.DISCONNECTED;
}.apply { start() }
_pingThread = Thread {
Logger.i(TAG, "Started ping loop.")
while (_scopeIO?.isActive == true) {
if (connectionState == CastConnectionState.CONNECTED) {
try {
send(Opcode.Ping)
if (System.currentTimeMillis() - _lastPongTime > 15000) {
Logger.w(TAG, "Closing socket due to last pong time being larger than 15 seconds.")
try {
_socket?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}
} catch (e: Throwable) {
Log.w(TAG, "Failed to send ping.")
try {
_socket?.close()
_inputStream?.close()
_outputStream?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}
}
Thread.sleep(5000)
}
Logger.i(TAG, "Stopped ping loop.")
}.apply { start() }
} else {
Log.i(TAG, "Thread was still alive, not restarted")
}
}
private fun handleMessage(opcode: Opcode, json: String? = null) {
Log.i(TAG, "Processing packet (opcode: $opcode, size: ${json?.length ?: 0})")
when (opcode) {
Opcode.PlaybackUpdate -> {
if (json == null) {
Logger.w(TAG, "Got playback update without JSON, ignoring.");
return;
}
val playbackUpdate = FCastCastingDevice.json.decodeFromString<FCastPlaybackUpdateMessage>(json);
setTime(playbackUpdate.time, playbackUpdate.generationTime);
setDuration(playbackUpdate.duration, playbackUpdate.generationTime);
isPlaying = when (playbackUpdate.state) {
1 -> true
else -> false
}
}
Opcode.VolumeUpdate -> {
if (json == null) {
Logger.w(TAG, "Got volume update without JSON, ignoring.");
return;
}
val volumeUpdate = FCastCastingDevice.json.decodeFromString<FCastVolumeUpdateMessage>(json);
setVolume(volumeUpdate.volume, volumeUpdate.generationTime);
}
Opcode.PlaybackError -> {
if (json == null) {
Logger.w(TAG, "Got playback error without JSON, ignoring.");
return;
}
val playbackError = FCastCastingDevice.json.decodeFromString<FCastPlaybackErrorMessage>(json);
Logger.e(TAG, "Remote casting playback error received: $playbackError")
}
Opcode.Version -> {
if (json == null) {
Logger.w(TAG, "Got version without JSON, ignoring.");
return;
}
val version = FCastCastingDevice.json.decodeFromString<FCastVersionMessage>(json);
_version = version.version;
Logger.i(TAG, "Remote version received: $version")
}
Opcode.Ping -> send(Opcode.Pong)
Opcode.Pong -> _lastPongTime = System.currentTimeMillis()
else -> { }
}
}
private fun send(opcode: Opcode, message: String? = null) {
ensureNotMainThread()
synchronized (_outputStreamLock) {
try {
val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0)
val size = 1 + data.size
val outputStream = _outputStream
if (outputStream == null) {
Log.w(TAG, "Failed to send $size bytes, output stream is null.")
return
}
val serializedSizeLE = ByteArray(4)
serializedSizeLE[0] = (size and 0xff).toByte()
serializedSizeLE[1] = (size shr 8 and 0xff).toByte()
serializedSizeLE[2] = (size shr 16 and 0xff).toByte()
serializedSizeLE[3] = (size shr 24 and 0xff).toByte()
outputStream.write(serializedSizeLE)
val opcodeBytes = ByteArray(1)
opcodeBytes[0] = opcode.value
outputStream.write(opcodeBytes)
if (data.isNotEmpty()) {
outputStream.write(data)
}
Log.d(TAG, "Sent $size bytes: (opcode: $opcode, body: $message).")
} catch (e: Throwable) {
Log.i(TAG, "Failed to send message.", e)
throw e
}
}
}
private inline fun <reified T> send(opcode: Opcode, message: T) {
try {
send(opcode, message?.let { Json.encodeToString(it) })
} catch (e: Throwable) {
Log.i(TAG, "Failed to encode message to string.", e)
throw e
}
}
override fun stop() {
Logger.i(TAG, "Stopping...");
usedRemoteAddress = null;
localAddress = null;
_started = false;
//TODO: Kill and/or join thread?
_thread = null;
_pingThread = null;
val socket = _socket;
val scopeIO = _scopeIO;
if (scopeIO != null && socket != null) {
Logger.i(TAG, "Cancelling scopeIO with open socket.")
scopeIO.launch {
socket.close();
_inputStream?.close()
_outputStream?.close()
connectionState = CastConnectionState.DISCONNECTED;
scopeIO.cancel();
Logger.i(TAG, "Cancelled scopeIO with open socket.")
}
} else {
scopeIO?.cancel();
Logger.i(TAG, "Cancelled scopeIO without open socket.")
}
_scopeIO = null;
_socket = null;
_outputStream = null;
_inputStream = null;
connectionState = CastConnectionState.DISCONNECTED;
}
override fun getDeviceInfo(): CastingDeviceInfo {
return CastingDeviceInfo(name!!, CastProtocolType.FCAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
}
companion object {
val TAG = "FCastCastingDevice";
private val json = Json { ignoreUnknownKeys = true }
fun getKeyExchangeMessage(keyPair: KeyPair): FCastKeyExchangeMessage {
return FCastKeyExchangeMessage(1, Base64.encodeToString(keyPair.public.encoded, Base64.NO_WRAP))
}
fun generateKeyPair(): KeyPair {
//modp14
val p = BigInteger("ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aacaa68ffffffffffffffff", 16)
val g = BigInteger("2", 16)
val dhSpec = DHParameterSpec(p, g)
val keyGen = KeyPairGenerator.getInstance("DH")
keyGen.initialize(dhSpec)
return keyGen.generateKeyPair()
}
fun computeSharedSecret(privateKey: PrivateKey, keyExchangeMessage: FCastKeyExchangeMessage): SecretKeySpec {
val keyFactory = KeyFactory.getInstance("DH")
val receivedPublicKeyBytes = Base64.decode(keyExchangeMessage.publicKey, Base64.NO_WRAP)
val receivedPublicKeySpec = X509EncodedKeySpec(receivedPublicKeyBytes)
val receivedPublicKey = keyFactory.generatePublic(receivedPublicKeySpec)
val keyAgreement = KeyAgreement.getInstance("DH")
keyAgreement.init(privateKey)
keyAgreement.doPhase(receivedPublicKey, true)
val sharedSecret = keyAgreement.generateSecret()
Log.i(TAG, "sharedSecret ${Base64.encodeToString(sharedSecret, Base64.NO_WRAP)}")
val sha256 = MessageDigest.getInstance("SHA-256")
val hashedSecret = sha256.digest(sharedSecret)
Log.i(TAG, "hashedSecret ${Base64.encodeToString(hashedSecret, Base64.NO_WRAP)}")
return SecretKeySpec(hashedSecret, "AES")
}
fun encryptMessage(aesKey: SecretKeySpec, decryptedMessage: FCastDecryptedMessage): FCastEncryptedMessage {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, aesKey)
val iv = cipher.iv
val json = Json.encodeToString(decryptedMessage)
val encrypted = cipher.doFinal(json.toByteArray(Charsets.UTF_8))
return FCastEncryptedMessage(
version = 1,
iv = Base64.encodeToString(iv, Base64.NO_WRAP),
blob = Base64.encodeToString(encrypted, Base64.NO_WRAP)
)
}
fun decryptMessage(aesKey: SecretKeySpec, encryptedMessage: FCastEncryptedMessage): FCastDecryptedMessage {
val iv = Base64.decode(encryptedMessage.iv, Base64.NO_WRAP)
val encrypted = Base64.decode(encryptedMessage.blob, Base64.NO_WRAP)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv))
val decryptedJson = cipher.doFinal(encrypted)
return Json.decodeFromString(String(decryptedJson, Charsets.UTF_8))
}
}
}
@@ -6,7 +6,9 @@ import android.content.Context
import android.os.Looper
import android.util.Log
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
@@ -14,9 +16,11 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.api.http.server.ManagedHttpServer
import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler
import com.futo.platformplayer.api.http.server.handlers.HttpContentUriHandler
import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
@@ -33,8 +37,11 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
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.awaitCancelConverted
import com.futo.platformplayer.builders.DashBuilder
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
@@ -51,16 +58,22 @@ import com.futo.platformplayer.views.casting.CastView.Companion
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.fcast.sender_sdk.CastContext
import org.fcast.sender_sdk.DeviceInfo
import org.fcast.sender_sdk.Metadata
import org.fcast.sender_sdk.NsdDeviceDiscoverer
import org.fcast.sender_sdk.ProtocolType
import java.net.Inet6Address
import java.net.URLDecoder
import java.net.URLEncoder
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
abstract class StateCasting {
class StateCasting {
val _scopeIO = CoroutineScope(Dispatchers.IO);
val _scopeMain = CoroutineScope(Dispatchers.Main);
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
@@ -77,6 +90,7 @@ abstract class StateCasting {
val onActiveDeviceTimeChanged = Event1<Double>();
val onActiveDeviceDurationChanged = Event1<Double>();
val onActiveDeviceVolumeChanged = Event1<Double>();
val onActiveDeviceMediaItemEnd = Event0()
var activeDevice: CastingDevice? = null;
private var _videoExecutor: JSRequestExecutor? = null
private var _audioExecutor: JSRequestExecutor? = null
@@ -85,15 +99,163 @@ abstract class StateCasting {
val isCasting: Boolean get() = activeDevice != null;
private val _castId = AtomicInteger(0)
abstract fun handleUrl(url: String)
abstract fun onStop()
abstract fun start(context: Context)
abstract fun stop()
private val _context = CastContext()
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
abstract fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice?
abstract fun startUpdateTimeJob(
onTimeJobTimeChanged_s: Event1<Long>, setTime: (Long) -> Unit
): Job?
class DiscoveryEventHandler(
private val onDeviceAdded: (RsDeviceInfo) -> Unit,
private val onDeviceRemoved: (String) -> Unit,
private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
onDeviceAdded(deviceInfo)
}
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
onDeviceUpdated(deviceInfo)
}
override fun deviceRemoved(deviceName: String) {
onDeviceRemoved(deviceName)
}
}
init {
if (BuildConfig.DEBUG) {
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
}
}
fun handleUrl(url: String) {
try {
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
connectDevice(CastingDevice(foundDevice))
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle URL: $e")
}
}
fun onStop() {
val ad = activeDevice ?: return
_resumeCastingDevice = ad.getDeviceInfo()
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
Logger.i(TAG, "Stopping active device because of onStop.")
try {
ad.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect from device: $e")
}
}
@Synchronized
fun start(context: Context) {
if (_started)
return
_started = true
Log.i(TAG, "_resumeCastingDevice set null start")
_resumeCastingDevice = null
Logger.i(TAG, "CastingService starting...")
_castServer.start()
enableDeveloper(true)
Logger.i(TAG, "CastingService started.")
_deviceDiscoverer = NsdDeviceDiscoverer(
context,
DiscoveryEventHandler(
{ deviceInfo -> // Added
Logger.i(TAG, "Device added: ${deviceInfo.name}")
val device = _context.createDeviceFromInfo(deviceInfo)
val deviceHandle = CastingDevice(device)
devices[deviceHandle.device.name()] = deviceHandle
invokeInMainScopeIfRequired {
onDeviceAdded.emit(deviceHandle)
}
},
{ deviceName -> // Removed
invokeInMainScopeIfRequired {
if (devices.containsKey(deviceName)) {
val device = devices.remove(deviceName)
if (device != null) {
onDeviceRemoved.emit(device)
}
}
}
},
{ deviceInfo -> // Updated
Logger.i(TAG, "Device updated: $deviceInfo")
val handle = devices[deviceInfo.name]
if (handle != null && handle is CastingDevice) {
handle.device.setPort(deviceInfo.port)
handle.device.setAddresses(deviceInfo.addresses)
invokeInMainScopeIfRequired {
onDeviceChanged.emit(handle)
}
}
},
)
)
}
@Synchronized
fun stop() {
if (!_started) {
return
}
_started = false
Logger.i(TAG, "CastingService stopping.")
_scopeIO.cancel()
_scopeMain.cancel()
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
val d = activeDevice
activeDevice = null
try {
d?.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect device: $e")
}
_castServer.stop()
_castServer.removeAllHandlers()
Logger.i(TAG, "CastingService stopped.")
_deviceDiscoverer = null
}
fun startUpdateTimeJob(
onTimeJobTimeChanged_s: Event1<Long>,
setTime: (Long) -> Unit
): Job? = null
fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice? {
try {
val rsAddrs =
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) }
val rsDeviceInfo = RsDeviceInfo(
name = deviceInfo.name,
protocol = when (deviceInfo.type) {
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
else -> throw IllegalArgumentException()
},
addresses = rsAddrs,
port = deviceInfo.port.toUShort(),
)
return CastingDevice(_context.createDeviceFromInfo(rsDeviceInfo))
} catch (_: Throwable) {
return null
}
}
fun onResume() {
val ad = activeDevice
@@ -140,6 +302,7 @@ abstract class StateCasting {
device.onTimeChanged.clear();
device.onVolumeChanged.clear();
device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
ad.disconnect()
}
@@ -154,6 +317,7 @@ abstract class StateCasting {
device.onTimeChanged.clear();
device.onVolumeChanged.clear();
device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
activeDevice = null;
}
@@ -217,6 +381,9 @@ abstract class StateCasting {
device.onTimeChanged.subscribe {
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
};
device.onMediaItemEnd.subscribe {
invokeInMainScopeIfRequired { onActiveDeviceMediaItemEnd.emit() }
}
try {
device.connect();
@@ -227,6 +394,7 @@ abstract class StateCasting {
device.onTimeChanged.clear();
device.onVolumeChanged.clear();
device.onDurationChanged.clear();
device.onMediaItemEnd.clear();
return;
}
@@ -234,9 +402,9 @@ abstract class StateCasting {
Logger.i(TAG, "Connect to device ${device.name}")
}
fun metadataFromVideo(video: IPlatformVideoDetails): Metadata {
fun metadataFromVideo(video: IPlatformVideoDetails, videoThumbnailOverrideUrl: String? = null): Metadata {
return Metadata(
title = video.name, thumbnailUrl = video.thumbnails.getHQThumbnail()
title = video.name, thumbnailUrl = videoThumbnailOverrideUrl ?: video.thumbnails.getHQThumbnail()
)
}
@@ -295,20 +463,63 @@ abstract class StateCasting {
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
if (videoSource is IVideoUrlSource) {
val videoPath = "/video-${id}"
val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl();
Logger.i(TAG, "Casting as singular video");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
val videoPath = "/video-$id"
val upstreamUrl = videoSource.getVideoUrl()
val videoUrl = if (proxyStreams) url + videoPath else upstreamUrl
val jsReqMod = (videoSource as? JSSource)?.getRequestModifier()
if (proxyStreams) {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", videoPath, upstreamUrl, true)
.withIRequestModifier(jsReqMod)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"),
true
).withTag("castSingular")
}
Logger.i(TAG, "Casting as singular video (proxy=$proxyStreams, url=$videoUrl)")
ad.loadVideo(
if (video.isLive) "LIVE" else "BUFFERED",
videoSource.container,
videoUrl,
resumePosition,
video.duration.toDouble(),
speed,
metadataFromVideo(video)
)
} else if (audioSource is IAudioUrlSource) {
val audioPath = "/audio-${id}"
val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl();
Logger.i(TAG, "Casting as singular audio");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
val audioPath = "/audio-$id"
val upstreamUrl = audioSource.getAudioUrl()
val audioUrl = if (proxyStreams) url + audioPath else upstreamUrl
val jsReqMod = (audioSource as? JSSource)?.getRequestModifier()
if (proxyStreams) {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", audioPath, upstreamUrl, true)
.withIRequestModifier(jsReqMod)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"),
true
).withTag("castSingular")
}
Logger.i(TAG, "Casting as singular audio (proxy=$proxyStreams, url=$audioUrl)")
ad.loadVideo(
if (video.isLive) "LIVE" else "BUFFERED",
audioSource.container,
audioUrl,
resumePosition,
video.duration.toDouble(),
speed,
metadataFromVideo(video)
)
} else if (videoSource is IHLSManifestSource) {
if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
Logger.i(TAG, "Casting as proxied HLS");
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed);
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed, (videoSource as JSSource?)?.getRequestModifier());
} else {
Logger.i(TAG, "Casting as non-proxied HLS");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
@@ -316,7 +527,7 @@ abstract class StateCasting {
} else if (audioSource is IHLSManifestAudioSource) {
if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
Logger.i(TAG, "Casting as proxied audio HLS");
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed);
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed, (audioSource as JSSource?)?.getRequestModifier());
} else {
Logger.i(TAG, "Casting as non-proxied audio HLS");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
@@ -327,6 +538,12 @@ abstract class StateCasting {
} else if (audioSource is LocalAudioSource) {
Logger.i(TAG, "Casting as local audio");
castLocalAudio(video, audioSource, resumePosition, speed);
} else if (videoSource is LocalVideoContentSource) {
Logger.i(TAG, "Casting as local video");
castLocalVideo(contentResolver, video, videoSource, resumePosition, speed);
} else if (audioSource is LocalAudioContentSource) {
Logger.i(TAG, "Casting as local audio");
castLocalAudio(contentResolver, video, audioSource, resumePosition, speed);
} else if (videoSource is JSDashManifestRawSource) {
Logger.i(TAG, "Casting as JSDashManifestRawSource video");
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
@@ -347,6 +564,11 @@ abstract class StateCasting {
}
}
private fun HttpProxyHandler.withIRequestModifier(requestModifier: IRequestModifier?): HttpProxyHandler {
if (requestModifier == null) return this
return withRequestModifier { url, headers -> requestModifier.modifyRequest(url, headers) }
}
fun resumeVideo(): Boolean {
val ad = activeDevice ?: return false;
try {
@@ -412,6 +634,65 @@ abstract class StateCasting {
}
return true;
}
private fun castLocalVideo(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: LocalVideoContentSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf();
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
val videoPath = "/video-${id}"
val videoUrl = url + videoPath;
val thumbnailPath = "/thumbnail-${id}"
val thumbnailUrl = url + thumbnailPath;
val thumbnailContentUrl = video.thumbnails.getHQThumbnail()
if (thumbnailContentUrl != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri())
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
}
_castServer.addHandlerWithAllowAllOptions(
HttpContentUriHandler("GET", videoPath, contentResolver, videoSource.contentUrl.toUri())
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
Logger.i(TAG, "Casting local video (videoUrl: $videoUrl).");
ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video, if (thumbnailContentUrl != null) thumbnailUrl else null));
return listOf(videoUrl);
}
private fun castLocalAudio(contentResolver: ContentResolver, video: IPlatformVideoDetails, audioSource: LocalAudioContentSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf();
val url = getLocalUrl(ad);
val id = UUID.randomUUID();
val audioPath = "/audio-${id}"
val audioUrl = url + audioPath;
val thumbnailPath = "/thumbnail-${id}"
val thumbnailUrl = url + thumbnailPath;
val thumbnailContentUrl = video.thumbnails.getHQThumbnail()
if (thumbnailContentUrl != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri())
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
}
_castServer.addHandlerWithAllowAllOptions(
HttpContentUriHandler("GET", audioPath, contentResolver, audioSource.contentUrl.toUri())
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl).");
ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video, if (thumbnailContentUrl != null) thumbnailUrl else null));
return listOf(audioUrl);
}
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf();
@@ -665,7 +946,8 @@ abstract class StateCasting {
sourceUrl: String,
codec: String?,
resumePosition: Double,
speed: Double?
speed: Double?,
requestModifier: IRequestModifier?
): List<String> {
_castServer.removeAllHandlers("castProxiedHlsMaster")
@@ -686,7 +968,9 @@ abstract class StateCasting {
val headers = masterContext.headers.clone()
headers["Content-Type"] = "application/vnd.apple.mpegurl";
val masterPlaylistResponse = _client.get(sourceUrl)
val req = requestModifier?.modifyRequest(sourceUrl, mapOf())
val masterPlaylistResponse = _client.get(req?.url ?: sourceUrl, (req?.headers ?: mapOf()).toMutableMap())
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
val masterPlaylistContent = masterPlaylistResponse.body?.string()
@@ -706,7 +990,7 @@ abstract class StateCasting {
val variantPlaylist =
HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl)
val proxiedVariantPlaylist =
proxyVariantPlaylist(url, id, variantPlaylist, video.isLive)
proxyVariantPlaylist(url, id, variantPlaylist, video.isLive, requestModifier)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
return@HttpFunctionHandler
@@ -747,7 +1031,7 @@ abstract class StateCasting {
val variantPlaylist =
HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url)
val proxiedVariantPlaylist =
proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive)
proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive, requestModifier)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
}.withHeader("Access-Control-Allow-Origin", "*"), true
@@ -784,7 +1068,7 @@ abstract class StateCasting {
val variantPlaylist =
HLS.parseVariantPlaylist(vpContent, mediaRendition.uri)
val proxiedVariantPlaylist = proxyVariantPlaylist(
url, playlistId, variantPlaylist, video.isLive
url, playlistId, variantPlaylist, video.isLive, requestModifier
)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
@@ -826,13 +1110,13 @@ abstract class StateCasting {
return listOf(hlsUrl);
}
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, proxySegments: Boolean = true): HLS.VariantPlaylist {
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, requestModifier: IRequestModifier?, proxySegments: Boolean = true): HLS.VariantPlaylist {
val newSegments = arrayListOf<HLS.Segment>()
if (proxySegments) {
variantPlaylist.segments.forEachIndexed { index, segment ->
val sequenceNumber = (variantPlaylist.mediaSequence ?: 0) + index.toLong()
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber))
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber, requestModifier))
}
} else {
newSegments.addAll(variantPlaylist.segments)
@@ -850,7 +1134,7 @@ abstract class StateCasting {
)
}
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment {
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long, requestModifier: IRequestModifier?): HLS.Segment {
if (segment is HLS.MediaSegment) {
val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}"
val newSegmentUrl = url + newSegmentPath;
@@ -858,6 +1142,7 @@ abstract class StateCasting {
if (_castServer.getHandler("GET", newSegmentPath) == null) {
_castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", newSegmentPath, segment.uri, true)
.withIRequestModifier(requestModifier)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castProxiedHlsVariant")
@@ -1111,6 +1396,47 @@ abstract class StateCasting {
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
}
private fun escapeXml(s: String): String =
s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
private fun injectSubtitleAdaptationSet(
mpd: String,
subtitleUrl: String,
mimeType: String,
lang: String = "und",
label: String = "Subtitles"
): String {
val mt = mimeType.lowercase()
val codecs = when (mt) {
"text/vtt", "text/webvtt" -> "wvtt"
"application/ttml+xml", "application/ttml" -> "stpp"
else -> null
}
val codecsAttr = codecs?.let { " codecs=\"${escapeXml(it)}\"" } ?: ""
val adaptation = """
<AdaptationSet id="123456" contentType="text" mimeType="${escapeXml(mimeType)}" lang="${escapeXml(lang)}">
<Role schemeIdUri="urn:mpeg:dash:role:2011" value="subtitle"/>
<Label>${escapeXml(label)}</Label>
<Representation id="123457"$codecsAttr bandwidth="256" mimeType="${escapeXml(mimeType)}">
<BaseURL>${escapeXml(subtitleUrl)}</BaseURL>
</Representation>
</AdaptationSet>
""".trimIndent()
val periodClose = Regex("</Period\\s*>", RegexOption.IGNORE_CASE)
return if (periodClose.containsMatchIn(mpd)) {
mpd.replaceFirst(periodClose, adaptation + "\n</Period>")
} else {
mpd
}
}
@OptIn(UnstableApi::class)
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?, castId: Int, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null) : List<String> {
val ad = activeDevice ?: return listOf();
@@ -1132,30 +1458,42 @@ abstract class StateCasting {
val videoUrl = url + videoPath
val audioUrl = url + audioPath
val subtitleMimeTypeFull = subtitleSource?.format ?: "text/vtt"
val subtitleMimeTypeForMpd = subtitleMimeTypeFull.substringBefore(';').trim()
val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) {
return@withContext subtitleSource.getSubtitlesURI();
} else null;
subtitleSource.getSubtitlesURI()
} else null
var subtitlesUrl: String? = null;
var subtitlesUrl: String? = null
if (subtitlesUri != null) {
if(subtitlesUri.scheme == "file") {
var content: String? = null;
val inputStream = contentResolver.openInputStream(subtitlesUri);
inputStream?.use { stream ->
val reader = stream.bufferedReader();
content = reader.use { it.readText() };
when (subtitlesUri.scheme) {
"file", "content" -> {
val content = withContext(Dispatchers.IO) {
contentResolver.openInputStream(subtitlesUri)?.use { stream ->
stream.bufferedReader().use { it.readText() }
}
}
if (!content.isNullOrEmpty()) {
_castServer.addHandlerWithAllowAllOptions(
HttpConstantHandler("GET", subtitlePath, content, subtitleMimeTypeFull)
.withHeader("Access-Control-Allow-Origin", "*"),
true
).withTag("castDashRaw")
subtitlesUrl = url + subtitlePath
}
}
if (content != null) {
_castServer.addHandlerWithAllowAllOptions(
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast");
"http", "https" -> {
// Receiver will fetch directly (works only if it doesnt need auth/headers)
subtitlesUrl = subtitlesUri.toString()
}
subtitlesUrl = url + subtitlePath;
} else {
subtitlesUrl = subtitlesUri.toString();
else -> {
Logger.w(TAG, "Unsupported subtitlesUri scheme: ${subtitlesUri.scheme}")
}
}
}
@@ -1201,8 +1539,22 @@ abstract class StateCasting {
return emptyList()
}
if (subtitlesUrl != null) {
dashContent = injectSubtitleAdaptationSet(
dashContent,
subtitlesUrl!!,
subtitleMimeTypeForMpd
)
}
var hasAudioInDash = false
for (representation in representationRegex.findAll(dashContent)) {
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
if (mediaType.startsWith("audio/")) {
hasAudioInDash = true
}
dashContent = mediaInitializationRegex.replace(dashContent) {
if (it.range.first < representation.range.first || it.range.last > representation.range.last) {
return@replace it.value
@@ -1226,12 +1578,20 @@ abstract class StateCasting {
throw Exception("Audio source without request executor not supported")
}
if (audioSource != null && audioSource.hasRequestExecutor) {
_audioExecutor = audioSource.getRequestExecutor()
if (videoSource != null && videoSource.hasRequestExecutor) {
val oldVideoExecutor = _videoExecutor
oldVideoExecutor?.closeAsync()
_videoExecutor = videoSource.getRequestExecutor()
}
if (videoSource != null && videoSource.hasRequestExecutor) {
_videoExecutor = videoSource.getRequestExecutor()
if (audioSource != null) {
val oldExecutor = _audioExecutor
oldExecutor?.closeAsync()
_audioExecutor = audioSource.getRequestExecutor()
} else if (hasAudioInDash && videoSource != null) {
val oldExecutor = _audioExecutor
oldExecutor?.closeAsync()
_audioExecutor = _videoExecutor
}
//TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also
@@ -1262,7 +1622,7 @@ abstract class StateCasting {
}.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castDashRaw");
}
if (audioSource != null) {
if (audioSource != null || (audioSource == null && hasAudioInDash)) {
_castServer.addHandlerWithAllowAllOptions(
HttpFunctionHandler("GET", audioPath) { httpContext ->
val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler
@@ -1327,11 +1687,7 @@ abstract class StateCasting {
}
companion object {
var instance: StateCasting = if (Settings.instance.casting.experimentalCasting) {
StateCastingExp()
} else {
StateCastingLegacy()
}
var instance = StateCasting()
private val representationRegex = Regex(
"<Representation .*?mimeType=\"(.*?)\".*?>(.*?)<\\/Representation>",
RegexOption.DOT_MATCHES_ALL
@@ -1,178 +0,0 @@
package com.futo.platformplayer.casting
import android.content.Context
import android.util.Log
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
import org.fcast.sender_sdk.ProtocolType
import org.fcast.sender_sdk.CastContext
import org.fcast.sender_sdk.NsdDeviceDiscoverer
class StateCastingExp : StateCasting() {
private val _context = CastContext()
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
class DiscoveryEventHandler(
private val onDeviceAdded: (RsDeviceInfo) -> Unit,
private val onDeviceRemoved: (String) -> Unit,
private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
onDeviceAdded(deviceInfo)
}
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
onDeviceUpdated(deviceInfo)
}
override fun deviceRemoved(deviceName: String) {
onDeviceRemoved(deviceName)
}
}
init {
if (BuildConfig.DEBUG) {
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
}
}
override fun handleUrl(url: String) {
try {
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
connectDevice(CastingDeviceExp(foundDevice))
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle URL: $e")
}
}
override fun onStop() {
val ad = activeDevice ?: return
_resumeCastingDevice = ad.getDeviceInfo()
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
Logger.i(TAG, "Stopping active device because of onStop.")
try {
ad.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect from device: $e")
}
}
@Synchronized
override fun start(context: Context) {
if (_started)
return
_started = true
Log.i(TAG, "_resumeCastingDevice set null start")
_resumeCastingDevice = null
Logger.i(TAG, "CastingService starting...")
_castServer.start()
enableDeveloper(true)
Logger.i(TAG, "CastingService started.")
_deviceDiscoverer = NsdDeviceDiscoverer(
context,
DiscoveryEventHandler(
{ deviceInfo -> // Added
Logger.i(TAG, "Device added: ${deviceInfo.name}")
val device = _context.createDeviceFromInfo(deviceInfo)
val deviceHandle = CastingDeviceExp(device)
devices[deviceHandle.device.name()] = deviceHandle
invokeInMainScopeIfRequired {
onDeviceAdded.emit(deviceHandle)
}
},
{ deviceName -> // Removed
invokeInMainScopeIfRequired {
if (devices.containsKey(deviceName)) {
val device = devices.remove(deviceName)
if (device != null) {
onDeviceRemoved.emit(device)
}
}
}
},
{ deviceInfo -> // Updated
Logger.i(TAG, "Device updated: $deviceInfo")
val handle = devices[deviceInfo.name]
if (handle != null && handle is CastingDeviceExp) {
handle.device.setPort(deviceInfo.port)
handle.device.setAddresses(deviceInfo.addresses)
invokeInMainScopeIfRequired {
onDeviceChanged.emit(handle)
}
}
},
)
)
}
@Synchronized
override fun stop() {
if (!_started) {
return
}
_started = false
Logger.i(TAG, "CastingService stopping.")
_scopeIO.cancel()
_scopeMain.cancel()
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
val d = activeDevice
activeDevice = null
try {
d?.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to disconnect device: $e")
}
_castServer.stop()
_castServer.removeAllHandlers()
Logger.i(TAG, "CastingService stopped.")
_deviceDiscoverer = null
}
override fun startUpdateTimeJob(
onTimeJobTimeChanged_s: Event1<Long>,
setTime: (Long) -> Unit
): Job? = null
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDeviceExp? {
try {
val rsAddrs =
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) }
val rsDeviceInfo = RsDeviceInfo(
name = deviceInfo.name,
protocol = when (deviceInfo.type) {
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
else -> throw IllegalArgumentException()
},
addresses = rsAddrs,
port = deviceInfo.port.toUShort(),
)
return CastingDeviceExp(_context.createDeviceFromInfo(rsDeviceInfo))
} catch (_: Throwable) {
return null
}
}
companion object {
private val TAG = "StateCastingExp"
}
}
@@ -1,399 +0,0 @@
package com.futo.platformplayer.casting
import android.content.Context
import android.net.Uri
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.util.Base64
import android.util.Log
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.net.InetAddress
import kotlinx.coroutines.delay
class StateCastingLegacy : StateCasting() {
private var _nsdManager: NsdManager? = null
private val _discoveryListeners = mapOf(
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
)
override fun handleUrl(url: String) {
val uri = Uri.parse(url)
if (uri.scheme != "fcast") {
throw Exception("Expected scheme to be FCast")
}
val type = uri.host
if (type != "r") {
throw Exception("Expected type r")
}
val connectionInfo = uri.pathSegments[0]
val json =
Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
.toString(Charsets.UTF_8)
val networkConfig = Json.decodeFromString<FCastNetworkConfig>(json)
val tcpService = networkConfig.services.first { v -> v.type == 0 }
val foundInfo = addRememberedDevice(
CastingDeviceInfo(
name = networkConfig.name,
type = CastProtocolType.FCAST,
addresses = networkConfig.addresses.toTypedArray(),
port = tcpService.port
)
)
if (foundInfo != null) {
connectDevice(deviceFromInfo(foundInfo))
}
}
override fun onStop() {
val ad = activeDevice ?: return;
_resumeCastingDevice = ad.getDeviceInfo()
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
Logger.i(TAG, "Stopping active device because of onStop.");
ad.disconnect();
}
@Synchronized
override fun start(context: Context) {
if (_started)
return;
_started = true;
Log.i(TAG, "_resumeCastingDevice set null start")
_resumeCastingDevice = null;
Logger.i(TAG, "CastingService starting...");
_castServer.start();
enableDeveloper(true);
Logger.i(TAG, "CastingService started.");
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
startDiscovering()
}
@Synchronized
private fun startDiscovering() {
_nsdManager?.apply {
_discoveryListeners.forEach {
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
}
}
}
@Synchronized
private fun stopDiscovering() {
_nsdManager?.apply {
_discoveryListeners.forEach {
try {
stopServiceDiscovery(it.value)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
}
}
@Synchronized
override fun stop() {
if (!_started)
return;
_started = false;
Logger.i(TAG, "CastingService stopping.")
stopDiscovering()
_scopeIO.cancel();
_scopeMain.cancel();
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
val d = activeDevice;
activeDevice = null;
d?.disconnect();
_castServer.stop();
_castServer.removeAllHandlers();
Logger.i(TAG, "CastingService stopped.")
_nsdManager = null
}
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
return object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(regType: String) {
Log.d(TAG, "Service discovery started for $regType")
}
override fun onDiscoveryStopped(serviceType: String) {
Log.i(TAG, "Discovery stopped: $serviceType")
}
override fun onServiceLost(service: NsdServiceInfo) {
Log.e(TAG, "service lost: $service")
// TODO: Handle service lost, e.g., remove device
}
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
try {
_nsdManager?.stopServiceDiscovery(this)
} catch (e: Throwable) {
Logger.w(TAG, "Failed to stop service discovery", e)
}
}
override fun onServiceFound(service: NsdServiceInfo) {
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
service.hostAddresses.toTypedArray()
} else {
arrayOf(service.host)
}
addOrUpdate(service.serviceName, addresses, service.port)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
_nsdManager?.registerServiceInfoCallback(
service,
{ it.run() },
object : NsdManager.ServiceInfoCallback {
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "onServiceUpdated: $serviceInfo")
addOrUpdate(
serviceInfo.serviceName,
serviceInfo.hostAddresses.toTypedArray(),
serviceInfo.port
)
}
override fun onServiceLost() {
Log.v(TAG, "onServiceLost: $service")
// TODO: Handle service lost
}
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
}
override fun onServiceInfoCallbackUnregistered() {
Log.v(TAG, "onServiceInfoCallbackUnregistered")
}
})
} else {
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
Log.v(TAG, "Resolve failed: $errorCode")
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
addOrUpdate(
serviceInfo.serviceName,
arrayOf(serviceInfo.host),
serviceInfo.port
)
}
})
}
}
}
}
override fun startUpdateTimeJob(
onTimeJobTimeChanged_s: Event1<Long>,
setTime: (Long) -> Unit
): Job? {
val d = activeDevice;
if (d is CastingDeviceLegacyWrapper && (d.inner is AirPlayCastingDevice || d.inner is ChromecastCastingDevice)) {
return _scopeMain.launch {
while (true) {
val device = instance.activeDevice
if (device == null || !device.isPlaying) {
break
}
delay(1000)
val time_ms = (device.expectedCurrentTime * 1000.0).toLong()
setTime(time_ms)
onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong())
}
}
}
return null
}
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice {
return CastingDeviceLegacyWrapper(
when (deviceInfo.type) {
CastProtocolType.CHROMECAST -> {
ChromecastCastingDevice(deviceInfo);
}
CastProtocolType.AIRPLAY -> {
AirPlayCastingDevice(deviceInfo);
}
CastProtocolType.FCAST -> {
FCastCastingDevice(deviceInfo);
}
}
)
}
private fun addOrUpdateChromeCastDevice(
name: String,
addresses: Array<InetAddress>,
port: Int
) {
return addOrUpdateCastDevice(
name,
deviceFactory = {
CastingDeviceLegacyWrapper(
ChromecastCastingDevice(
name,
addresses,
port
)
)
},
deviceUpdater = { d ->
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is ChromecastCastingDevice) {
return@addOrUpdateCastDevice false;
}
val changed =
addresses.contentEquals(d.inner.addresses) || d.name != name || d.inner.port != port;
if (changed) {
d.inner.name = name;
d.inner.addresses = addresses;
d.inner.port = port;
}
return@addOrUpdateCastDevice changed;
}
);
}
private fun addOrUpdateAirPlayDevice(name: String, addresses: Array<InetAddress>, port: Int) {
return addOrUpdateCastDevice(
name,
deviceFactory = {
CastingDeviceLegacyWrapper(
AirPlayCastingDevice(
name,
addresses,
port
)
)
},
deviceUpdater = { d ->
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is AirPlayCastingDevice) {
return@addOrUpdateCastDevice false;
}
val changed =
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
if (changed) {
d.inner.name = name;
d.inner.port = port;
d.inner.addresses = addresses;
}
return@addOrUpdateCastDevice changed;
}
);
}
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
return addOrUpdateCastDevice(
name,
deviceFactory = { CastingDeviceLegacyWrapper(FCastCastingDevice(name, addresses, port)) },
deviceUpdater = { d ->
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is FCastCastingDevice) {
return@addOrUpdateCastDevice false;
}
val changed =
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
if (changed) {
d.inner.name = name;
d.inner.port = port;
d.inner.addresses = addresses;
}
return@addOrUpdateCastDevice changed;
}
);
}
private inline fun addOrUpdateCastDevice(
name: String,
deviceFactory: () -> CastingDevice,
deviceUpdater: (device: CastingDevice) -> Boolean
) {
var invokeEvents: (() -> Unit)? = null;
synchronized(devices) {
val device = devices[name];
if (device != null) {
val changed = deviceUpdater(device);
if (changed) {
invokeEvents = {
onDeviceChanged.emit(device);
}
}
} else {
val newDevice = deviceFactory();
this.devices[name] = newDevice
invokeEvents = {
onDeviceAdded.emit(newDevice);
};
}
}
invokeEvents?.let { _scopeMain.launch { it(); }; };
}
@Serializable
private data class FCastNetworkConfig(
val name: String,
val addresses: List<String>,
val services: List<FCastService>
)
@Serializable
private data class FCastService(
val port: Int,
val type: Int
)
companion object {
private val TAG = "StateCastingLegacy"
}
}
@@ -1,72 +0,0 @@
package com.futo.platformplayer.casting.models
import kotlinx.serialization.Serializable
@Serializable
data class FCastPlayMessage(
val container: String,
val url: String? = null,
val content: String? = null,
val time: Double? = null,
val speed: Double? = null
) { }
@Serializable
data class FCastSeekMessage(
val time: Double
) { }
@Serializable
data class FCastPlaybackUpdateMessage(
val generationTime: Long,
val time: Double,
val duration: Double,
val state: Int,
val speed: Double
) { }
@Serializable
data class FCastVolumeUpdateMessage(
val generationTime: Long,
val volume: Double
)
@Serializable
data class FCastSetVolumeMessage(
val volume: Double
)
@Serializable
data class FCastSetSpeedMessage(
val speed: Double
)
@Serializable
data class FCastPlaybackErrorMessage(
val message: String
)
@Serializable
data class FCastVersionMessage(
val version: Long
)
@Serializable
data class FCastKeyExchangeMessage(
val version: Long,
val publicKey: String
)
@Serializable
data class FCastDecryptedMessage(
val opcode: Long,
val message: String?
)
@Serializable
data class FCastEncryptedMessage(
val version: Long,
val iv: String?,
val blob: String
)
@@ -18,6 +18,7 @@ import com.futo.platformplayer.engine.dev.V8RemoteObject
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.gsonStandard
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.serialize
import com.futo.platformplayer.engine.packages.PackageHttp
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateAssets
@@ -28,6 +29,8 @@ import com.google.gson.FieldAttributes
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.lang.reflect.Field
@@ -268,11 +271,17 @@ class DeveloperEndpoints(private val context: Context) {
context.respondCode(403, "This plugin doesn't support auth");
return;
}
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
LoginFragment.showLogin(config){
_testPluginVariables.clear();
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
};
}
/*
LoginActivity.showLogin(StateApp.instance.context, config) {
_testPluginVariables.clear();
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
};
}; */
context.respondCode(200, "Login started");
}
catch(ex: Throwable) {
@@ -16,9 +16,12 @@ import android.widget.Button
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UpdateDownloadService
import com.futo.platformplayer.UpdateNotificationManager
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.logging.Logger
@@ -34,6 +37,8 @@ import java.io.InputStream
class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
companion object {
private val TAG = "AutoUpdateDialog";
var currentDialog: AutoUpdateDialog? = null
}
private lateinit var _buttonNever: Button;
@@ -46,7 +51,6 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
private var _maxVersion: Int = 0;
private var _updating: Boolean = false;
private var _apkFile: File? = null;
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
@@ -61,12 +65,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
_buttonShowChangelog = findViewById(R.id.button_show_changelog);
_buttonNever.setOnClickListener {
UpdateNotificationManager.cancelAll(context)
Settings.instance.autoUpdate.check = 1;
Settings.instance.save();
dismiss();
};
_buttonClose.setOnClickListener {
UpdateNotificationManager.cancelAll(context)
dismiss();
};
@@ -76,23 +82,32 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
};
_buttonUpdate.setOnClickListener {
UpdateNotificationManager.cancelAll(context)
if (_updating) {
return@setOnClickListener;
}
_updating = true;
update();
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
val ctx = context.applicationContext;
val intent = Intent(ctx, UpdateDownloadService::class.java);
intent.putExtra(UpdateDownloadService.EXTRA_VERSION, _maxVersion);
ContextCompat.startForegroundService(ctx, intent);
UIDialogs.toast(context, "Downloading update in background");
dismiss();
} else {
_updating = true;
update();
}
};
}
fun showPredownloaded(apkFile: File) {
_apkFile = apkFile;
super.show()
currentDialog = this
}
override fun dismiss() {
super.dismiss()
InstallReceiver.onReceiveResult.clear();
currentDialog = null
Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.")
}
@@ -118,21 +133,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
GlobalScope.launch(Dispatchers.IO) {
var inputStream: InputStream? = null;
try {
val apkFile = _apkFile;
if (apkFile != null) {
inputStream = apkFile.inputStream();
val dataLength = apkFile.length();
val client = ManagedHttpClient();
val response = client.get(StateUpdate.APK_URL);
if (response.isOk && response.body != null) {
inputStream = response.body.byteStream();
val dataLength = response.body.contentLength();
install(inputStream, dataLength);
} else {
val client = ManagedHttpClient();
val response = client.get(StateUpdate.APK_URL);
if (response.isOk && response.body != null) {
inputStream = response.body.byteStream();
val dataLength = response.body.contentLength();
install(inputStream, dataLength);
} else {
throw Exception("Failed to download latest version of app.");
}
throw Exception("Failed to download latest version of app.");
}
} catch (e: Throwable) {
Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e);
@@ -1,9 +1,13 @@
package com.futo.platformplayer.dialogs
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.*
@@ -11,88 +15,88 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.google.android.material.button.MaterialButton
class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
private lateinit var _buttonStart: LinearLayout;
private lateinit var _buttonStop: LinearLayout;
private lateinit var _buttonCancel: ImageButton;
private lateinit var _editPassword: EditText;
private lateinit var _editPassword2: EditText;
private lateinit var _inputMethodManager: InputMethodManager;
private lateinit var _buttonStart: LinearLayout
private lateinit var _buttonStop: LinearLayout
private lateinit var _buttonCancel: ImageButton
private lateinit var _imm: InputMethodManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup, null));
super.onCreate(savedInstanceState)
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup, null))
_buttonCancel = findViewById(R.id.button_cancel);
_buttonStop = findViewById(R.id.button_stop);
_buttonStart = findViewById(R.id.button_start);
_editPassword = findViewById(R.id.edit_password);
_editPassword2 = findViewById(R.id.edit_password2);
_buttonCancel = findViewById(R.id.button_cancel)
_buttonStop = findViewById(R.id.button_stop)
_buttonStart = findViewById(R.id.button_start)
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
_imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
_buttonStart.visibility = if (Settings.instance.backup.autoBackupEnabled) View.GONE else View.VISIBLE
_buttonStop.visibility = if (Settings.instance.backup.autoBackupEnabled) View.VISIBLE else View.GONE
_buttonCancel.setOnClickListener {
clearFocus();
dismiss();
};
_buttonStop.setOnClickListener {
clearFocus();
dismiss();
Settings.instance.backup.autoBackupPassword = null;
Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save();
dismiss()
}
UIDialogs.toast(context, "AutoBackup disabled");
_buttonStop.setOnClickListener {
dismiss()
Settings.instance.backup.autoBackupEnabled = false
Settings.instance.backup.autoBackupPassword = null
Settings.instance.backup.didAskAutoBackup = true
Settings.instance.save()
UIDialogs.toast(context, context.getString(R.string.automatic_backup_disabled))
}
_buttonStart.setOnClickListener {
val p1 = _editPassword.text.toString();
val p2 = _editPassword2.text.toString();
if(!(p1?.equals(p2) ?: false)) {
UIDialogs.toast(context, "Password fields do not match, confirm that you typed it correctly.");
return@setOnClickListener;
dismiss()
Logger.i(TAG, "Enable AutoBackup (unencrypted)")
val activity = StateApp.instance.activity as? Activity
if (activity == null) {
UIDialogs.toast(context, "No activity available")
return@setOnClickListener
}
val pbytes = _editPassword.text.toString().toByteArray();
if(pbytes.size < 4 || pbytes.size > 32) {
UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and smaller than 32 bytes", false);
return@setOnClickListener;
}
clearFocus();
dismiss();
dismiss()
Logger.i(TAG, "Set AutoBackupPassword");
Settings.instance.backup.autoBackupPassword = _editPassword.text.toString();
Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save();
Logger.i(TAG, "Enable AutoBackup")
Settings.instance.backup.autoBackupPassword = null
Settings.instance.backup.didAskAutoBackup = true
Settings.instance.save()
UIDialogs.toast(context, "AutoBackup enabled");
UIDialogs.toast(context, "AutoBackup enabled")
try {
StateBackup.startAutomaticBackup(true);
StateBackup.startAutomaticBackup(true)
} catch (ex: Throwable) {
Logger.e(TAG, "Forced automatic backup failed", ex)
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message)
}
catch(ex: Throwable) {
Logger.e(TAG, "Forced automatic backup failed", ex);
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message);
Settings.instance.backup.autoBackupEnabled = true
Settings.instance.backup.autoBackupPassword = null
Settings.instance.backup.didAskAutoBackup = true
Settings.instance.save()
StateAnnouncement.instance.deleteAnnouncement("backup")
UIDialogs.toast(context, context.getString(R.string.automatic_backup_enabled))
try {
StateBackup.startAutomaticBackup(true)
} catch (ex: Throwable) {
Logger.e(TAG, "Forced automatic backup failed", ex)
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message)
}
};
}
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
}
private fun clearFocus() {
_editPassword.clearFocus();
currentFocus?.let { _inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) };
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
}
companion object {
private val TAG = "AutomaticBackupDialog";
private const val TAG = "AutomaticBackupDialog"
}
}
@@ -3,87 +3,155 @@ package com.futo.platformplayer.dialogs
import android.app.AlertDialog
import android.content.Context
import android.os.Bundle
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.*
import android.widget.EditText
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.*
import com.google.android.material.button.MaterialButton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import userpackage.Protocol
import java.time.OffsetDateTime
import kotlinx.coroutines.withContext
class AutomaticRestoreDialog(context: Context, private val scope: CoroutineScope) : AlertDialog(context) {
class AutomaticRestoreDialog(context: Context, val scope: CoroutineScope) : AlertDialog(context) {
private lateinit var _buttonStart: LinearLayout;
private lateinit var _buttonCancel: MaterialButton;
private lateinit var _editPassword: EditText;
private lateinit var _inputMethodManager: InputMethodManager;
private lateinit var _buttonStart: LinearLayout
private lateinit var _buttonCancel: MaterialButton
private lateinit var _textReason: TextView
private lateinit var _editPassword: EditText
private lateinit var _passwordContainer: LinearLayout
private lateinit var _icon: ImageView
private lateinit var _progress: ProgressBar
private lateinit var _textStart: TextView
private lateinit var _imm: InputMethodManager
private var _needsPassword: Boolean = true
private var _detectJob: Job? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup_restore, null));
super.onCreate(savedInstanceState)
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup_restore, null))
_buttonCancel = findViewById(R.id.button_cancel);
_buttonStart = findViewById(R.id.button_start);
_editPassword = findViewById(R.id.edit_password);
_buttonCancel = findViewById(R.id.button_cancel)
_buttonStart = findViewById(R.id.button_start)
_editPassword = findViewById(R.id.edit_password)
_textReason = findViewById(R.id.text_reason)
_passwordContainer = findViewById(R.id.password_container)
_icon = findViewById(R.id.image_icon)
_progress = findViewById(R.id.progress_restore)
_textStart = findViewById(R.id.text_start)
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
_imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
_needsPassword = true
applyMode(needsPassword = true)
setBusy(true, labelRes = R.string.checking_backup, lockCancel = false)
_buttonCancel.setOnClickListener {
clearFocus();
dismiss();
};
clearFocus()
dismiss()
}
_buttonStart.setOnClickListener { onStartClicked() }
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
}
_buttonStart.setOnClickListener {
val pbytes = _editPassword.text.toString().toByteArray();
if(pbytes.size < 4 || pbytes.size > 32) {
UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and less than 32 bytes", false);
return@setOnClickListener;
override fun onStart() {
super.onStart()
_detectJob?.cancel()
_detectJob = scope.launch(Dispatchers.Main) {
val needs = try {
StateBackup.requiresPasswordForAutomaticBackup(context)
} catch (_: Throwable) {
true
}
clearFocus();
if (!isShowing) return@launch
_needsPassword = needs
applyMode(needsPassword = needs)
setBusy(false)
}
}
override fun onStop() {
_detectJob?.cancel()
_detectJob = null
super.onStop()
}
private fun applyMode(needsPassword: Boolean) {
_textStart.setText(R.string.restore)
if (needsPassword) {
_icon.setImageResource(R.drawable.ic_lock)
_passwordContainer.visibility = View.VISIBLE
_editPassword.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
_textReason.setText(R.string.it_appears_an_automatic_backup_exists_on_your_device_if_you_would_like_to_restore_enter_your_backup_password)
} else {
_icon.setImageResource(R.drawable.ic_move_up)
_passwordContainer.visibility = View.GONE
_editPassword.setText("")
_textReason.setText(R.string.automatic_backup_found_no_password)
}
}
private fun onStartClicked() {
val password = _editPassword.text?.toString() ?: ""
if (_needsPassword) {
val pbytes = password.toByteArray()
if (pbytes.size < 4 || pbytes.size > 32) {
_editPassword.error = context.getString(R.string.backup_password_length_error)
_editPassword.requestFocus()
return
}
}
clearFocus()
setBusy(true, labelRes = R.string.restoring, lockCancel = true)
scope.launch(Dispatchers.IO) {
try {
StateBackup.restoreAutomaticBackup(context, scope, _editPassword.text.toString(), true);
dismiss();
StateBackup.restoreAutomaticBackup(context, scope, if (_needsPassword) password else "", true)
withContext(Dispatchers.Main) {
if (isShowing) dismiss()
}
} catch (ex: Throwable) {
Logger.e(TAG, "Failed to restore automatic backup", ex)
withContext(Dispatchers.Main) {
if (!isShowing) return@withContext
setBusy(false)
UIDialogs.showGeneralErrorDialog(context, "Restore failed", ex)
}
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to restore automatic backup", ex);
//UIDialogs.toast(context, "Restore failed due to:\n" + ex.message);
UIDialogs.showGeneralErrorDialog(context, "Restore failed", ex);
}
};
}
}
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
private fun setBusy(busy: Boolean, labelRes: Int = R.string.restore, lockCancel: Boolean = busy) {
_progress.visibility = if (busy) View.VISIBLE else View.GONE
_buttonCancel.isEnabled = !lockCancel
_buttonStart.isEnabled = !busy
_editPassword.isEnabled = !busy && _needsPassword
_buttonStart.alpha = if (busy) 0.6f else 1.0f
_textStart.setText(labelRes)
}
private fun clearFocus() {
_editPassword.clearFocus();
currentFocus?.let { _inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) };
_editPassword.clearFocus()
currentFocus?.let { _imm.hideSoftInputFromWindow(it.windowToken, 0) }
}
companion object {
private val TAG = "AutomaticRestoreDialog";
private const val TAG = "AutomaticRestoreDialog"
}
}
}
@@ -40,13 +40,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
_buttonConfirm = findViewById(R.id.button_confirm);
_buttonTutorial = findViewById(R.id.button_tutorial)
val deviceTypeArray = if (Settings.instance.casting.experimentalCasting) {
R.array.exp_casting_device_type_array
} else {
R.array.casting_device_type_array
}
ArrayAdapter.createFromResource(context, deviceTypeArray, R.layout.spinner_item_simple).also { adapter ->
ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter ->
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
_spinnerType.adapter = adapter;
};
@@ -12,7 +12,6 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastProtocolType
@@ -174,13 +173,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_textType.text = "AirPlay";
}
CastProtocolType.FCAST -> {
_imageDevice.setImageResource(
if (Settings.instance.casting.experimentalCasting) {
R.drawable.ic_exp_fc
} else {
R.drawable.ic_fc
}
)
_imageDevice.setImageResource(R.drawable.ic_fc)
_textType.text = "FCast";
}
}
@@ -48,6 +48,7 @@ class PluginUpdateDialog : AlertDialog {
private lateinit var _buttonCancel1: Button;
private lateinit var _buttonCancel2: Button;
private lateinit var _buttonAlways: LinearLayout;
private lateinit var _buttonUpdate: LinearLayout;
private lateinit var _buttonOk: LinearLayout;
@@ -58,6 +59,7 @@ class PluginUpdateDialog : AlertDialog {
private lateinit var _textProgres: TextView;
private lateinit var _textError: TextView;
private lateinit var _textResult: TextView;
private lateinit var _textChangelogResult: TextView;
private lateinit var _uiChoiceTop: FrameLayout;
private lateinit var _uiProgressTop: FrameLayout;
@@ -89,6 +91,7 @@ class PluginUpdateDialog : AlertDialog {
_buttonCancel1 = findViewById(R.id.button_cancel_1);
_buttonCancel2 = findViewById(R.id.button_cancel_2);
_buttonAlways = findViewById(R.id.button_always);
_buttonUpdate = findViewById(R.id.button_update);
_buttonOk = findViewById(R.id.button_ok);
@@ -99,6 +102,7 @@ class PluginUpdateDialog : AlertDialog {
_textProgres = findViewById(R.id.text_progress);
_textError = findViewById(R.id.text_error);
_textResult = findViewById(R.id.text_result);
_textChangelogResult = findViewById(R.id.text_changelog_result);
_uiChoiceTop = findViewById(R.id.dialog_ui_choice_top);
_uiProgressTop = findViewById(R.id.dialog_ui_progress_top);
@@ -119,17 +123,24 @@ class PluginUpdateDialog : AlertDialog {
val changelog = _newConfig.changelog!![changelogVersion]!!;
if(changelog.size > 1) {
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog.map { " - " + it.trim() }.joinToString("\n");
_textChangelogResult.text = _textChangelog.text;
}
else if(changelog.size == 1) {
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog[0].trim();
_textChangelogResult.text = _textChangelog.text;
}
else
else {
_textChangelog.visibility = View.GONE;
} else
_textChangelog.visibility = View.GONE;
_textChangelogResult.visibility = View.GONE;
}
} else {
_textChangelog.visibility = View.GONE;
_textChangelogResult.visibility = View.GONE;
}
}
catch(ex: Throwable) {
_textChangelog.visibility = View.GONE;
_textChangelogResult.visibility = View.GONE;
Logger.e(TAG, "Invalid changelog? ", ex);
}
@@ -145,6 +156,18 @@ class PluginUpdateDialog : AlertDialog {
_isUpdating = true;
update();
};
_buttonAlways.setOnClickListener {
if (_isUpdating)
return@setOnClickListener;
val plugin = StatePlugins.instance.getPlugin(_oldConfig.id);
if(plugin != null) {
plugin.appSettings.automaticUpdate = true;
StatePlugins.instance.savePlugin(_oldConfig.id);
UIDialogs.appToast("Automatic update enabled, can be disabled in plugin settings");
}
_isUpdating = true;
update();
};
Glide.with(_iconPlugin)
.load(_oldConfig.absoluteIconUrl)
@@ -158,7 +181,8 @@ class PluginUpdateDialog : AlertDialog {
if (_isUpdating)
return;
_isUpdating = true;
update();
update(true);
}
}
}
@@ -167,7 +191,7 @@ class PluginUpdateDialog : AlertDialog {
super.dismiss();
}
private fun update() {
private fun update(automatic: Boolean = false) {
_uiChoiceTop.visibility = View.GONE;
_uiRiskTop.visibility = View.GONE;
_uiChoiceBot.visibility = View.GONE;
@@ -187,9 +211,16 @@ class PluginUpdateDialog : AlertDialog {
val scope = StateApp.instance.scopeOrNull;
scope?.launch(Dispatchers.IO) {
try {
withContext(Dispatchers.Main) {
_textProgres.setText("Loading current script file...");
}
val client = ManagedHttpClient();
client.setTimeout(10000);
val script = StatePlugins.instance.getScript(_oldConfig.id) ?: "";
withContext(Dispatchers.Main) {
_textProgres.setText("Requesting new script file...");
}
val newScript = client.get(_newConfig.absoluteScriptUrl)?.body?.string();
if(newScript.isNullOrEmpty())
throw IllegalStateException("No script found");
@@ -1,13 +1,19 @@
package com.futo.platformplayer.downloads
import android.content.Context
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaMuxer
import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import com.arthenica.ffmpegkit.StatisticsCallback
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
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.IAudioSource
@@ -36,10 +42,13 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptReloadRequiredException
import com.futo.platformplayer.exceptions.DownloadException
import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.Language
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.states.StateDownloads
@@ -82,6 +91,9 @@ import kotlin.time.times
class VideoDownload {
var state: State = State.QUEUED;
@Contextual
@Transient
var plugin: IPlatformClient? = null;
var video: SerializedPlatformVideo? = null;
var videoDetails: SerializedPlatformVideoDetails? = null;
@@ -97,6 +109,7 @@ class VideoDownload {
var videoSource: VideoUrlSource?;
var audioSource: AudioUrlSource?;
var overrideResultAudioSource: IAudioSource? = null;
@Contextual
@Transient
val videoSourceToUse: IVideoSource? get () = if(requiresLiveVideoSource) videoSourceLive as IVideoSource? else videoSource as IVideoSource?;
@@ -126,16 +139,22 @@ class VideoDownload {
@Contextual
@kotlinx.serialization.Transient
var videoSourceLive: JSSource? = null;
val isLiveVideoSourceValid get() = videoSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false;
val isLiveVideoSourceValid get() = videoSourceLive?.getUnderlyingPlugin()?.busy {
videoSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false;
} ?: false;
var requiresLiveAudioSource: Boolean = false;
@Contextual
@kotlinx.serialization.Transient
var audioSourceLive: JSSource? = null;
val isLiveAudioSourceValid get() = audioSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false;
val isLiveAudioSourceValid get() = audioSourceLive?.getUnderlyingPlugin()?.busy {
audioSourceLive?.getUnderlyingObject()?.isClosed?.let { !it } ?: false;
} ?: false;
var hasVideoRequestExecutor: Boolean = false;
var hasAudioRequestExecutor: Boolean = false;
var hasVideoRequestModifier: Boolean = false;
var hasAudioRequestModifier: Boolean = false;
var progress: Double = 0.0;
var isCancelled = false;
@@ -203,8 +222,10 @@ class VideoDownload {
this.prepareTime = OffsetDateTime.now();
this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor;
this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
this.hasVideoRequestModifier = videoSource is JSSource && videoSource.hasRequestModifier;
this.hasAudioRequestModifier = audioSource is JSSource && audioSource.hasRequestModifier;
this.requiresLiveVideoSource = this.hasVideoRequestModifier || this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
this.requiresLiveAudioSource = this.hasAudioRequestModifier || this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
this.targetVideoName = videoSource?.name;
this.targetAudioName = audioSource?.name;
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
@@ -262,7 +283,7 @@ class VideoDownload {
//Fetch full video object and determine source
if(video != null && videoDetails == null) {
val original = StatePlatform.instance.getContentDetails(video!!.url).await();
val original = if (plugin != null) plugin!!.getContentDetails(video!!.url) else StatePlatform.instance.getContentDetails(video!!.url)?.await();
if(original !is IPlatformVideoDetails)
throw IllegalStateException("Original content is not media?");
@@ -429,6 +450,11 @@ class VideoDownload {
videoFileNameBase = "${videoDetails!!.id.value!!} [${actualVideoSource!!.width}x${actualVideoSource!!.height}]".sanitizeFileName();
videoFileNameExt = videoContainerToExtension(actualVideoSource!!.container);
videoFilePath = File(downloadDir, videoFileName!!).absolutePath;
if(actualVideoSource is JSDashManifestRawSource && actualAudioSource == null) {
audioFileNameBase = "${videoDetails!!.id.value!!}-[unknown]".sanitizeFileName();
audioFileNameExt = videoAudioContainerToExtension(actualVideoSource!!.container);
audioFilePath = File(downloadDir, audioFileName!!).absolutePath;
}
}
if(actualAudioSource != null) {
audioFileNameBase = "${videoDetails!!.id.value!!} [${actualAudioSource!!.language}-${actualAudioSource!!.bitrate}]".sanitizeFileName();
@@ -478,11 +504,15 @@ class VideoDownload {
if(actualVideoSource is IVideoUrlSource)
videoFileSize = when (videoSource!!.container) {
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
else -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
}
else if(actualVideoSource is JSDashManifestRawSource) {
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback);
if(actualAudioSource == null)
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback, 3,
File(downloadDir, audioFileName!!));
else
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback, 1);
}
else throw NotImplementedError("NotImplemented video download: " + actualVideoSource.javaClass.name);
});
@@ -518,11 +548,11 @@ class VideoDownload {
if(actualAudioSource is IAudioUrlSource)
audioFileSize = when (audioSource!!.container) {
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
else -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
}
else if(actualAudioSource is JSDashManifestRawAudioSource) {
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback);
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback, 2);
}
else throw NotImplementedError("NotImplemented audio download: " + actualAudioSource.javaClass.name);
});
@@ -580,121 +610,305 @@ class VideoDownload {
return cipher.doFinal(encryptedSegment)
}
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists())
targetFile.delete();
var downloadedTotalLength = 0L
val segmentFiles = arrayListOf<File>()
try {
val response = client.get(hlsUrl)
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
val vpContent = response.body?.string()
?: throw Exception("Variant playlist content is empty")
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) {
val keyResponse = client.get(variantPlaylist.decryptionInfo.keyUrl)
check(keyResponse.isOk) { "HLS request failed for decryption key: ${keyResponse.code}" }
DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv?.hexStringToByteArray())
} else {
null
}
variantPlaylist.segments.forEachIndexed { index, segment ->
if (segment !is HLS.MediaSegment) {
return@forEachIndexed
}
Logger.i(TAG, "Download '$name' segment $index Sequential");
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
val outputStream = segmentFile.outputStream()
try {
segmentFiles.add(segmentFile)
val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo, index) { segmentLength, totalRead, lastSpeed ->
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
}
downloadedTotalLength += segmentLength
} finally {
outputStream.close()
}
}
Logger.i(TAG, "Combining segments into $targetFile");
combineSegments(context, segmentFiles, targetFile)
Logger.i(TAG, "${name} downloadSource Finished");
}
catch(ioex: IOException) {
if(targetFile.exists())
targetFile.delete();
if(ioex.message?.contains("ENOSPC") ?: false)
throw Exception("Not enough space on device", ioex);
else
throw ioex;
}
catch(ex: Throwable) {
if(targetFile.exists())
targetFile.delete();
throw ex;
}
finally {
for (segmentFile in segmentFiles) {
segmentFile.delete()
}
}
return downloadedTotalLength;
}
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
require(segmentFiles.isNotEmpty()) { "segmentFiles must not be empty" }
suspendCancellableCoroutine { continuation ->
val cmd =
"-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
val concatInput = buildString {
append("concat:")
append(
segmentFiles.joinToString("|") { file ->
file.absolutePath
}
)
}
val cmd = "-i \"$concatInput\" -c copy \"${targetFile.absolutePath}\""
val statisticsCallback = StatisticsCallback { _ ->
//TODO: Show progress?
//No callback
}
val executorService = Executors.newSingleThreadExecutor()
val session = FFmpegKit.executeAsync(cmd,
{ session ->
if (ReturnCode.isSuccess(session.returnCode)) {
val session = FFmpegKit.executeAsync(
cmd,
{ completedSession ->
executorService.shutdown()
if (ReturnCode.isSuccess(completedSession.returnCode)) {
continuation.resumeWith(Result.success(Unit))
} else {
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
val errorMessage = if (ReturnCode.isCancel(completedSession.returnCode)) {
"Command cancelled"
} else {
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
"Command failed with state '${completedSession.state}' " +
"and return code ${completedSession.returnCode}, " +
"stack trace ${completedSession.failStackTrace}"
}
continuation.resumeWithException(RuntimeException(errorMessage))
}
},
{ Logger.v(TAG, it.message) },
{ log ->
Logger.v(TAG, log.message)
},
statisticsCallback,
executorService
)
continuation.invokeOnCancellation {
session.cancel()
executorService.shutdownNow()
}
}
}
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if (targetFile.exists())
targetFile.delete()
var downloadedTotalLength = 0L
val modifier = if (source is JSSource && source.hasRequestModifier)
source.getRequestModifier()
else
null
fun downloadBytes(url: String, rangeStart: Long? = null, rangeLength: Long? = null): ByteArray {
val headers = mutableMapOf<String, String>()
if (rangeStart != null) {
if (rangeLength != null && rangeLength > 0) {
val end = rangeStart + rangeLength - 1
headers["Range"] = "bytes=$rangeStart-$end"
} else {
headers["Range"] = "bytes=$rangeStart-"
}
}
val modified = modifier?.modifyRequest(url, headers)
val finalUrl = modified?.url ?: url
val finalHeaders = modified?.headers?.toMutableMap() ?: headers
val resp = client.get(finalUrl, finalHeaders)
if (!resp.isOk) {
resp.body?.close()
throw IllegalStateException("Failed to download HLS resource ($finalUrl): HTTP ${resp.code}")
}
val body = resp.body ?: throw IllegalStateException("Failed to download HLS resource ($finalUrl): Empty body")
val bytes = body.bytes()
body.close()
return bytes
}
fun buildSequenceIv(sequenceNumber: Long): ByteArray {
return ByteBuffer.allocate(16)
.putLong(0L)
.putLong(sequenceNumber)
.array()
}
val segmentFiles = arrayListOf<File>()
try {
val playlistHeaders = mutableMapOf<String, String>()
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
val playlistResp = client.get(
modifiedPlaylistReq?.url ?: hlsUrl,
modifiedPlaylistReq?.headers?.toMutableMap() ?: playlistHeaders
)
check(playlistResp.isOk) { "Failed to get variant playlist: ${playlistResp.code}" }
val vpContent = playlistResp.body?.string()
?: throw IllegalStateException("Variant playlist content is empty")
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
val hlsDec = variantPlaylist.decryptionInfo
val useDecryption = hlsDec != null && !hlsDec.method.equals("NONE", ignoreCase = true)
var keyBytes: ByteArray? = null
var staticIvBytes: ByteArray? = null
if (useDecryption) {
if (!hlsDec.method.equals("AES-128", ignoreCase = true)) {
throw UnsupportedOperationException("HLS decryption method '${hlsDec.method}' is not supported.")
}
val keyUrl = hlsDec.keyUrl ?: throw IllegalStateException("Encrypted HLS playlist without key URI is not supported.")
keyBytes = downloadBytes(keyUrl)
if (!hlsDec.iv.isNullOrEmpty()) {
staticIvBytes = hlsDec.iv.hexStringToByteArray()
}
}
val mediaSequence = variantPlaylist.mediaSequence ?: 0L
val rangeOffsets = mutableMapOf<String, Long>()
if (!variantPlaylist.mapUrl.isNullOrEmpty()) {
if (isCancelled) throw CancellationException("Cancelled")
Logger.i(TAG, "Downloading HLS initialization map")
var mapRangeStart: Long? = null
var mapRangeLength: Long? = null
if (variantPlaylist.mapBytesLength > 0) {
mapRangeLength = variantPlaylist.mapBytesLength
val mapUrl = variantPlaylist.mapUrl
if (variantPlaylist.mapBytesStart >= 0) {
mapRangeStart = variantPlaylist.mapBytesStart
rangeOffsets[mapUrl] =
variantPlaylist.mapBytesStart + variantPlaylist.mapBytesLength
} else {
val offset = rangeOffsets[mapUrl] ?: 0L
mapRangeStart = offset
rangeOffsets[mapUrl] = offset + variantPlaylist.mapBytesLength
}
}
var mapBytes = downloadBytes(variantPlaylist.mapUrl!!, mapRangeStart, mapRangeLength)
if (useDecryption) {
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
val iv = staticIvBytes
?: throw UnsupportedOperationException("Encrypted EXT-X-MAP without explicit IV is not supported.")
mapBytes = decryptSegment(mapBytes, kb, iv)
}
if (mapBytes.size.toLong() > Int.MAX_VALUE) {
throw IllegalStateException("HLS MAP segment too large to handle.")
}
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
val outStr = segmentFile.outputStream()
try {
segmentFiles.add(segmentFile)
outStr.write(mapBytes)
outStr.flush()
} finally {
outStr.close()
}
downloadedTotalLength += mapBytes.size
}
val totalSegments = variantPlaylist.segments.size
var mediaSegmentIndex = 0
var bytesSinceLastSpeedUpdate = 0L
var lastSpeedUpdateTime = System.currentTimeMillis()
var lastSpeed = 0L
variantPlaylist.segments.forEachIndexed { index, segment ->
if (segment !is HLS.MediaSegment) return@forEachIndexed
if (isCancelled) throw CancellationException("Cancelled")
Logger.i(TAG, "Download '$name' segment $index sequential")
var rangeStart: Long? = null
var rangeLength: Long? = null
if (segment.bytesLength > 0) {
rangeLength = segment.bytesLength
val urlKey = segment.uri
if (segment.bytesStart >= 0) {
rangeStart = segment.bytesStart
rangeOffsets[urlKey] = segment.bytesStart + segment.bytesLength
} else {
val offset = rangeOffsets[urlKey] ?: 0L
rangeStart = offset
rangeOffsets[urlKey] = offset + segment.bytesLength
}
}
var segmentBytes = downloadBytes(segment.uri, rangeStart, rangeLength)
if (useDecryption) {
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
val ivBytes = if (staticIvBytes != null) {
staticIvBytes
} else {
val sequenceNumber = mediaSequence + mediaSegmentIndex
buildSequenceIv(sequenceNumber)
}
segmentBytes = decryptSegment(segmentBytes, kb, ivBytes)
}
val segmentLength = segmentBytes.size.toLong()
if (segmentLength > Int.MAX_VALUE) {
throw IllegalStateException("HLS media segment too large to handle.")
}
val avgLen = if (index == 0) {
segmentLength
} else {
if (index > 0) downloadedTotalLength / index else segmentLength
}
val expectedTotal = avgLen * (totalSegments - 1) + segmentLength
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
val outStr = segmentFile.outputStream()
try {
segmentFiles.add(segmentFile)
outStr.write(segmentBytes)
} finally {
outStr.close()
}
downloadedTotalLength += segmentLength
bytesSinceLastSpeedUpdate += segmentLength
val now = System.currentTimeMillis()
val elapsed = now - lastSpeedUpdateTime
if (elapsed >= 500 && bytesSinceLastSpeedUpdate > 0) {
lastSpeed = (bytesSinceLastSpeedUpdate * 1000L / elapsed)
bytesSinceLastSpeedUpdate = 0
lastSpeedUpdateTime = now
}
onProgress(expectedTotal, downloadedTotalLength, lastSpeed)
mediaSegmentIndex++
}
combineSegments(context, segmentFiles, targetFile)
Logger.i(TAG, "Finished HLS Source for $name")
} catch (ioex: IOException) {
if (targetFile.exists())
targetFile.delete()
if (ioex.message?.contains("ENOSPC") == true)
throw Exception("Not enough space on device", ioex)
else
throw ioex
} catch (ex: Throwable) {
if (targetFile.exists())
targetFile.delete()
throw ex
}
finally {
for (segmentFile in segmentFiles) {
segmentFile.delete()
}
}
return downloadedTotalLength
}
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit, downloadType: Int = 0, targetFileAudio: File? = null): Long {
if(targetFile.exists())
targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
targetFile.createNewFile();
targetFileAudio?.createNewFile();
val sourceLength: Long?;
val sourceLengthAudio: Long?;
val fileStream = FileOutputStream(targetFile);
val fileStream2 = if(targetFileAudio != null) FileOutputStream(targetFileAudio) else null;
var executor: JSRequestExecutor? = null;
try{
var manifest = source.manifest;
if(source.hasGenerate)
@@ -703,35 +917,59 @@ class VideoDownload {
throw IllegalStateException("No manifest after generation");
//TODO: Temporary naive assume single-sourced dash
val foundTemplate = REGEX_DASH_TEMPLATE.find(manifest);
if(foundTemplate == null || foundTemplate.groupValues.size != 3)
val foundTemplates = REGEX_DASH_TEMPLATE_WITH_MIME.findAll(manifest);
val foundTemplate = when(downloadType) {
1 -> foundTemplates.find({ it.groupValues[1].contains("video/") });
2 -> foundTemplates.find({ it.groupValues[1].contains("audio/") });
else -> foundTemplates.find({ it.groupValues[1].contains("video/") });
}
if(foundTemplate == null || foundTemplate.groupValues.size != 4)
throw IllegalStateException("No SegmentTemplate found in manifest (unsupported dash?)");
val foundTemplateUrl = foundTemplate.groupValues[1];
val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[2]);
val foundTemplateUrl = foundTemplate.groupValues[2];
val foundCues = REGEX_DASH_CUE.findAll(foundTemplate.groupValues[3]).toList();
if(foundCues.count() <= 0)
throw IllegalStateException("No Cues found in manifest (unsupported dash?)");
val executor = if(source is JSSource && source.hasRequestExecutor)
val foundTemplate2 = if(downloadType == 3) foundTemplates.find({ it.groupValues[1].contains("audio/") }); else null;
val foundTemplateUrl2 = if(foundTemplate2 != null) foundTemplate2.groupValues[2] else null;
val foundCues2 = if(foundTemplate2 != null) REGEX_DASH_CUE.findAll(foundTemplate2.groupValues[3]).toList() else null;
val foundCues2Downloaded = hashSetOf<MatchResult>();
if(foundTemplate2 != null)
overrideResultAudioSource = LocalAudioSource((videoSource?.name)?.let { it + " [audio]" } ?: "audio", "", 0, 0, foundTemplate2.groupValues[1], REGEX_CODECS.find(foundTemplate2.groupValues[0])?.groupValues?.get(1) ?: "", Language.UNKNOWN);
executor = if(source is JSSource && source.hasRequestExecutor)
source.getRequestExecutor();
else
null;
val modifier = if (source is JSSource && source.hasRequestModifier)
source.getRequestModifier();
else
null;
val speedTracker = SpeedTracker(1000);
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
var written: Long = 0;
var written2: Long = 0;
var indexCounter = 0;
var indexCounter2 = 0;
onProgress(foundCues.count().toLong(), 0, 0);
val totalCues = foundCues.count().toLong() + (foundCues2?.count()?.toLong() ?: 0)
val lastCue = foundCues.lastOrNull();
for(cue in foundCues) {
val t = cue.groupValues[1];
val d = cue.groupValues[2];
Logger.i(TAG, "Downloading cue ${indexCounter}")
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
val modified = modifier?.modifyRequest(url, mapOf());
val data = if(executor != null)
executor.executeRequest("GET", url, null, mapOf());
executor.executeRequest("GET", modified?.url ?: url, null, modified?.headers ?: mapOf());
else {
val resp = client.get(url, mutableMapOf());
val resp = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: mutableMapOf());
if(!resp.isOk)
throw IllegalStateException("Dash request failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
resp.body!!.bytes()
@@ -740,17 +978,60 @@ class VideoDownload {
speedTracker.addWork(data.size.toLong());
written += data.size;
onProgress(foundCues.count().toLong(), indexCounter.toLong(), speedTracker.lastSpeed);
onProgress(totalCues, indexCounter.toLong() + indexCounter2.toLong(), speedTracker.lastSpeed);
indexCounter++;
if(foundCues2 != null && foundTemplateUrl2 != null && fileStream2 != null) {
val toDownload = if(lastCue != null && cue == lastCue)
foundCues2.filter { !foundCues2Downloaded.contains(it) }.toList() else
foundCues2.filter { !foundCues2Downloaded.contains(it) && (it.groupValues[1].toLong()) < t.toLong() }.toList();
Logger.i(TAG, "Downloading audio cues (${toDownload.size})")
for(cue2 in toDownload) {
val index2 = foundCues2.indexOf(cue2);
val t2 = cue2.groupValues[1];
val d2 = cue2.groupValues[2];
val url2 = foundTemplateUrl2!!.replace("\$Number\$", (index2).toString());
val modified2 = modifier?.modifyRequest(url, mapOf());
val data = if(executor != null)
executor.executeRequest("GET", modified2?.url ?: url2, null, modified2?.headers ?: mapOf());
else {
val resp = client.get(modified2?.url ?: url, modified2?.headers?.toMutableMap() ?: mutableMapOf());
if(!resp.isOk)
throw IllegalStateException("Dash request2 failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
resp.body!!.bytes()
}
fileStream2.write(data, 0, data.size);
speedTracker.addWork(data.size.toLong());
written2 += data.size;
indexCounter2++;
foundCues2Downloaded.add(cue2);
onProgress(totalCues, indexCounter.toLong() + indexCounter2.toLong(), speedTracker.lastSpeed);
}
}
}
sourceLength = written;
sourceLengthAudio = written2;
Logger.i(TAG, "$name downloadSource Finished");
}
catch(scriptEx: ScriptReloadRequiredException) {
if(targetFile.exists() ?: false)
targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
createNewPluginClient();
throw scriptEx;
}
catch(ioex: IOException) {
if(targetFile.exists() ?: false)
targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
if(ioex.message?.contains("ENOSPC") ?: false)
throw Exception("Not enough space on device", ioex);
else
@@ -759,14 +1040,38 @@ class VideoDownload {
catch(ex: Throwable) {
if(targetFile.exists() ?: false)
targetFile.delete();
if(targetFileAudio?.exists() ?: false)
targetFileAudio.delete();
throw ex;
}
finally {
fileStream.close();
fileStream2?.close();
executor?.closeAsync()
}
if(sourceLengthAudio != null && sourceLengthAudio > 0)
audioFileSize = sourceLengthAudio
return sourceLength!!;
}
private fun downloadFileSource(name: String, client: ManagedHttpClient, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
fun createNewPluginClient() {
UIDialogs.appToast("Download creating new client at request of plugin");
cleanupPluginClient();
plugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null }?.getCopy(false, true);
plugin?.initialize();
}
fun cleanupPluginClient() {
val oldPlugin = plugin;
plugin = null;
try {
oldPlugin?.disable();
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to dispose download client: ${ex.message}" , ex);
}
}
private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists())
targetFile.delete();
@@ -775,7 +1080,12 @@ class VideoDownload {
val sourceLength: Long?;
val fileStream = FileOutputStream(targetFile);
try{
val modifier = if (source is JSSource && source.hasRequestModifier)
source.getRequestModifier();
else
null;
try {
val head = client.tryHead(videoUrl);
val relatedPlugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null };
if(Settings.instance.downloads.byteRangeDownload && head?.containsKey("accept-ranges") == true && head.containsKey("content-length"))
@@ -786,12 +1096,12 @@ class VideoDownload {
Logger.i(TAG, "Download $name ByteRange Parallel (${concurrency}): " + videoUrl);
sourceLength = head["content-length"]!!.toLong();
onProgress(sourceLength, 0, 0);
downloadSource_Ranges(name, client, fileStream, videoUrl, sourceLength, 1024*512, concurrency, onProgress);
downloadSource_Ranges(name, client, modifier, fileStream, videoUrl, sourceLength, 1024*512, concurrency, onProgress);
}
else {
Logger.i(TAG, "Download $name Sequential");
try {
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, 0, onProgress);
sourceLength = downloadSource_Sequential(client, modifier, fileStream, videoUrl, null, 0, onProgress);
} catch (e: Throwable) {
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
throw e
@@ -842,7 +1152,7 @@ class VideoDownload {
}
}
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long {
private fun downloadSource_Sequential(client: ManagedHttpClient, modifier: IRequestModifier? = null, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long {
val progressRate: Int = 4096 * 5;
var lastProgressCount: Int = 0;
val speedRate: Int = 4096 * 5;
@@ -851,7 +1161,12 @@ class VideoDownload {
var lastSpeed: Long = 0;
val result = client.get(url);
val result = if (modifier != null) {
val modified = modifier.modifyRequest(url, mapOf())
client.get(modified.url!!, modified.headers.toMutableMap())
} else {
client.get(url)
}
if (!result.isOk) {
result.body?.close()
throw IllegalStateException("Failed to download source. Web[${result.code}] Error");
@@ -988,7 +1303,7 @@ class VideoDownload {
onProgress(sourceLength, totalRead, 0)
return sourceLength
}*/
private fun downloadSource_Ranges(name: String, client: ManagedHttpClient, fileStream: FileOutputStream, url: String, sourceLength: Long, rangeSize: Int, concurrency: Int = 1, onProgress: (Long, Long, Long) -> Unit) {
private fun downloadSource_Ranges(name: String, client: ManagedHttpClient, modifier: IRequestModifier?, fileStream: FileOutputStream, url: String, sourceLength: Long, rangeSize: Int, concurrency: Int = 1, onProgress: (Long, Long, Long) -> Unit) {
val progressRate: Int = 4096 * 5;
var lastProgressCount: Int = 0;
val speedRate: Int = 4096 * 5;
@@ -1007,7 +1322,7 @@ class VideoDownload {
Logger.i(TAG, "Download ${name} Batch #${reqCount} [${concurrency}] (${lastSpeed.toHumanBytesSpeed()})");
val byteRangeResults = requestByteRangeParallel(client, pool, url, sourceLength, concurrency, totalRead,
val byteRangeResults = requestByteRangeParallel(client, pool, modifier, url, sourceLength, concurrency, totalRead,
rangeSize, 1024 * 64);
for(byteRange in byteRangeResults) {
@@ -1038,7 +1353,7 @@ class VideoDownload {
onProgress(sourceLength, totalRead, 0);
}
private fun requestByteRangeParallel(client: ManagedHttpClient, pool: ForkJoinPool, url: String, totalLength: Long, concurrency: Int, rangePosition: Long, rangeSize: Int, rangeVariance: Int = -1): List<Triple<ByteArray, Long, Long>> {
private fun requestByteRangeParallel(client: ManagedHttpClient, pool: ForkJoinPool, modifier: IRequestModifier?, url: String, totalLength: Long, concurrency: Int, rangePosition: Long, rangeSize: Int, rangeVariance: Int = -1): List<Triple<ByteArray, Long, Long>> {
val tasks = mutableListOf<ForkJoinTask<Triple<ByteArray, Long, Long>>>();
var readPosition = rangePosition;
for(i in 0 until concurrency) {
@@ -1052,21 +1367,25 @@ class VideoDownload {
else readPosition + toRead;
tasks.add(pool.submit<Triple<ByteArray, Long, Long>> {
return@submit requestByteRange(client, url, rangeStart, rangeEnd);
return@submit requestByteRange(client, modifier, url, rangeStart, rangeEnd);
});
readPosition = rangeEnd + 1;
}
return tasks.map { it.get() };
}
private fun requestByteRange(client: ManagedHttpClient, url: String, rangeStart: Long, rangeEnd: Long): Triple<ByteArray, Long, Long> {
private fun requestByteRange(client: ManagedHttpClient, modifier: IRequestModifier?, url: String, rangeStart: Long, rangeEnd: Long): Triple<ByteArray, Long, Long> {
var retryCount = 0
var lastException: Throwable? = null
var lastException: Throwable? = null;
val headers = mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}"));
val modified = modifier?.modifyRequest(url, headers);
while (retryCount <= 3) {
try {
val toRead = rangeEnd - rangeStart;
val req = client.get(url, mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}")));
val req = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: headers);
if (!req.isOk) {
val bodyString = req.body?.string()
req.body?.close()
@@ -1111,7 +1430,7 @@ class VideoDownload {
throw IllegalStateException("Expected size [${videoFileSize}], but found ${expectedFile.length()}");
}
}
if(audioSourceToUse != null) {
if(audioSourceToUse != null || (videoSourceToUse is IJSDashManifestRawSource)) {
if(audioFilePath == null)
throw IllegalStateException("Missing audio file name after download");
val expectedFile = File(audioFilePath!!);
@@ -1134,7 +1453,7 @@ class VideoDownload {
Logger.i(TAG, "VideoDownload Complete [${name}]");
val existing = StateDownloads.instance.getCachedVideo(id);
val localVideoSource = videoFilePath?.let { LocalVideoSource.fromSource(videoSourceToUse!!, it, videoFileSize ?: 0, videoOverrideContainer) };
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) };
val localAudioSource = audioFilePath?.let { LocalAudioSource.fromSource(overrideResultAudioSource ?: audioSourceToUse!!, it, audioFileSize ?: 0, audioOverrideContainer) };
val localSubtitleSource = subtitleFilePath?.let { LocalSubtitleSource.fromSource(subtitleSource!!, it) };
if(localVideoSource != null && videoSourceToUse != null && videoSourceToUse is IStreamMetaDataSource)
@@ -1143,6 +1462,9 @@ class VideoDownload {
if(localAudioSource != null && audioSourceToUse != null && audioSourceToUse is IStreamMetaDataSource)
localAudioSource.streamMetaData = (audioSourceToUse as IStreamMetaDataSource).streamMetaData;
if(localAudioSource != null && localAudioSource.streamMetaData == null && videoSourceToUse is JSDashManifestRawSource)
localAudioSource.streamMetaData = (videoSourceToUse as JSDashManifestRawSource).audioStreamMetaData;
if(existing != null) {
existing.videoSerialized = videoDetails!!;
if(localVideoSource != null) {
@@ -1176,6 +1498,10 @@ class VideoDownload {
}
}
fun cleanup(){
cleanupPluginClient()
}
enum class State {
QUEUED,
PREPARING,
@@ -1199,6 +1525,8 @@ class VideoDownload {
const val GROUP_WATCHLATER= "WatchLater";
val REGEX_DASH_TEMPLATE = Regex("<SegmentTemplate .*?media=\"(.*?)\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
val REGEX_DASH_TEMPLATE_WITH_MIME = Regex("<Representation.*?mimeType=\\\"(.*?)\\\".*?>.*?<SegmentTemplate .*?media=\\\"(.*?)\\\".*?>(.*?)<\\/SegmentTemplate>", RegexOption.DOT_MATCHES_ALL);
val REGEX_CODECS = Regex("codecs=\\\"(.*?)\\\"")
val REGEX_DASH_CUE = Regex("<S .*?t=\"([0-9]*?)\".*?d=\"([0-9]*?)\".*?\\/>", RegexOption.DOT_MATCHES_ALL);
fun videoContainerToExtension(container: String): String? {
@@ -1218,6 +1546,16 @@ class VideoDownload {
return "video";//throw IllegalStateException("Unknown container: " + container)
}
//TODO: Change usages of this to an accurate container instead of infering it.
fun videoAudioContainerToExtension(container: String): String? {
if (container.contains("video/mp4") || container == "application/vnd.apple.mpegurl")
return "mp4a";
else if (container.contains("video/webm"))
return "webm";
else
return "mp4a";//throw IllegalStateException("Unknown container: " + container)
}
fun audioContainerToExtension(container: String): String {
if (container.contains("audio/mp4"))
return "mp4a";
@@ -1272,4 +1610,4 @@ class VideoDownload {
}
}
}
}
@@ -13,8 +13,10 @@ import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toHumanBitrate
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File
@@ -51,8 +53,12 @@ class VideoExport {
val outputFile: DocumentFile?;
val downloadRoot = documentRoot ?: StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set");
val safeBaseName = videoLocal.name.sanitizeFileName(true).ifBlank {
"video_${UUID.randomUUID()}"
}
if (sourceCount > 1) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container);
val outputFileName = "$safeBaseName.mp4"
val f = downloadRoot.createFile("video/mp4", outputFileName)
?: throw Exception("Failed to create file in external directory.");
@@ -60,7 +66,9 @@ class VideoExport {
val tempFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4");
try {
combine(a?.filePath, v?.filePath, s?.filePath, tempFile.absolutePath, videoLocal.duration.toDouble()) { progress -> onProgress?.invoke(progress) };
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
val outputStream = context.contentResolver.openOutputStream(f.uri)
?: throw IOException("Failed to open output stream for ${f.uri}")
outputStream.use { outputStream ->
copy(tempFile.absolutePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
} finally {
@@ -68,25 +76,29 @@ class VideoExport {
}
outputFile = f;
} else if (v != null) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container);
val outputFileName = "$safeBaseName.${VideoDownload.videoContainerToExtension(v.container)}"
val f = downloadRoot.createFile(if (v.container == "application/vnd.apple.mpegurl") "video/mp4" else v.container, outputFileName)
?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying video.");
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
val outputStream = context.contentResolver.openOutputStream(f.uri)
?: throw IOException("Failed to open output stream for ${f.uri}")
outputStream.use { outputStream ->
copy(v.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
outputFile = f;
} else if (a != null) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container);
val outputFileName = "$safeBaseName.${VideoDownload.audioContainerToExtension(a.container)}"
val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") "video/mp4" else a.container, outputFileName)
?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying audio.");
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
val outputStream = context.contentResolver.openOutputStream(f.uri)
?: throw IOException("Failed to open output stream for ${f.uri}")
outputStream.use { outputStream ->
copy(a.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
@@ -98,29 +110,48 @@ class VideoExport {
return@coroutineScope outputFile;
}
private fun ffmpegArg(value: String): String {
return "\"" + value
.replace("\\", "\\\\")
.replace("\"", "\\\"") + "\""
}
private fun resumeSuccessSafely(continuation: CancellableContinuation<Unit>) {
if (!continuation.isActive) return
try {
continuation.resumeWith(Result.success(Unit))
} catch (_: IllegalStateException) {
}
}
private fun resumeFailureSafely(continuation: CancellableContinuation<Unit>, throwable: Throwable) {
if (!continuation.isActive) return
try {
continuation.resumeWithException(throwable)
} catch (_: IllegalStateException) {
}
}
private suspend fun combine(inputPathAudio: String?, inputPathVideo: String?, inputPathSubtitles: String?, outputPath: String, duration: Double, onProgress: ((Double) -> Unit)? = null) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation ->
//ffmpeg -i a.mp4 -i b.m4a -scodec mov_text -i c.vtt -map 0:v -map 1:a -map 2 -c:v copy -c:a copy -c:s mov_text output.mp4
val cmdBuilder = StringBuilder("-y")
var counter = 0
if (inputPathVideo != null) {
cmdBuilder.append(" -i $inputPathVideo")
cmdBuilder.append(" -i ${ffmpegArg(inputPathVideo)}")
}
if (inputPathAudio != null) {
cmdBuilder.append(" -i $inputPathAudio")
cmdBuilder.append(" -i ${ffmpegArg(inputPathAudio)}")
}
if (inputPathSubtitles != null) {
val subtitleExtension = File(inputPathSubtitles).extension
val codec = when (subtitleExtension.lowercase()) {
"srt" -> "mov_text"
"vtt" -> "webvtt"
val subtitleExtension = File(inputPathSubtitles).extension.lowercase()
when (subtitleExtension) {
"srt", "vtt" -> {}
else -> throw Exception("Unsupported subtitle format: $subtitleExtension")
}
cmdBuilder.append(" -scodec $codec -i $inputPathSubtitles")
cmdBuilder.append(" -i ${ffmpegArg(inputPathSubtitles)}")
}
if (inputPathVideo != null) {
@@ -132,6 +163,7 @@ class VideoExport {
if (inputPathSubtitles != null) {
cmdBuilder.append(" -map ${counter++}")
cmdBuilder.append(" -c:s mov_text")
}
if (inputPathVideo != null) {
@@ -140,33 +172,44 @@ class VideoExport {
if (inputPathAudio != null) {
cmdBuilder.append(" -c:a copy")
}
if (inputPathAudio != null) {
cmdBuilder.append(" -c:s mov_text")
}
cmdBuilder.append(" $outputPath")
cmdBuilder.append(" ${ffmpegArg(outputPath)}")
val cmd = cmdBuilder.toString()
Logger.i(TAG, "Used command: $cmd");
val statisticsCallback = StatisticsCallback { statistics ->
val time = statistics.time.toDouble() / 1000.0
val progressPercentage = (time / duration)
onProgress?.invoke(progressPercentage)
val progressPercentage = if (duration > 0.0) {
(time / duration).coerceIn(0.0, 1.0)
} else {
0.0
}
onProgress?.let { callback ->
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
callback(progressPercentage)
}
}
}
val executorService = Executors.newSingleThreadExecutor()
val session = FFmpegKit.executeAsync(cmd,
{ session ->
if (ReturnCode.isSuccess(session.returnCode)) {
continuation.resumeWith(Result.success(Unit))
} else {
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
"Command cancelled"
try {
if (ReturnCode.isSuccess(session.returnCode)) {
resumeSuccessSafely(continuation)
} else {
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
"Command cancelled"
} else {
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
}
resumeFailureSafely(continuation, RuntimeException(errorMessage))
}
continuation.resumeWithException(RuntimeException(errorMessage))
} finally {
executorService.shutdown()
}
},
LogCallback { Logger.v(TAG, it.message) },
@@ -176,6 +219,7 @@ class VideoExport {
continuation.invokeOnCancellation {
session.cancel()
executorService.shutdown()
}
}
}
@@ -196,14 +240,24 @@ class VideoExport {
val totalBytes = srcFile.length()
var bytesCopied: Long = 0
if (totalBytes == 0L) {
onProgress?.let {
withContext(Dispatchers.Main) {
it(1.0)
}
}
return@withContext
}
var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
bytesCopied += bytesRead.toLong()
onProgress?.let {
val progress = (bytesCopied / totalBytes.toDouble()).coerceIn(0.0, 1.0)
withContext(Dispatchers.Main) {
it(bytesCopied / totalBytes.toDouble())
it(progress)
}
}
}
@@ -15,7 +15,9 @@ import com.caoccao.javet.values.primitive.V8ValueString
import com.caoccao.javet.values.reference.IV8ValuePromise
import com.caoccao.javet.values.reference.V8ValueObject
import com.caoccao.javet.values.reference.V8ValuePromise
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.engine.exceptions.NoInternetException
@@ -34,6 +36,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptTimeoutException
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
import com.futo.platformplayer.engine.internal.V8Converter
import com.futo.platformplayer.engine.packages.PackageBridge
import com.futo.platformplayer.engine.packages.PackageBrowser
import com.futo.platformplayer.engine.packages.PackageDOMParser
import com.futo.platformplayer.engine.packages.PackageHttp
import com.futo.platformplayer.engine.packages.PackageHttpImp
@@ -44,6 +47,7 @@ import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateAssets
import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.toList
import com.futo.platformplayer.toV8ValueBlocking
import com.futo.platformplayer.toV8ValueAsync
@@ -54,6 +58,7 @@ import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.cancel
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
@@ -83,6 +88,7 @@ class V8Plugin {
private val _deps : LinkedHashMap<String, String> = LinkedHashMap();
private val _depsPackages : MutableList<V8Package> = mutableListOf();
private var _script : String? = null;
val bridge: PackageBridge;
var isStopped = true;
val onStopped = Event1<V8Plugin>();
@@ -90,6 +96,9 @@ class V8Plugin {
private val _busyLock = ReentrantLock()
val isBusy get() = _busyLock.isLocked;
@Volatile
private var _busyHolder: Thread? = null;
var allowDevSubmit: Boolean = false
private set(value) {
field = value;
@@ -110,7 +119,8 @@ class V8Plugin {
this._clientAuth = clientAuth;
this.config = config;
this._script = script;
withDependency(PackageBridge(this, config));
bridge = PackageBridge(this, config);
withDependency(bridge);
for(pack in config.packages)
withDependency(getPackage(pack)!!);
@@ -155,51 +165,53 @@ class V8Plugin {
fun start() {
val script = _script ?: throw IllegalStateException("Attempted to start V8 without script");
synchronized(_runtimeLock) {
if (_runtime != null)
return;
runtimeId = runtimeId + 1;
//V8RuntimeOptions.V8_FLAGS.setUseStrict(true);
val host = V8Host.getV8Instance();
val options = host.jsRuntimeType.getRuntimeOptions();
tryBusy(BUSY_STARTUP_MS) {
synchronized(_runtimeLock) {
if (_runtime != null)
return@tryBusy;
runtimeId = runtimeId + 1;
//V8RuntimeOptions.V8_FLAGS.setUseStrict(true);
val host = V8Host.getV8Instance();
val options = host.jsRuntimeType.getRuntimeOptions();
_runtime = host.createV8Runtime(options);
if (!host.isIsolateCreated)
throw IllegalStateException("Isolate not created");
_runtime = host.createV8Runtime(options);
if (!host.isIsolateCreated)
throw IllegalStateException("Isolate not created");
_runtimeMap.put(_runtime!!, this);
_runtimeMap.put(_runtime!!, this);
//Setup bridge
_runtime?.let {
it.converter = V8Converter();
//Setup bridge
_runtime?.let {
it.converter = V8Converter();
for (pack in _depsPackages) {
if (pack.variableName != null)
it.createV8ValueObject().use { v8valueObject ->
it.globalObject.set(pack.variableName, v8valueObject);
v8valueObject.bind(pack);
};
catchScriptErrors("Package Dep[${pack.name}]") {
for (packScript in pack.getScripts())
it.getExecutor(packScript).executeVoid();
for (pack in _depsPackages) {
if (pack.variableName != null)
it.createV8ValueObject().use { v8valueObject ->
it.globalObject.set(pack.variableName, v8valueObject);
v8valueObject.bind(pack);
};
catchScriptErrors("Package Dep[${pack.name}]") {
for (packScript in pack.getScripts())
it.getExecutor(packScript).executeVoid();
}
}
}
//Load deps
for (dep in _deps)
catchScriptErrors("Dep[${dep.key}]") {
it.getExecutor(dep.value).executeVoid()
//Load deps
for (dep in _deps)
catchScriptErrors("Dep[${dep.key}]") {
it.getExecutor(dep.value).executeVoid()
};
if (config.allowEval)
it.allowEval(true);
//Load plugin
catchScriptErrors("Plugin[${config.name}]") {
it.getExecutor(script).executeVoid()
};
if (config.allowEval)
it.allowEval(true);
//Load plugin
catchScriptErrors("Plugin[${config.name}]") {
it.getExecutor(script).executeVoid()
};
isStopped = false;
isStopped = false;
}
}
}
}
@@ -218,6 +230,9 @@ class V8Plugin {
if(pack is PackageHttp) {
pack.cleanup();
}
else if(pack is PackageBrowser) {
pack.deinitialize();
}
}
_runtime?.let {
@@ -245,27 +260,30 @@ class V8Plugin {
fun isThreadAlreadyBusy(): Boolean {
return _busyLock.isHeldByCurrentThread;
}
fun <T> busy(handle: ()->T): T {
_busyLock.lock();
//Logger.i(TAG, "Busy Enter [" + _busyLock.holdCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString())
fun <T> busy(handle: ()->T): T = busyInternal(BUSY_FATAL_MS, true, "busy(enter)", handle)
fun <T> tryBusy(maxWaitMs: Long, handle: ()->T): T = busyInternal(maxWaitMs, false, "tryBusy(enter)", handle)
private fun <T> busyInternal(maxWaitMs: Long, allowUnwedge: Boolean, context: String, handle: ()->T): T {
acquireBusyOrThrow(context, maxWaitMs, allowUnwedge);
_busyHolder = Thread.currentThread();
try {
return handle();
}
finally {
//Logger.i(TAG, "Busy Leave [" + _busyLock.holdCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString())
_busyLock.unlock();
if (_busyLock.isHeldByCurrentThread) {
if (_busyLock.holdCount == 1)
_busyHolder = null;
_busyLock.unlock();
}
}
/*
_busyLock.withLock {
//Logger.i(TAG, "Entered busy: " + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString());
return handle();
}*/
}
fun <T> unbusy(handle: ()->T): T {
val wasLocked = isThreadAlreadyBusy();
if(!wasLocked)
return handle();
val lockCount = _busyLock.holdCount;
_busyHolder = null;
for(i in 1..lockCount)
_busyLock.unlock();
try {
@@ -274,9 +292,90 @@ class V8Plugin {
}
finally {
Logger.w(TAG, "Relocking V8 thread for [${config.name}] for a blocking resolve of a promise")
var acquired = 0;
try {
for (i in 1..lockCount) {
acquireBusyOrThrow("unbusy(relock)");
acquired++;
}
_busyHolder = Thread.currentThread();
}
catch (timeout: Throwable) {
for (j in 1..acquired)
_busyLock.unlock();
throw timeout;
}
}
}
for(i in 1..lockCount)
_busyLock.lock();
private fun acquireBusyOrThrow(context: String, maxWaitMs: Long = BUSY_FATAL_MS, allowUnwedge: Boolean = true) {
val warnAt = Math.min(BUSY_WARN_MS, maxWaitMs);
if (_busyLock.tryLock(warnAt, TimeUnit.MILLISECONDS))
return;
logBusyContention(context);
val remaining = maxWaitMs - warnAt;
if (remaining > 0 && _busyLock.tryLock(remaining, TimeUnit.MILLISECONDS))
return;
if (!allowUnwedge)
throw IllegalStateException("V8 busy lock for [${config.name}] timed out after ${maxWaitMs}ms in $context (fast-fail)");
unwedgeBusyHolder(context);
if (_busyLock.tryLock(BUSY_RECOVERY_MS, TimeUnit.MILLISECONDS))
return;
throw IllegalStateException("V8 busy lock for [${config.name}] timed out after ${maxWaitMs + BUSY_RECOVERY_MS}ms in $context; holder did not release after recovery");
}
private fun unwedgeBusyHolder(context: String) {
val holder = _busyHolder;
Logger.w(TAG, "V8 busy lock for [${config.name}] still held in $context after ${BUSY_FATAL_MS}ms; attempting to unwedge holder ${holder?.name ?: "unknown"}");
try {
val rt = _runtime;
if (rt != null && !rt.isClosed && !rt.isDead) {
Logger.w(TAG, "Calling terminateExecution() on [${config.name}] runtime");
rt.terminateExecution();
}
}
catch (ex: Throwable) {
Logger.e(TAG, "terminateExecution() failed for [${config.name}]", ex);
}
try {
holder?.interrupt();
}
catch (ex: Throwable) {
Logger.e(TAG, "Interrupting holder thread for [${config.name}] failed", ex);
}
}
private fun logBusyContention(context: String) {
try {
val holder = _busyHolder;
val sb = StringBuilder();
sb.append("V8 BUSY CONTENTION [${config.name}] in $context: queueLength=${_busyLock.queueLength}, holdCount=${_busyLock.holdCount}, waited>${BUSY_WARN_MS}ms\n");
if (holder != null) {
sb.append("Lock holder: ${holder.name} (id=${holder.id}, state=${holder.state})\n");
for (frame in holder.stackTrace.take(40))
sb.append(" at ").append(frame.toString()).append("\n");
} else {
sb.append("Lock holder unknown (likely already released or never set)\n");
}
sb.append("Suspect waiting/blocked threads:\n");
val cur = Thread.currentThread();
for ((thread, stack) in Thread.getAllStackTraces()) {
if (thread == cur || thread == holder) continue;
if (thread.state != Thread.State.WAITING && thread.state != Thread.State.BLOCKED && thread.state != Thread.State.TIMED_WAITING) continue;
if (stack.none {
val cn = it.className;
cn.contains("V8Plugin") || cn.contains("JSClient") || cn.contains("Extensions_V8")
|| cn.contains("Subscription") || cn.contains("PackageHttp") || cn.contains("JSPager")
|| cn.contains("JSContent")
}) continue;
sb.append(" ${thread.name} (state=${thread.state}):\n");
for (frame in stack.take(20))
sb.append(" at ").append(frame.toString()).append("\n");
}
Logger.w(TAG, sb.toString());
}
catch (ex: Throwable) {
Logger.e(TAG, "Failed to log busy contention", ex);
}
}
fun execute(js: String) : V8Value {
@@ -387,6 +486,18 @@ class V8Plugin {
"HttpImp" -> PackageHttpImp(this, config)
"Utilities" -> PackageUtilities(this, config)
"JSDOM" -> PackageJSDOM(this, config)
"Browser" -> {
val isOfficial = (config is SourcePluginConfig && config.isOfficialAuthor());
if(BuildConfig.DEBUG)
PackageBrowser(this)
else if(isOfficial)
PackageBrowser(this)
else if(config is SourcePluginConfig && config.id == StateDeveloper.DEV_ID)
PackageBrowser(this)
else
throw IllegalArgumentException("Browser is only allowed for debug and official plugins due to security");
};
else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}");
};
}
@@ -409,6 +520,11 @@ class V8Plugin {
val TAG = "V8Plugin";
private const val BUSY_WARN_MS = 10_000L;
private const val BUSY_FATAL_MS = 60_000L;
private const val BUSY_RECOVERY_MS = 5_000L;
const val BUSY_STARTUP_MS = 5_000L;
fun getPluginFromRuntime(runtime: V8Runtime): V8Plugin? {
return _runtimeMap.getOrDefault(runtime, null);
}
@@ -546,4 +662,4 @@ class V8Plugin {
return StateAssets.readAsset(context, path) ?: throw java.lang.IllegalStateException("script ${path} not found");
}
}
}
}
@@ -23,10 +23,7 @@ object Libcurl {
var body: ByteArray? = null,
var impersonateTarget: String = "chrome136",
var useBuiltInHeaders: Boolean = true,
var timeoutMs: Int = 30_000,
var cookieJarPath: String? = null,
var sendCookies: Boolean = true,
var persistCookies: Boolean = true,
var timeoutMs: Int = 30_000
)
@Keep
@@ -121,12 +118,6 @@ object Libcurl {
if (slist != 0L) checkOK(ce_setopt_ptr(easy, CURLOPT.HTTPHEADER, slist))
}
if (req.sendCookies || req.persistCookies) {
val jar = (req.cookieJarPath ?: defaultCookieJarPath())
if (req.sendCookies) checkOK(ce_setopt_str(easy, CURLOPT.COOKIEFILE, jar))
if (req.persistCookies) checkOK(ce_setopt_str(easy, CURLOPT.COOKIEJAR, jar))
}
val method = req.method
if (!method.equals("GET", ignoreCase = true)) {
checkOK(ce_setopt_str(easy, CURLOPT.CUSTOMREQUEST, method))
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.JSClientConstants
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
@@ -36,6 +37,9 @@ class PackageBridge : V8Package {
private val _client: ManagedHttpClient
@Transient
private val _clientAuth: ManagedHttpClient
// Set by JSClient after construction to provide access to auth/captcha data
@Transient
var descriptor: SourcePluginDescriptor? = null
override val name: String get() = "Bridge";
@@ -80,6 +84,17 @@ class PackageBridge : V8Package {
return "android";
}
// User agent captured during captcha/auth WebView flows, matching Desktop's bridge.captchaUserAgent/bridge.authUserAgent.
// Plugins use these to make HTTP requests with the same UA that was used in the WebView.
@V8Property
fun captchaUserAgent(): String? {
return descriptor?.getCaptchaData()?.userAgent
}
@V8Property
fun authUserAgent(): String? {
return descriptor?.getAuth()?.userAgent
}
@V8Property
fun supportedFeatures(): Array<String> {
return arrayOf(
@@ -105,10 +120,17 @@ class PackageBridge : V8Package {
)
}
@V8Function
fun hasPackage(str: String): Boolean {
return _plugin.getPackages().any { it.name == str };
}
@V8Function
fun dispose(value: V8Value) {
Logger.e(TAG, "Manual dispose: " + value.javaClass.name);
value.close();
_plugin.busy {
value.close();
}
}
var timeoutCounter = 0;
@@ -274,4 +296,4 @@ class PackageBridge : V8Package {
}
}
}
@@ -0,0 +1,554 @@
package com.futo.platformplayer.engine.packages
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.net.Uri
import android.os.Looper
import android.util.Log
import android.webkit.ConsoleMessage
import android.webkit.CookieManager
import android.webkit.JavascriptInterface
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.webkit.ScriptHandler
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.caoccao.javet.annotations.V8Function
import com.caoccao.javet.utils.JavetResourceUtils
import com.caoccao.javet.values.reference.V8ValueFunction
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDeveloper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.ByteArrayInputStream
import java.nio.charset.Charset
import androidx.core.net.toUri
class PackageBrowser: V8Package {
val useAddDocumentStartJavaScript = true
override val name: String get() = "Browser";
override val variableName: String = "browser";
@Volatile private var _loadToken: String? = null
@Volatile private var _expectedMainUrl: String? = null
private val _json = Json { };
@Transient
private val _pageLoadScriptRefs = ConcurrentHashMap<String, ScriptHandler>()
@Transient
private val _pageLoadScriptsFallback = ConcurrentHashMap<String, String>()
@Transient
private var _readySemaphore: Semaphore? = null;
@Transient
private val _callbacks = mutableMapOf<String, (String?)->Unit>();
@Transient
private var _browser: WebView? = null;
private val browser: WebView get() {
if(_browser == null)
throw IllegalStateException("Browser not initialized");
return _browser!!;
}
@Volatile
private var _userAgent: String = ""
private val http = OkHttpClient.Builder()
.followRedirects(false)
.followSslRedirects(false)
.build()
constructor(v8Plugin: V8Plugin): super(v8Plugin) {
}
@V8Function
fun initialize() {
if (_browser != null) return
onMainBlocking {
_browser = WebView(StateApp.instance.contextOrNull ?: return@onMainBlocking);
_userAgent = _browser?.settings?.userAgentString.orEmpty()
_browser?.settings?.javaScriptEnabled = true;
_browser?.settings?.blockNetworkImage = false;
_browser?.settings?.blockNetworkLoads = false;
_browser?.settings?.allowContentAccess = false;
_browser?.settings?.allowFileAccess = false;
//_browser?.settings?.useWideViewPort = true;
//_browser?.settings?.loadWithOverviewMode = true;
_browser?.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
if (view == null || request == null) return null
if (useAddDocumentStartJavaScript && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return null
if (!request.isForMainFrame) return null
if (!request.method.equals("GET", ignoreCase = true)) return null
val url = request.url?.toString() ?: return null
Log.i("PackageBrowser", "shouldInterceptRequest: " + url)
val scheme = request.url?.scheme ?: return null
if (scheme != "http" && scheme != "https") return null
val scripts = _pageLoadScriptsFallback.values.toList()
if (scripts.isEmpty()) return null
return try {
val cookie = request.requestHeaders["Cookie"] ?: runCatching { CookieManager.getInstance().getCookie(url) }.getOrNull()
val ua = request.requestHeaders["User-Agent"] ?: _userAgent
val okReq = Request.Builder()
.url(url)
.get()
.header("User-Agent", ua)
.apply { if (!cookie.isNullOrEmpty()) header("Cookie", cookie) }
.build()
http.newCall(okReq).execute().use { resp ->
val code = resp.code
val reason = resp.message.ifBlank { "OK" }
if (code in 300..399) return null
val contentType = resp.header("Content-Type") ?: ""
val isHtml =
contentType.startsWith("text/html", ignoreCase = true) ||
contentType.startsWith("application/xhtml+xml", ignoreCase = true)
if (!isHtml) return null
val bodyBytes = resp.body.bytes()
val charset = charsetFromContentType(contentType) ?: Charsets.UTF_8
val html = bodyBytes.toString(charset)
val cspHeader = resp.header("Content-Security-Policy")
?: resp.header("Content-Security-Policy-Report-Only")
val nonce = extractNonceFromCsp(cspHeader) ?: extractNonceFromHtml(html)
val injected = injectIntoHead(html, scripts.joinToString("\n"), nonce)
val outBytes = injected.toByteArray(charset)
val headers = resp.headers.toMultimap()
.mapValues { it.value.joinToString(",") }
.toMutableMap()
headers.remove("Content-Length")
val cookieMgr = CookieManager.getInstance()
resp.headers.values("Set-Cookie").forEach { sc ->
try { cookieMgr.setCookie(url, sc) } catch (_: Throwable) {}
}
try { cookieMgr.flush() } catch (_: Throwable) {}
WebResourceResponse("text/html", charset.name(), code, reason, headers, ByteArrayInputStream(outBytes))
}
} catch (_: Throwable) {
null
}
}
override fun onPageCommitVisible(view: WebView?, url: String?) {
super.onPageCommitVisible(view, url)
Logger.i("PackageBrowser", "Browser loaded (commit visible): $url")
releaseReadyIfCurrent(url)
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
Logger.i("PackageBrowser", "Browser loaded (finished): $url")
releaseReadyIfCurrent(url)
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
return false;
}
}
_browser?.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
try {
val raw = consoleMessage?.message().orEmpty()
val normalized = raw.trim().let { s ->
if (s.length >= 2 && s.first() == '"' && s.last() == '"') {
s.substring(1, s.length - 1)
} else s
}
if (normalized.startsWith(CONSOLE_BRIDGE_PREFIX)) {
val payload = normalized.substring(CONSOLE_BRIDGE_PREFIX.length)
if (handleConsoleBridgeMessage(payload)) return true
}
if (consoleMessage?.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
val emsg =
"Browser Error:${consoleMessage.message()} [${consoleMessage.lineNumber()}]"
Logger.e("PackageBrowser", emsg)
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
StateDeveloper.instance.logDevException(
StateDeveloper.instance.currentDevID ?: "", emsg
)
} else {
val imsg = "Browser Log:${consoleMessage?.message()}"
Logger.i("PackageBrowser", imsg)
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID)
StateDeveloper.instance.logDevInfo(
StateDeveloper.instance.currentDevID ?: "", imsg
)
}
return super.onConsoleMessage(consoleMessage)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to handle onConsoleMessage", e)
return super.onConsoleMessage(consoleMessage)
}
}
}
}
val bootstrap = """
(() => {
try {
if (window.__GJ) return;
const PREFIX = ${CONSOLE_BRIDGE_PREFIX.quoteForJs()};
const emit = (obj) => {
try {
console.info(PREFIX + JSON.stringify(obj));
} catch (_) {}
};
Object.defineProperty(window, "__GJ", {
value: {
callback: (id, result) => {
try {
const r = (typeof result === "string")
? result
: (() => { try { return JSON.stringify(result); } catch (_) { return String(result); } })();
emit({ t: "cb", id: String(id), result: r });
} catch (_) {}
},
log: (msg) => {
try { emit({ t: "log", msg: String(msg) }); } catch (_) {}
}
},
enumerable: false,
configurable: false,
writable: false
});
} catch (_) {}
})();
""".trimIndent()
addScriptOnLoad(bootstrap)
}
@V8Function
fun deinitialize() {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
_browser?.destroy();
}
_browser = null;
}
@V8Function
fun getCurrentUrl(): String? {
return browser.url;
}
@V8Function
fun waitTillLoaded(timeout: Int = 1000): Boolean {
val acquired = _readySemaphore?.let {
if(!it.tryAcquire()) {
Logger.i("PackageBrowser", "Waiting for browser to be ready");
if(!runBlocking {
try {
return@runBlocking withTimeout(timeout.toLong(), {
it.acquire()
return@withTimeout true;
});
}
catch(ex: TimeoutCancellationException) {
return@runBlocking false;
}
}) return@let false;
}
it.release();
return@let true;
} ?: true;
if(acquired)
Logger.i("PackageBrowser", "Browser is ready");
else
Logger.i("PackageBrowser", "Browser failed wait ready");
return acquired;
}
@V8Function
fun load(url: String) {
Logger.i("PackageBrowser", "Browser loading url [$url]")
val token = UUID.randomUUID().toString()
_loadToken = token
_expectedMainUrl = url
_readySemaphore = Semaphore(1, acquiredPermits = 1)
StateApp.instance.scope.launch(Dispatchers.Main) {
try { browser.loadUrl(url) }
catch (t: Throwable) { Logger.e("PackageBrowser", "loadUrl failed", t) }
}
}
private fun releaseReadyIfCurrent(url: String?) {
if (url == null) return
val expected = _expectedMainUrl
if (url.trimEnd('/') != expected?.trimEnd('/')) return
_readySemaphore?.release()
_readySemaphore = null
_expectedMainUrl = null
}
@V8Function
fun run(js: String, callbackId: String? = null, callback: V8ValueFunction? = null) {
waitTillLoaded();
val funcClone = callback?.toClone<V8ValueFunction>()
if(callbackId != null && callback != null) {
synchronized(_callbacks) {
_callbacks.put(callbackId, {
_plugin.busy {
funcClone?.callVoid(null, arrayOf(it));
}
if (!_plugin.isStopped)
JavetResourceUtils.safeClose(funcClone);
});
}
}
StateApp.instance.scope.launch(Dispatchers.Main) {
try {
try {
Logger.i("PackageBrowser", "Browser running JS with callback [${callbackId}]\n${(if(js.length > 200) (js.substring(0, 200) + "...") else js)})");
browser.evaluateJavascript(js, object : ValueCallback<String> {
override fun onReceiveValue(value: String?) {
Logger.i("PackageBrowser", "Browser run finished");
}
})
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Browser running failed: " + ex.message, ex);
}
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Failed to invoke browser", ex);
}
}
}
@V8Function
fun runWithReturn(js: String, callback: V8ValueFunction? = null) {
waitTillLoaded();
val funcClone = callback?.toClone<V8ValueFunction>()
StateApp.instance.scope.launch(Dispatchers.Main) {
try {
Logger.i("PackageBrowser", "Browser running JS with callback [sync]\n${(if(js.length > 200) (js.substring(0, 200) + "...") else js)})");
browser.evaluateJavascript(js, object : ValueCallback<String> {
override fun onReceiveValue(value: String?) {
Logger.i("PackageBrowser", "Browser run returned: " + (value ?: ""));
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
Logger.i("PackageBrowser", "Invoking V8 with result (${funcClone != null})");
try {
_plugin.busy {
if (value != null) {
val json = _json.decodeFromString<String>(value);
funcClone?.callVoid(null, arrayOf(json));
} else
funcClone?.callVoid(null, arrayOf((null as String?)));
}
if (!_plugin.isStopped)
JavetResourceUtils.safeClose(funcClone);
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Browser Failed to callback: " + ex.message, ex);
}
}
}
})
}
catch(ex: Throwable) {
Logger.e("PackageBrowser", "Browser Failed to invoke browser", ex);
}
}
}
@V8Function
fun addScriptOnLoad(js: String): String {
require(js.isNotBlank()) { "Script must be non-empty." }
val id = UUID.randomUUID().toString()
onMainBlocking {
if (useAddDocumentStartJavaScript && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
val ref = WebViewCompat.addDocumentStartJavaScript(browser, js, setOf("*"))
_pageLoadScriptRefs[id] = ref
} else {
_pageLoadScriptsFallback[id] = js
}
}
Logger.i("PackageBrowser", "addScriptOnLoad() registered (id=$id)")
return id
}
@SuppressLint("RequiresFeature")
@V8Function
fun removeScriptOnLoad(identifier: String): Boolean {
if (identifier.isBlank()) return false
val ref = _pageLoadScriptRefs.remove(identifier)
val removedFallback = _pageLoadScriptsFallback.remove(identifier) != null
if (ref != null) {
onMainBlocking {
try { ref.remove() } catch (_: Throwable) {}
}
Logger.i("PackageBrowser", "removeScriptOnLoad() removed (id=$identifier)")
return true
}
if (removedFallback) {
Logger.i("PackageBrowser", "removeScriptOnLoad() removed fallback (id=$identifier)")
return true
}
return false
}
@SuppressLint("RequiresFeature")
@V8Function
fun clearScriptsOnLoad() {
val refs = _pageLoadScriptRefs.values.toList()
_pageLoadScriptRefs.clear()
_pageLoadScriptsFallback.clear()
onMainBlocking {
for (r in refs) {
try { r.remove() } catch (_: Throwable) {}
}
}
Logger.i("PackageBrowser", "clearScriptsOnLoad() cleared")
}
private fun charsetFromContentType(ct: String): Charset? {
val m = Regex("(?i)charset=([\\w\\-]+)").find(ct) ?: return null
val name = m.groupValues.getOrNull(1)?.trim().orEmpty()
return runCatching { Charset.forName(name) }.getOrNull()
}
private fun injectIntoHead(html: String, js: String, nonce: String?): String {
val nonceAttr = nonce?.let { " nonce=\"${escapeHtmlAttr(it)}\"" } ?: ""
val tag = "<script$nonceAttr>\n$js\n</script>\n"
val head = Regex("(?i)<head[^>]*>").find(html)
if (head != null) {
val i = head.range.last + 1
return buildString(html.length + tag.length + 8) {
append(html, 0, i)
append('\n')
append(tag)
append(html, i, html.length)
}
}
return tag + html
}
private fun <T> onMainBlocking(block: () -> T): T {
return if (Looper.myLooper() == Looper.getMainLooper()) {
block()
} else runBlocking {
withContext(Dispatchers.Main) { block() }
}
}
private fun extractNonceFromCsp(csp: String?): String? {
if (csp.isNullOrBlank()) return null
val m = Regex("(?i)'nonce-([^'\\s;]+)'").find(csp) ?: return null
return m.groupValues[1]
}
private fun extractNonceFromHtml(html: String): String? {
val m = Regex("(?i)<script[^>]*\\snonce\\s*=\\s*['\"]([^'\"]+)['\"][^>]*>").find(html)
return m?.groupValues?.get(1)
}
private fun escapeHtmlAttr(s: String): String =
s.replace("&", "&amp;").replace("\"", "&quot;")
@Serializable
private data class ConsoleBridgeMsg(
val t: String,
val id: String? = null,
val result: String? = null,
val msg: String? = null
)
private fun handleConsoleBridgeMessage(payload: String): Boolean {
Logger.i("PackageBrowser", "handleConsoleBridgeMessage: " + payload)
val parsed = runCatching { _json.decodeFromString<ConsoleBridgeMsg>(payload) }.getOrNull()
?: return false
when (parsed.t) {
"cb" -> {
val id = parsed.id ?: return true
val res = parsed.result
val cb = synchronized(_callbacks) { _callbacks.remove(id) } ?: return true
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
cb.invoke(res)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to invoke callback asynchronously", e)
}
}
return true
}
"log" -> {
val text = parsed.msg.orEmpty()
Logger.i("PackageBrowser", "Browser Log: $text")
if (_plugin.config is SourcePluginConfig && _plugin.config.id == StateDeveloper.DEV_ID) {
StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", text)
}
return true
}
else -> return true
}
}
private companion object {
private const val CONSOLE_BRIDGE_PREFIX = "__GJ__:"
private const val TAG = "PackageBrowser"
private fun String.quoteForJs(): String {
val s = this
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
return "\"$s\""
}
}
}
@@ -651,14 +651,17 @@ class PackageHttp: V8Package {
@V8Function
fun connect(socketObj: V8ValueObject) {
val hasOpen = socketObj.has("open");
val hasMessage = socketObj.has("message");
val hasClosing = socketObj.has("closing");
val hasClosed = socketObj.has("closed");
val hasFailure = socketObj.has("failure");
val (hasOpen, hasMessage, hasClosing, hasClosed, hasFailure) = _package._plugin.busy {
val open = socketObj.has("open");
val message = socketObj.has("message");
val closing = socketObj.has("closing");
val closed = socketObj.has("closed");
val failure = socketObj.has("failure");
socketObj.setWeak(); //We have to manage this lifecycle
_listeners = socketObj;
socketObj.setWeak(); //We have to manage this lifecycle
_listeners = socketObj;
Quintuple(open, message, closing, closed, failure);
};
_socket = _packageClient.logExceptions {
val client = _client;
@@ -666,51 +669,50 @@ class PackageHttp: V8Package {
override fun open() {
Logger.i(TAG, "Websocket opened: " + _url);
_isOpen = true;
if(hasOpen && _listeners?.isClosed != true) {
try {
_package._plugin.busy {
try {
_package._plugin.busy {
if(hasOpen && _listeners?.isClosed != true) {
_listeners?.invokeV8Void("open", arrayOf<Any>());
}
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] open failed: " + ex.message, ex);
}
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] open failed: " + ex.message, ex);
}
}
override fun message(msg: String) {
if(hasMessage && _listeners?.isClosed != true) {
try {
_package._plugin.busy {
try {
_package._plugin.busy {
if(hasMessage && _listeners?.isClosed != true) {
_listeners?.invokeV8Void("message", msg);
}
}
catch(ex: Throwable) {}
}
catch(ex: Throwable) {}
}
override fun closing(code: Int, reason: String) {
if(hasClosing && _listeners?.isClosed != true)
{
try {
_package._plugin.busy {
try {
_package._plugin.busy {
if(hasClosing && _listeners?.isClosed != true) {
_listeners?.invokeV8Void("closing", code, reason);
}
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closing failed: " + ex.message, ex);
}
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closing failed: " + ex.message, ex);
}
}
override fun closed(code: Int, reason: String) {
_isOpen = false;
if(hasClosed && _listeners?.isClosed != true) {
try {
_package._plugin.busy {
try {
_package._plugin.busy {
if(hasClosed && _listeners?.isClosed != true) {
_listeners?.invokeV8Void("closed", code, reason);
}
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
}
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
}
Logger.w(TAG, "PackageHttp Socket removed");
synchronized(_package.aliveSockets) {
@@ -720,15 +722,15 @@ class PackageHttp: V8Package {
override fun failure(exception: Throwable) {
_isOpen = false;
Logger.e(TAG, "Websocket failure: ${exception.message} (${_url})", exception);
if(hasFailure && _listeners?.isClosed != true) {
try {
_package._plugin.busy {
try {
_package._plugin.busy {
if(hasFailure && _listeners?.isClosed != true) {
_listeners?.invokeV8Void("failure", exception.message);
}
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
}
}
catch(ex: Throwable){
Logger.e(TAG, "Socket for [${_packageClient.parentConfig.name}] closed failed: " + ex.message, ex);
}
}
});
@@ -747,10 +749,20 @@ class PackageHttp: V8Package {
@V8Function
fun close(code: Int?, reason: String?) {
_socket?.close(code ?: 1000, reason ?: "");
_listeners?.close()
_package._plugin.busy {
_listeners?.close()
}
}
}
private data class Quintuple<A, B, C, D, E>(
val first: A,
val second: B,
val third: C,
val fourth: D,
val fifth: E
)
data class RequestDescriptor(
val method: String,
val url: String,
@@ -780,4 +792,4 @@ class PackageHttp: V8Package {
private const val TAG = "PackageHttp";
private val WHITELISTED_RESPONSE_HEADERS = listOf("content-type", "date", "content-length", "last-modified", "etag", "cache-control", "content-encoding", "content-disposition", "connection")
}
}
}
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,7 @@ import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
open class MainActivityFragment : Fragment() {
protected val currentMain : MainFragment
protected val currentMain : MainFragment?
get() {
isValidMainActivity();
return (activity as MainActivity).fragCurrent;
@@ -8,18 +8,25 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.animation.doOnEnd
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp
import com.futo.platformplayer.fragment.mainactivity.MainActivityFragment
import com.futo.platformplayer.fragment.mainactivity.main.*
@@ -27,6 +34,10 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePayment
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.AnyAdapterView
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.pills.RoundButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.math.floor
@@ -69,9 +80,15 @@ class MenuBottomBarFragment : MainActivityFragment() {
private val _inflater: LayoutInflater;
private val _subscribedActivity: MainActivity?;
private val _containerMoreHeader: ConstraintLayout;
private val _toggleAirplaneMode: LinearLayout;
private val _togglePrivacy: LinearLayout;
private var _overlayMore: FrameLayout;
private var _overlayMoreBackground: FrameLayout;
private var _layoutMoreButtons: LinearLayout;
private var _layoutMoreButtons: RecyclerView;
private val _layoutMoreButtonItems = arrayListOf<MenuButtonItem>();
private var _layoutMoreButtonsAdapter: AnyAdapterView<MenuButtonItem, MenuButtonItemViewHolder>;
private var _layoutBottomBarButtons: LinearLayout;
private var _moreVisible = false;
@@ -85,15 +102,90 @@ class MenuBottomBarFragment : MainActivityFragment() {
private var currentButtonDefinitions: List<ButtonDefinition>? = null;
private var moreColumns = 3;
constructor(fragment: MenuBottomBarFragment, inflater: LayoutInflater) : super(inflater.context) {
_fragment = fragment;
_inflater = inflater;
inflater.inflate(R.layout.fragment_overview_bottom_bar, this);
_containerMoreHeader = findViewById(R.id.container_more_options);
_toggleAirplaneMode = findViewById(R.id.container_toggle_airplane);
_togglePrivacy = findViewById(R.id.container_toggle_privacy);
_toggleAirplaneMode.isVisible = false //TODO: Remove when airplane mode implemented
StateApp.instance.airplaneModeChanged.subscribe {
if(!StateApp.instance.airplaneMode)
_toggleAirplaneMode.setBackgroundResource(R.drawable.background_menu_toggle)
else
_toggleAirplaneMode.setBackgroundResource(R.drawable.background_menu_toggle_active)
}
if(!StateApp.instance.airplaneMode)
_toggleAirplaneMode.setBackgroundResource(R.drawable.background_menu_toggle)
else
_toggleAirplaneMode.setBackgroundResource(R.drawable.background_menu_toggle_active)
_toggleAirplaneMode.setOnClickListener {
if(StateApp.instance.airplaneMode) {
StateApp.instance.setAirMode(false);
UIDialogs.appToast("Airplane mode disabled");
}
else {
StateApp.instance.setAirMode(true);
UIDialogs.appToast("Airplane mode enabled");
}
}
StateApp.instance.privateModeChanged.subscribe {
if(!StateApp.instance.privateMode)
_togglePrivacy.setBackgroundResource(R.drawable.background_menu_toggle)
else
_togglePrivacy.setBackgroundResource(R.drawable.background_menu_toggle_active)
}
if(!StateApp.instance.privateMode)
_togglePrivacy.setBackgroundResource(R.drawable.background_menu_toggle)
else
_togglePrivacy.setBackgroundResource(R.drawable.background_menu_toggle_active)
_togglePrivacy.setOnClickListener {
if(StateApp.instance.privateMode) {
StateApp.instance.setPrivacyMode(false);
UIDialogs.appToast("Privacy mode disabled");
}
else {
StateApp.instance.setPrivacyMode(true);
UIDialogs.appToast("Privacy mode enabled");
if(Settings.instance.other.showPrivacyModeDialog)
UIDialogs.showDialog(it.context ?: return@setOnClickListener, R.drawable.incognito, "Privacy Mode",
"All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0,
UIDialogs.Action("Don't show again", {
Settings.instance.other.showPrivacyModeDialog = false;
Settings.instance.save();
}, UIDialogs.ActionStyle.NONE),
UIDialogs.Action("Understood", {
}, UIDialogs.ActionStyle.PRIMARY));
}
}
_overlayMore = findViewById(R.id.more_overlay);
_overlayMoreBackground = findViewById(R.id.more_overlay_background);
_layoutMoreButtons = findViewById(R.id.more_menu_buttons);
_layoutBottomBarButtons = findViewById(R.id.bottom_bar_buttons)
_layoutBottomBarButtons = findViewById(R.id.bottom_bar_buttons);
val totalWidthDp = resources.displayMetrics.widthPixels / resources.displayMetrics.density;
val columns = MenuButtonItemViewHolder.getAutoSizeColumns(totalWidthDp);
_layoutMoreButtonsAdapter = _layoutMoreButtons.asAny<MenuButtonItem, MenuButtonItemViewHolder>(_layoutMoreButtonItems,
RecyclerView.VERTICAL, false, { button ->
button.setAutoSize(totalWidthDp);
button.parentFragment = this@MenuBottomBarView._fragment;
button.onClick.subscribe {
setMoreVisible(false);
}
})
moreColumns = columns;
val layoutManager = GridLayoutManager(context, columns, GridLayoutManager.VERTICAL, true);
_layoutMoreButtons.layoutManager = layoutManager;
_overlayMoreBackground.setOnClickListener { setMoreVisible(false); };
@@ -120,6 +212,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
}
private fun setMoreVisible(visible: Boolean) {
//TODO: issues with these bools
if (_moreVisibleAnimating) {
return
}
@@ -128,9 +222,12 @@ class MenuBottomBarFragment : MainActivityFragment() {
return
}
/*
val height = _moreButtons.firstOrNull()?.let {
it.height.toFloat() + (it.layoutParams as MarginLayoutParams).bottomMargin
} ?: return
*/
_moreVisibleAnimating = true
val moreOverlayBackground = _overlayMoreBackground
@@ -142,14 +239,17 @@ class MenuBottomBarFragment : MainActivityFragment() {
moreOverlay.visibility = VISIBLE
val animations = arrayListOf<Animator>()
animations.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 0.0f, 1.0f).setDuration(duration))
animations.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "alpha", 0.0f, 1.0f).setDuration(duration))
animations.add(ObjectAnimator.ofFloat(_containerMoreHeader, "alpha", 0.0f, 1.0f).setDuration(duration))
_bottomButtons.find { it.definition.id == 99 }?.let {
animations.add(ObjectAnimator.ofFloat(it, "alpha", 0.4f, 1.0f)
animations.add(ObjectAnimator.ofFloat(it, "alpha", 0.5f, 1.0f)
.setDuration(duration));
}
animations.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "translationY", resources.displayMetrics.heightPixels.toFloat(), 0.0f).setDuration(duration))
for ((index, button) in _moreButtons.withIndex()) {
val i = _moreButtons.size - index
animations.add(ObjectAnimator.ofFloat(button, "translationY", height * staggerFactor * (i + 1), 0.0f).setDuration(duration))
//animations.add(ObjectAnimator.ofFloat(button, "translationY", height * staggerFactor * (i + 1), 0.0f).setDuration(duration))
}
val animatorSet = AnimatorSet()
@@ -164,14 +264,21 @@ class MenuBottomBarFragment : MainActivityFragment() {
animations
.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 1.0f, 0.0f)
.setDuration(duration))
animations
.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "alpha", 1.0f, 0.0f)
.setDuration(duration))
animations
.add(ObjectAnimator.ofFloat(_containerMoreHeader, "alpha", 1.0f, 0.0f)
.setDuration(duration))
_bottomButtons.find { it.definition.id == 99 }?.let {
animations.add(ObjectAnimator.ofFloat(it, "alpha", 1.0f, 0.4f)
animations.add(ObjectAnimator.ofFloat(it, "alpha", 1.0f, 0.5f)
.setDuration(duration));
}
animations.add(ObjectAnimator.ofFloat(_layoutMoreButtons, "translationY", 0.0f, resources.displayMetrics.heightPixels.toFloat()).setDuration(duration))
for ((index, button) in _moreButtons.withIndex()) {
val i = _moreButtons.size - index
animations.add(ObjectAnimator.ofFloat(button, "translationY", 0.0f, height * staggerFactor * (i + 1)).setDuration(duration))
//animations.add(ObjectAnimator.ofFloat(button, "translationY", 0.0f, height * staggerFactor * (i + 1)).setDuration(duration))
}
val animatorSet = AnimatorSet()
@@ -183,11 +290,12 @@ class MenuBottomBarFragment : MainActivityFragment() {
animatorSet.playTogether(animations)
animatorSet.start()
}
}
private fun updateBottomMenuButtons(buttons: MutableList<ButtonDefinition>, hasMore: Boolean) {
if (hasMore) {
buttons.add(ButtonDefinition(99, R.drawable.ic_more, R.drawable.ic_more, R.string.more, canToggle = false, { false }, { setMoreVisible(true) }))
buttons.add(ButtonDefinition(99, R.drawable.ic_more, R.drawable.ic_more, R.string.more, canToggle = false, { false }, { setMoreVisible(!_moreVisible) }))
}
_bottomButtons.clear();
@@ -227,32 +335,42 @@ class MenuBottomBarFragment : MainActivityFragment() {
_layoutMoreButtons.removeAllViews();
var insertedButtons = 0;
//Force settings to be first
val settingsIndex = buttons.indexOfFirst { b -> b.id == 7 };
if (settingsIndex != -1) {
val button = buttons[settingsIndex]
buttons.removeAt(settingsIndex)
buttons.add(0, button)
//insertedButtons++;
}
//Force buy to be on top for more buttons
val buyIndex = buttons.indexOfFirst { b -> b.id == 98 };
if (buyIndex != -1) {
val button = buttons[buyIndex]
buttons.removeAt(buyIndex)
buttons.add(0, button)
insertedButtons++;
buttons.add(button)
//insertedButtons++;
}
//Force faq to be second
val faqIndex = buttons.indexOfFirst { b -> b.id == 97 };
if (faqIndex != -1) {
val button = buttons[faqIndex]
buttons.removeAt(faqIndex)
buttons.add(if (insertedButtons == 1) 1 else 0, button)
insertedButtons++;
buttons.add(button)
//insertedButtons++;
}
//Force privacy to be third
val privacyIndex = buttons.indexOfFirst { b -> b.id == 96 };
if (privacyIndex != -1) {
val button = buttons[privacyIndex]
buttons.removeAt(privacyIndex)
buttons.add(if (insertedButtons == 2) 2 else (if(insertedButtons == 1) 1 else 0), button)
insertedButtons++;
buttons.add(button)
//insertedButtons++;
}
val newButtons = mutableListOf<MenuButtonItem>();
for (data in buttons) {
/*
val button = MenuButton(context, data, _fragment, true);
button.setOnClickListener {
updateMenuIcons()
@@ -262,7 +380,12 @@ class MenuBottomBarFragment : MainActivityFragment() {
_moreButtons.add(button);
_layoutMoreButtons.addView(button);
*/
val buttonItem = MenuButtonItem(data);
newButtons.add(buttonItem);
}
_layoutMoreButtonsAdapter.setData(newButtons);
_layoutMoreButtonsAdapter.notifyContentChanged();
}
private fun updateMenuIcons() {
@@ -350,6 +473,71 @@ class MenuBottomBarFragment : MainActivityFragment() {
}
class MenuButtonItem(val def: ButtonDefinition);
class MenuButtonItemViewHolder(private val _viewGroup: ViewGroup): AnyAdapter.AnyViewHolder<MenuButtonItem>(
LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_menu_tile,
_viewGroup, false)) {
val onClick = Event1<MenuButtonItem>();
val root: ConstraintLayout;
val imageIcon: ImageView;
val textName: TextView;
var button: MenuButtonItem? = null;
var parentFragment: MenuBottomBarFragment? = null;
init {
root = _view.findViewById(R.id.root);
imageIcon = _view.findViewById(R.id.image_icon);
textName = _view.findViewById(R.id.text_name);
root.setOnClickListener {
button?.let {
it.def.action(parentFragment ?: return@let);
onClick.emit(it);
}
}
}
override fun bind(value: MenuButtonItem) {
button = value;
textName.text = _view.context.getString(value.def.string);
imageIcon.setImageResource(value.def.iconActive);
}
fun setWidth(dp: Int) {
root.updateLayoutParams {
this.width = (dp - 6).dp(_viewGroup.context.resources);
this.height = (dp - 6).dp(_viewGroup.context.resources);
}
imageIcon.updateLayoutParams {
this.width = (dp - 54).dp(_viewGroup.context.resources);
this.height = (dp - 54).dp(_viewGroup.context.resources);
}
}
fun setAutoSize(totalWidth: Float) {
val dpWidth = totalWidth;
val columns = Math.max(((dpWidth) / viewWidthDp).toInt(), 1);
val remainder = dpWidth - columns * viewWidthDp;
val targetSize = viewWidthDp + (remainder / columns).toInt();
setWidth(targetSize);
}
companion object {
val viewWidthDp = 90;
fun getAutoSizeColumns(totalWidth: Float): Int {
val dpWidth = totalWidth;
val columns = Math.max(((dpWidth) / viewWidthDp).toInt(), 1);
return columns;
}
}
}
class MenuButton: LinearLayout {
val definition: ButtonDefinition;
@@ -369,7 +557,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
this.alpha = 1f;
}
else {
this.alpha = 0.4f;
this.alpha = 0.5f;
}
_textButton = findViewById(R.id.text_button);
@@ -389,7 +577,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
this.alpha = 1f;
}
else {
this.alpha = 0.4f;
this.alpha = 0.5f;
}
}
}
@@ -413,7 +601,9 @@ class MenuBottomBarFragment : MainActivityFragment() {
}
}),
ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate<SubscriptionsFeedFragment>(withHistory = false) }),
ButtonDefinition(12, R.drawable.ic_library, R.drawable.ic_library, R.string.library, canToggle = false, { it.currentMain is LibraryFragment }, { it.navigate<LibraryFragment>(withHistory = false) }),
//if(Build.VERSION.SDK_INT > Build.VERSION_CODES.P)
ButtonDefinition(12, R.drawable.ic_library, R.drawable.ic_library, R.string.library, canToggle = false, { it.currentMain is LibraryFragment }, { it.navigate<LibraryFragment>(withHistory = false) })
,//else null,
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>(withHistory = false) }),
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>(withHistory = false) }),
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>(withHistory = false) }),
@@ -423,7 +613,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>(withHistory = false) }),
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>(withHistory = false) }),
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>(withHistory = false) }),
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { it.currentMain is SettingsFragment }, {
it.navigate<SettingsFragment>();
/*
val c = it.context ?: return@ButtonDefinition;
@@ -434,7 +624,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
if (c is Activity) {
c.overridePendingTransition(R.anim.slide_in_up, R.anim.slide_darken);
}*/
}),
}),/*
ButtonDefinition(96, R.drawable.ic_disabled_visible, R.drawable.ic_disabled_visible, R.string.privacy_mode, canToggle = true, { false }, {
UIDialogs.showDialog(it.context ?: return@ButtonDefinition, R.drawable.ic_disabled_visible_purple, "Privacy Mode",
"All requests will be processed anonymously (any logins will be disabled except for the personalized home page), local playback and history tracking will also be disabled.\n\nTap the icon to disable.", null, 0,
@@ -444,14 +634,14 @@ class MenuBottomBarFragment : MainActivityFragment() {
UIDialogs.Action("Enable", {
StateApp.instance.setPrivacyMode(true);
}, UIDialogs.ActionStyle.PRIMARY));
}),
}),*/
ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = true, { false }, {
it.navigate<BrowserFragment>(Settings.URL_FAQ, withHistory = false);
})
//96 is reserved for privacy button
//98 is reserved for buy button
//99 is reserved for more button
);
).filterNotNull();
}
data class ButtonDefinition(
@@ -367,7 +367,7 @@ class ArticleDetailFragment : MainFragment {
_rating.visibility = VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
_rating.onLikeDislikeUpdated.subscribe(this@ArticleDetailView) { args ->
if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) {
@@ -0,0 +1,88 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.provider.MediaStore
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.dp
import com.futo.platformplayer.states.Album
import com.futo.platformplayer.states.Artist
import com.futo.platformplayer.states.ArtistOrdering
import com.futo.platformplayer.states.FileEntry
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.AnyInsertedAdapterView
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithViews
import com.futo.platformplayer.views.LibrarySection
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.InsertedViewAdapter
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.ArtistTileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.LocalVideoTileViewHolder
import com.futo.platformplayer.views.buttons.BigButton
class BaseFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val newView = FragView(this);
view = newView;
return newView;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown();
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = BaseFragment().apply {}
}
class FragView: ConstraintLayout {
val fragment: BaseFragment;
constructor(fragment: BaseFragment) : super(fragment.requireContext()) {
inflate(context, R.layout.fragview_library, this);
this.fragment = fragment;
}
fun onShown() {
}
}
}
@@ -10,6 +10,7 @@ import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import android.widget.Spinner
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@@ -21,6 +22,7 @@ class BrowserFragment : MainFragment() {
override val isTab: Boolean = false;
override val hasBottomBar: Boolean get() = true;
private var _root: LinearLayout? = null;
private var _webview: WebView? = null;
private val _webviewWithoutHandling = object: WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
@@ -31,6 +33,7 @@ class BrowserFragment : MainFragment() {
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = inflater.inflate(R.layout.fragment_browser, container, false);
_root = view.findViewById<LinearLayout>(R.id.root);
_webview = view.findViewById<WebView?>(R.id.webview).apply {
this.webViewClient = _webviewWithoutHandling;
this.settings.javaScriptEnabled = true;
@@ -43,7 +46,12 @@ class BrowserFragment : MainFragment() {
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack)
if(parameter is String) {
if(parameter is WebView) {
_root?.removeView(_webview);
_root?.addView(parameter);
_webview = parameter;
}
else if(parameter is String) {
_webview?.webViewClient = _webviewWithoutHandling;
_webview?.loadUrl(parameter);
}
@@ -1,8 +1,6 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -16,6 +14,8 @@ import com.futo.futopay.formatMoney
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StatePayment
import com.futo.platformplayer.views.overlays.LoaderOverlay
@@ -68,8 +68,11 @@ class BuyFragment : MainFragment() {
_paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, _, exception ->
if(success) {
UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0, UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY));
_fragment.close(true);
UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0,
UIDialogs.Action("Ok", {
(fragment.activity as? MainActivity)?.navigate<SettingsFragment>(withHistory = false);
}, UIDialogs.ActionStyle.PRIMARY)
);
}
else {
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.payment_failed), exception);
@@ -89,16 +92,19 @@ class BuyFragment : MainFragment() {
fragment.lifecycleScope.launch(Dispatchers.IO) {
//Calling this function will cache first call
try {
val currencies = StatePayment.instance.getAvailableCurrencies("grayjay");
val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay");
val country = StatePayment.instance.getPaymentCountryFromIP(true)?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } };
val currency = country?.let { c -> PaymentConfigurations.CURRENCIES.find { it.id == c.defaultCurrencyId && (currencies.contains(it.id)) } };
// TODO: Restore multi-currency support when payment backend supports it
// val currencies = StatePayment.instance.getAvailableCurrencies("grayjay");
// val prices = StatePayment.instance.getAvailableCurrencyPrices("grayjay");
// val country = StatePayment.instance.getPaymentCountryFromIP(true)?.let { c -> PaymentConfigurations.COUNTRIES.find { it.id.equals(c, ignoreCase = true) } };
// val currency = country?.let { c -> PaymentConfigurations.CURRENCIES.find { it.id == c.defaultCurrencyId && (currencies.contains(it.id)) } };
// if(currency != null && prices.containsKey(currency.id)) {
// val price = prices[currency.id]!!;
// _buttonBuyText.text = formatMoney(country.id, currency.id, price) + context.getString(R.string.plus_tax);
// }
if(currency != null && prices.containsKey(currency.id)) {
val price = prices[currency.id]!!;
withContext(Dispatchers.Main) {
_buttonBuyText.text = formatMoney(country.id, currency.id, price) + context.getString(R.string.plus_tax);
}
val priceCents = StatePayment.instance.getPolarProductPrice(PaymentConfigurations.PolarConfig.PRODUCT_SLUG)
withContext(Dispatchers.Main) {
_buttonBuyText.text = formatMoney("US", "usd", priceCents) + context.getString(R.string.plus_tax)
}
}
catch(ex: Throwable) {
@@ -165,4 +171,4 @@ class BuyFragment : MainFragment() {
fun newInstance() = BuyFragment().apply {}
private val TAG = "BuyFragment"
}
}
}
@@ -20,12 +20,14 @@ import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.widget.ViewPager2
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.contents.ContentType
@@ -55,6 +57,7 @@ import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.platformplayer.withTimestamp
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
@@ -198,8 +201,12 @@ class ChannelFragment : MainFragment() {
adapter.onContentClicked.subscribe { v, _ ->
when (v) {
is IPlatformVideo -> {
StatePlayer.instance.clearQueue()
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail()
//StatePlayer.instance.clearQueue()
if (StatePlayer.instance.hasQueue) {
StatePlayer.instance.insertToQueue(v, true);
} else {
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail();
}
}
is IPlatformPlaylist -> {
@@ -209,6 +216,10 @@ class ChannelFragment : MainFragment() {
is IPlatformPost -> {
fragment.navigate<PostDetailFragment>(v)
}
is IPlatformArticle -> {
fragment.navigate<ArticleDetailFragment>(v)
}
}
}
adapter.onShortClicked.subscribe { v, _, pagerPair ->
@@ -244,7 +255,7 @@ class ChannelFragment : MainFragment() {
adapter.onContentUrlClicked.subscribe { url, contentType ->
when (contentType) {
ContentType.MEDIA -> {
StatePlayer.instance.clearQueue()
StatePlayer.instance.clearQueue();
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
}
@@ -403,7 +414,7 @@ class ChannelFragment : MainFragment() {
_fragment.topBar?.onShown(channel)
val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) {
UIDialogs.showConfirmationDialog(context,
val dialog = UIDialogs.showConfirmationDialog(context,
context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist)
.replace("{channelName}", channel.name),
{
@@ -150,7 +150,7 @@ class DownloadsFragment : MainFragment() {
spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
};
val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc", "sizeAsc", "sizeDesc");
val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc", "sizeAsc", "sizeDesc", "typeAudio", "typeVideo");
spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) {
when(pos) {
@@ -162,6 +162,8 @@ class DownloadsFragment : MainFragment() {
5 -> ordering.setAndSave("releasedDesc")
6 -> ordering.setAndSave("sizeAsc")
7 -> ordering.setAndSave("sizeDesc")
8 -> ordering.setAndSave("typeAudio")
9 -> ordering.setAndSave("typeVideo")
else -> ordering.setAndSave("")
}
updateContentFilters()
@@ -261,6 +263,8 @@ class DownloadsFragment : MainFragment() {
"releasedDesc" -> vidsToReturn.sortedByDescending { it.datetime ?: OffsetDateTime.MIN }
"sizeAsc" -> vidsToReturn.sortedBy { it.videoSource.sumOf { it.fileSize } + it.audioSource.sumOf { it.fileSize } }
"sizeDesc" -> vidsToReturn.sortedByDescending { it.videoSource.sumOf { it.fileSize } + it.audioSource.sumOf { it.fileSize } }
"typeAudio" -> vidsToReturn.sortedBy { if (it.videoSource.isEmpty() && it.audioSource.isNotEmpty()) 0 else 1 }
"typeVideo" -> vidsToReturn.sortedBy { if (it.videoSource.isNotEmpty()) 0 else 1 }
else -> vidsToReturn
}
}
@@ -9,6 +9,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
@@ -39,21 +40,22 @@ import java.time.OffsetDateTime
import kotlin.math.max
abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : LinearLayout where TPager : IPager<TResult>, TViewHolder : RecyclerView.ViewHolder, TFragment : MainFragment {
protected val _feedRoot: FrameLayout;
protected val _feedRoot: ConstraintLayout;
protected val _recyclerResults: RecyclerView;
protected val _overlayContainer: FrameLayout;
protected val _swipeRefresh: SwipeRefreshLayout;
private val _progressBar: ProgressBar;
private val _spinnerSortBy: Spinner;
private val _containerSortBy: LinearLayout;
private val _announcementView: AnnouncementView;
//private val _announcementView: AnnouncementView;
private val _tagsView: TagsView;
private val _textCentered: TextView;
private val _emptyPagerContainer: FrameLayout;
protected val _toolbarContentView: LinearLayout;
protected val _bottomContentView: LinearLayout;
private var _loading: Boolean = true;
private var _loading: Boolean = false;
private val _pagerLock = Object();
private var _cache: ItemCache<TResult>? = null;
@@ -85,7 +87,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_textCentered = findViewById(R.id.text_centered);
_emptyPagerContainer = findViewById(R.id.empty_pager_container);
_progressBar = findViewById(R.id.progress_bar);
_announcementView = findViewById(R.id.announcement_view)
//_announcementView = findViewById(R.id.announcement_view)
_progressBar.inactiveColor = Color.TRANSPARENT;
_swipeRefresh = findViewById(R.id.swipe_refresh);
@@ -136,6 +138,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
setActiveTags(null);
_toolbarContentView = findViewById(R.id.container_toolbar_content);
_bottomContentView = findViewById(R.id.container_bottom);
_nextPageHandler = TaskHandler<TPager, Pair<TPager, List<TResult>>>({fragment.lifecycleScope}, {
if (it is IAsyncPager<*>)
@@ -177,10 +180,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
val visibleItemCount = _recyclerResults.childCount;
val firstVisibleItem = recyclerData.layoutManager.findFirstVisibleItemPosition()
//Logger.i(TAG, "onScrolled loadNextPage visibleItemCount=$visibleItemCount firstVisibleItem=$visibleItemCount")
//Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}")
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size && firstVisibleItem > 0) {
//Logger.i(TAG, "onScrolled loadNextPage(): firstVisibleItem=$firstVisibleItem visibleItemCount=$visibleItemCount visibleThreshold=$visibleThreshold recyclerData.results.size=${recyclerData.results.size}")
if (!_loading && firstVisibleItem + visibleItemCount + visibleThreshold >= recyclerData.results.size) {
loadNextPage();
}
}
@@ -190,61 +192,48 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
}
protected fun showAnnouncementView() {
_announcementView.visibility = View.VISIBLE
//_announcementView.visibility = View.VISIBLE
}
private fun ensureEnoughContentVisible(filteredResults: List<TConverted>) {
val canScroll = if (recyclerData.results.isEmpty()) false else {
val height = resources.displayMetrics.heightPixels;
_recyclerResults.post {
val canScroll = _recyclerResults.canScrollVertically(1)
Logger.i(
TAG,
"ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter"
)
if (!canScroll || filteredResults.isEmpty()) {
_automaticNextPageCounter++
if (_automaticNextPageCounter < _automaticBackoff.size) {
if (_automaticNextPageCounter > 0) {
val automaticNextPageCounterSaved = _automaticNextPageCounter;
fragment.lifecycleScope.launch(Dispatchers.Default) {
val backoff = _automaticBackoff[Math.min(
_automaticBackoff.size - 1,
_automaticNextPageCounter
)];
val layoutManager = recyclerData.layoutManager
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null;
val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition();
val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null;
val rows = if(recyclerData.layoutManager is GridLayoutManager) Math.max(1, recyclerData.results.size / recyclerData.layoutManager.spanCount) else 1;
val rowsHeight = (firstVisibleItemView?.height ?: 0) * rows;
if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) {
false;
}
else if (firstVisibleItemView != null && height != null && rowsHeight < height) {
false;
} else {
true;
}
}
Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter")
if (!canScroll || filteredResults.isEmpty()) {
_automaticNextPageCounter++
if(_automaticNextPageCounter < _automaticBackoff.size) {
if(_automaticNextPageCounter > 0) {
val automaticNextPageCounterSaved = _automaticNextPageCounter;
fragment.lifecycleScope.launch(Dispatchers.Default) {
val backoff = _automaticBackoff[Math.min(_automaticBackoff.size - 1, _automaticNextPageCounter)];
withContext(Dispatchers.Main) {
setLoading(true);
}
delay(backoff.toLong());
if(automaticNextPageCounterSaved == _automaticNextPageCounter) {
withContext(Dispatchers.Main) {
loadNextPage();
setLoading(true);
}
delay(backoff.toLong());
if (automaticNextPageCounterSaved == _automaticNextPageCounter) {
withContext(Dispatchers.Main) {
loadNextPage();
}
} else {
withContext(Dispatchers.Main) {
setLoading(false);
}
}
}
else {
withContext(Dispatchers.Main) {
setLoading(false);
}
}
}
} else
loadNextPage();
}
else
loadNextPage();
} else {
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
_automaticNextPageCounter = 0;
}
} else {
Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset");
_automaticNextPageCounter = 0;
}
}
fun resetAutomaticNextPageCounter(){
@@ -481,7 +470,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
recyclerData.resultsUnfiltered.addAll(toAdd);
recyclerData.adapter.notifyDataSetChanged();
recyclerData.loadedFeedStyle = feedStyle;
ensureEnoughContentVisible(filteredResults)
setLoading(false)
if(pager.hasMorePages())
ensureEnoughContentVisible(filteredResults)
}
private fun detachPagerEvents() {

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