Compare commits

...

153 Commits

Author SHA1 Message Date
Kelvin 7729681829 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-02-16 14:58:28 +01:00
Kelvin b12d04b27d Attempted fix for double controls 2024-02-16 14:58:17 +01:00
Koen e6608b9a5c Updated PolycentricAndroid. 2024-02-16 14:07:27 +01:00
Koen 2d503dfaf6 Added scroll to top. Full scrollable parent comment and Polycentric process secret backup and automatic database recovery. 2024-02-16 13:56:14 +01:00
Kelvin 08934ef8de Modify subscription groups in sub settings 2024-02-14 23:25:58 +01:00
Kelvin 62d927739a Sharing from overview options, notification channel names 2024-02-14 20:15:12 +01:00
Kelvin c8db8f58e8 Refs 2024-02-14 19:19:24 +01:00
Kelvin 0fc966a77d Subscription watched filter 2024-02-14 19:18:35 +01:00
Kelvin 9f6c6c8cf3 Fix support, fix membership urls 2024-01-23 23:51:21 +01:00
Kelvin 43a6ff138c Fix queue looping 2024-01-22 20:54:40 +01:00
Kelvin 269a3460e7 Fix live stream retrying 2024-01-22 15:52:51 +01:00
Koen 18150e9e15 Fixed bottom menu button visibility. 2024-01-19 20:29:00 +01:00
Koen 362c7f5b2c Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-19 20:24:35 +01:00
Koen 2adb8ad7f9 Fixed brightness not working. 2024-01-19 20:24:23 +01:00
Kelvin 6b5d4e7507 Fix nullable 2024-01-19 19:44:52 +01:00
Kelvin 49c82726f0 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-19 17:28:45 +01:00
Kelvin c8ddcda384 Refs, Dev portal improvements and on-device testing, Fix crashes on disabling v8 race conditions, edgecase where history could be null, issue on starting Grayjay with an url 2024-01-19 17:28:35 +01:00
Koen b75217f789 Possible fix for AudioNoisyReceiver popping up 'App is not responding'. 2024-01-19 17:02:24 +01:00
Koen 8ba8e535bd Added check for updates button on exception activity. 2024-01-19 16:13:42 +01:00
Koen e4c574db6b Fixed crash in updateAllButtonVisibility. 2024-01-19 15:38:22 +01:00
Koen fae73293d7 Fixed crash where it would fail to unregister audio noisy receiver. Fixed crash where system brightness setting does not exist. 2024-01-19 15:17:11 +01:00
Koen 3bd0aac4f8 Implemented system brightness in an alternative way. 2024-01-19 14:09:56 +01:00
Kelvin 26b822e04b Text edit 2024-01-17 17:23:38 +01:00
Kelvin 96b9b8843c Fix wrong visibility for no sources ui 2024-01-17 16:57:35 +01:00
Kelvin 6d9c1e17b5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-17 15:31:11 +01:00
Kelvin 507ad105c0 Hide warnings if empty, enable newly installed plugins, new browse plugin url 2024-01-17 15:31:05 +01:00
Koen 40a283017e Fixed issue where adding new playlist would require two back swipes to minimize video. 2024-01-17 15:26:09 +01:00
Kelvin be14597670 Merge 2024-01-17 13:28:54 +01:00
Kelvin 837609abb9 Remove primary client, remove play store default source, add additional flows for adding sources 2024-01-17 13:26:17 +01:00
Koen d64cd98b43 Removed most announcements. 2024-01-17 13:08:25 +01:00
Koen 0081ff1483 Removed playstore pre-installed PeerTube. 2024-01-17 12:54:45 +01:00
Kelvin f78ca6c7ed Toggle to disable update check for individual sources 2024-01-17 12:34:58 +01:00
Kelvin cfc7cbcaa4 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-17 12:15:26 +01:00
Kelvin e533eb7778 Video zoom increase tolerances 2024-01-17 12:15:17 +01:00
Koen 7c1d0a7f88 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-17 11:02:13 +01:00
Koen 01ef471708 Better handling of null or empty id/url for Polycentric comments/likes. 2024-01-17 11:02:03 +01:00
Kelvin 2fd0a9a41d Fix scroll downloaded playlists 2024-01-16 22:31:45 +01:00
Kelvin 635749dfe4 Open channel/playlist urls through search 2024-01-16 21:42:43 +01:00
Kelvin c4bd5626f3 Fix usable motionlayout on portrait fullscreen 2024-01-16 21:21:26 +01:00
Kelvin 568a0f6329 Catch any failed auth/captcha decodes 2024-01-16 21:11:11 +01:00
Kelvin 7ee67b5cd0 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-16 20:11:27 +01:00
Kelvin fc94c6903c Additional warnings for reinstall, disable uninstall if doesn't make sense, reduce maxheight to 40% of videoview 2024-01-16 20:11:23 +01:00
Koen a0af8805e7 Removed accidentally cmomitted code. 2024-01-16 20:09:02 +01:00
Koen 9b64cde17d Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-16 20:08:15 +01:00
Koen f6931bcf8c Added isUserGesturing boolean to gesture control view. 2024-01-16 20:07:26 +01:00
Kelvin a4ff47d863 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-16 16:46:40 +01:00
Kelvin 982d251126 Prevent video reload if same video, refs 2024-01-16 16:46:30 +01:00
Koen 8820a0ecc0 Fixed slide subscription overlay not closing on back gesture in video detail view. Fixed bottom margin for minimized view progress bar. 2024-01-16 13:25:12 +01:00
Koen b99a713ffc Added 'Add to new playlist' to add button when watching video. 2024-01-16 13:04:44 +01:00
Koen dfc8c4b740 Added snapping when only panning. 2024-01-16 12:24:07 +01:00
Koen c3df9e5259 Changed tolerances on zooming/panning snapping. 2024-01-16 12:20:04 +01:00
Koen b9c7e0a8ca Made snapping only work when panned close to center. 2024-01-16 12:05:04 +01:00
Koen 2c7f02a24d Added zoom pan snapping. 2024-01-16 11:01:00 +01:00
Koen 5cc8488d94 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-15 18:07:08 +01:00
Koen 6f7304f59c Added ability to click on a comment to view where the comment was placed and the ability to navigate upwards in the replies overlay by clicking the parent comment. 2024-01-15 18:06:57 +01:00
Kelvin ea4fea4401 Deduplication priorities fixed, playpause button change and wakelock on interruption fixed 2024-01-13 00:51:00 +01:00
Kelvin 9b48664de4 Resolving wakelock on play interuptions 2024-01-12 23:22:49 +01:00
Kelvin 8964dc68f0 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-12 21:34:49 +01:00
Kelvin 4711b8055b Empty home and install plugin flows. BrowserFragment optional url handling 2024-01-12 21:34:39 +01:00
Koen 84e3373fa7 Added settings to enable/disable zoom/pan gestures. 2024-01-11 15:48:58 +01:00
Koen fdd7e32dd8 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-11 12:59:18 +01:00
Koen e57119ebbd Added setting for restore system brightness and finished zoom pan controls. 2024-01-11 12:59:10 +01:00
Kelvin ed29dd8365 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-10 17:53:52 +01:00
Kelvin 196cacb452 Open playlist urls support, indexOutOfBound fix for queue when deleting items 2024-01-10 17:53:45 +01:00
Koen c025913fc8 Fixed tutorial video aspect ratio. 2024-01-10 13:13:25 +01:00
Koen 48b2c68e72 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-10 13:07:33 +01:00
Koen 689766a6ac Added monetization tutorial and added tutorial descriptions. 2024-01-10 13:07:22 +01:00
Kelvin 9306024d17 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-09 22:25:49 +01:00
Kelvin 195163840b DOMParser toNodeTree, Better subscription errors, Watch later ordering 2024-01-09 22:25:41 +01:00
Koen 788c54bf8f Fixed durations for castview. 2024-01-09 13:50:51 +01:00
Koen 031aabd523 Proper implementation of editor action. 2024-01-09 13:37:56 +01:00
Koen 85db4cc4e6 Theoretical fix for double controls. 2024-01-08 14:19:21 +01:00
Koen 745aad385b Gesture controls can individually be enabled/disabled and can be toggled to use system or non-system values. 2024-01-08 13:56:11 +01:00
Koen ba87261f9f Added settings to enable/disable gestures. 2024-01-08 12:30:04 +01:00
Koen 7d091382c0 Do not restart threads when started is false. 2024-01-07 21:56:56 +01:00
Koen 781d0797e7 More casting fixes. 2024-01-07 18:18:15 +01:00
Koen ec12a06b88 Reverted disconnect based on pong timer. 2024-01-07 17:05:35 +01:00
Koen bf3e8867c3 Synchronized writes. 2024-01-07 14:19:03 +01:00
Koen 176814a715 Crashfix on unreliable casting connection. Made casting more robust with intermittent TCP connections. 2024-01-07 13:41:43 +01:00
Koen 898637a616 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-06 23:07:50 +01:00
Koen f1860126a7 - Changed casting connection timeout to 2 seconds.
- Added ping pong to FCast.
- Removal of DataInputStream and just using raw InputStream.
- Fix slider position crash.
2024-01-06 23:07:34 +01:00
Kelvin f8402676d7 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-06 22:19:38 +01:00
Kelvin cf86ce1ab3 Minor leak fix, login warning support, refs 2024-01-06 22:19:29 +01:00
Koen f4cb1719e0 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-04 13:38:37 +01:00
Koen 4898cb53ae Fixed tint color. 2024-01-04 13:38:30 +01:00
Kelvin 0f60d4737e Plugin update appToast, Refs 2024-01-03 19:47:38 +01:00
Kelvin 0dc33e1f2b Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-03 17:59:11 +01:00
Kelvin d86a997a88 appToast system, VideoToOpen changed 2024-01-03 17:59:01 +01:00
Koen 34d4d92289 Casting stability fixes to ChromeCast connection thread. 2024-01-03 11:24:55 +01:00
Koen 4cb1bf268f Updated YouTube submodule. 2024-01-03 11:09:57 +01:00
Kelvin 8488706ff9 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-02 20:14:36 +01:00
Kelvin a348bb2662 Fix crash download livestream, refs 2024-01-02 20:14:28 +01:00
Koen 60a17b3c67 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-02 19:48:51 +01:00
Koen 386c58d4ad Added timeouts to casting flow. 2024-01-02 19:48:43 +01:00
Kelvin 356ba01dc1 Fix default comment section 2024-01-02 16:28:08 +01:00
Kelvin ed2aa848da Fix clear playback notification, Update V8 Library, Close modified requests after usage 2024-01-02 15:55:29 +01:00
Kelvin c5dd90048f Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2024-01-02 14:36:46 +01:00
Kelvin ab04f334dc Download cleanup after cancel/failure and on startup, auto-select subtitles if downloaded, refs 2024-01-02 14:36:41 +01:00
Koen 0d44f8a416 Fixed issue where some videos would not play in Odysee. 2024-01-02 13:36:43 +01:00
Koen d01a1545e2 Allow large heap to fix Rumble failing to load large videos. 2024-01-02 13:35:48 +01:00
Kelvin e599729ba1 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-27 15:21:52 +01:00
Kelvin 3ac043740e Fix RequestModifier not being applied, and add default option to add pre-existing headers 2023-12-27 15:21:43 +01:00
Koen 89603d0ff3 changed typeface when update is available. 2023-12-27 14:31:26 +01:00
Koen 05b6cd7c97 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-27 11:11:36 +01:00
Koen ea5aad0631 Implemented check for updates. 2023-12-27 11:11:29 +01:00
Kelvin 96e034b9bf Fix plugin.d.ts and update 2023-12-22 19:29:38 +01:00
Kelvin 6141c36855 Refs 2023-12-21 20:04:53 +01:00
Kelvin 4084ab3ed0 refs 2023-12-21 20:01:51 +01:00
Kelvin 34e733823a Refs 2023-12-21 19:27:13 +01:00
Kelvin f1d01642cd Refs, ui fixes 2023-12-21 19:24:30 +01:00
Kelvin d5551d7118 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-21 17:24:18 +01:00
Kelvin d079a1e8e4 Add missing channel name setter 2023-12-21 17:24:11 +01:00
Koen c06c00ee9b Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-21 17:19:17 +01:00
Koen 1d8eababc2 Minor casting dialog fixes. 2023-12-21 17:19:05 +01:00
Kelvin 75cf1ffbdd Offline playback fix with remote sources 2023-12-21 17:13:43 +01:00
Kelvin 5499706a9b Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-21 16:14:01 +01:00
Kelvin ba57e32920 Minor queue styling, refs 2023-12-21 16:13:53 +01:00
Koen df96c5b51c Fixed live video previews in preview layout. Fixed time bar spacing at bottom. 2023-12-21 16:07:03 +01:00
Koen 75f81d20db Fixed buttons in subscription groups and made select button click only work when there are things selected. 2023-12-21 13:10:38 +01:00
Koen 3fc92e4065 Fixed rounding of subscription groups. 2023-12-21 12:56:35 +01:00
Koen 8ffd5f411f Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-21 11:21:34 +01:00
Koen 918161a299 Fixed margins for subscription groups and menu bar icons now show filled variant if existing. 2023-12-21 11:20:53 +01:00
Kelvin 9f50f72eaa Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-21 11:12:45 +01:00
Kelvin 2f66f124aa Toast on cancel creation, default subgroup creator image, fix subgroup cache load 2023-12-21 11:12:34 +01:00
Koen 9a11717cf4 Fixed Rumble channel contents having empty author. Changed TutorialVideo so author is not clickable. 2023-12-21 10:20:52 +01:00
Kelvin 0d80424799 Optimized selection creator overlay, global subscription progress listener 2023-12-20 21:54:39 +01:00
Kelvin ed9a65b2f0 Filter cache results by enabled clients 2023-12-20 18:35:13 +01:00
Kelvin 8a53297be2 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-20 18:08:52 +01:00
Kelvin 20862a27c8 Search creator overlay, Fix crash Add topbar 2023-12-20 18:08:40 +01:00
Koen 95785e6c78 Added something more similar to Jdenticons. 2023-12-20 17:35:57 +01:00
Koen e88c649578 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-20 17:09:19 +01:00
Koen 09f91e64fb Fixed Kick subscription imports. 2023-12-20 17:04:10 +01:00
Koen b8923e59a1 Fixed bottom bar new tabs not showing up for people who changed tabs. 2023-12-20 17:03:48 +01:00
Kelvin e722c0ce9a Explore subscription groups button 2023-12-20 17:02:40 +01:00
Kelvin 56248bf4b0 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-20 12:36:12 +01:00
Kelvin 5af4787c45 Fix nested overlays in more buttons 2023-12-20 12:35:40 +01:00
Koen 0990247322 Fixed Rumble channel content fetching, channel image and show comments when sign in is not required. 2023-12-20 12:06:21 +01:00
Koen 0154525578 Revert "Fixed download button overlay not working in more button."
This reverts commit 1dc6eee242.
2023-12-20 12:02:35 +01:00
Koen 1dc6eee242 Fixed download button overlay not working in more button. 2023-12-20 11:51:28 +01:00
Koen c63a63cb33 Fixed Gesture control distances for portrait full screen. 2023-12-20 11:06:18 +01:00
Koen c1967556ac Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-20 10:56:28 +01:00
Koen 309a57f5a1 Added icon based on the system key when a polycentric user has not set a profile picture. Made managing Polycentric profile most robust. Fixed an issue where latest events were not always being shown in relation to Polycentric profiles. Added copyable (on long press) system key in Polycentric profile activity. Added tutorial videos tab and flow. 2023-12-20 10:56:16 +01:00
Kelvin ee0bc96e53 Support for initial skip chapters 2023-12-20 00:28:21 +01:00
Kelvin a4422fdd56 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-19 23:08:48 +01:00
Kelvin b7c4047f1d Progress bars subscription groups, Various notification permission requests, improved notification experience, Settings activity open to specific settings, refs 2023-12-19 23:08:38 +01:00
Koen 65174ffc97 Show connected controls as disabled when still connecting. 2023-12-19 11:46:40 +01:00
Koen eac3e37af5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-19 11:38:32 +01:00
Koen 0d5ad90ff9 Fixed duration format. Added tutorial fragment. Added dialog that asks you if you want to see the tutorials on the first app boot. Made casting when clicking start now open connected dialog. Fixed crashes related to sliders in casting connected control. Fixed history tab title. Removed old FCast encryption. 2023-12-19 11:38:22 +01:00
Kelvin f42b14e95a Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-18 23:37:27 +01:00
Kelvin b8acd0b5b2 Subscriptions fetching support for subgroups 2023-12-18 23:37:10 +01:00
Koen ef72561768 Added support for chapter skip in casting. 2023-12-18 16:28:35 +01:00
Koen d63627bd61 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-18 15:48:17 +01:00
Koen 422cceb225 Added support for FCast encrypted connection upgrade and added support for ensuring threads are restarted onResume for FCast and ChromeCAst. 2023-12-18 15:48:05 +01:00
195 changed files with 5735 additions and 1734 deletions
-3
View File
@@ -1,9 +1,6 @@
[submodule "dep/polycentricandroid"] [submodule "dep/polycentricandroid"]
path = dep/polycentricandroid path = dep/polycentricandroid
url = ../polycentricandroid.git url = ../polycentricandroid.git
[submodule "app/src/playstore/assets/sources/peertube"]
path = app/src/playstore/assets/sources/peertube
url = ../plugins/peertube.git
[submodule "app/src/stable/assets/sources/kick"] [submodule "app/src/stable/assets/sources/kick"]
path = app/src/stable/assets/sources/kick path = app/src/stable/assets/sources/kick
url = ../plugins/kick.git url = ../plugins/kick.git
+14 -14
View File
@@ -151,7 +151,7 @@ dependencies {
//Core //Core
implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.10.0' implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
//Images //Images
@@ -162,25 +162,25 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
//HTTP //HTTP
implementation "com.squareup.okhttp3:okhttp:4.11.0" implementation "com.squareup.okhttp3:okhttp:4.12.0"
//JSON //JSON
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2" //Used for structured json implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2" //Used for structured json
implementation 'com.google.code.gson:gson:2.10.1' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject) implementation 'com.google.code.gson:gson:2.10.1' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
//JS //JS
implementation("com.caoccao.javet:javet-android:2.2.1") implementation("com.caoccao.javet:javet-android:3.0.3")
//Exoplayer //Exoplayer
implementation 'androidx.media3:media3-exoplayer:1.2.0' implementation 'androidx.media3:media3-exoplayer:1.2.1'
implementation 'androidx.media3:media3-exoplayer-dash:1.2.0' implementation 'androidx.media3:media3-exoplayer-dash:1.2.1'
implementation 'androidx.media3:media3-ui:1.2.0' implementation 'androidx.media3:media3-ui:1.2.1'
implementation 'androidx.media3:media3-exoplayer-hls:1.2.0' implementation 'androidx.media3:media3-exoplayer-hls:1.2.1'
implementation 'androidx.media3:media3-exoplayer-rtsp:1.2.0' implementation 'androidx.media3:media3-exoplayer-rtsp:1.2.1'
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.2.0' implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.2.1'
implementation 'androidx.media3:media3-transformer:1.2.0' implementation 'androidx.media3:media3-transformer:1.2.1'
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.5' implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7'
implementation 'androidx.navigation:navigation-ui-ktx:2.7.5' implementation 'androidx.navigation:navigation-ui-ktx:2.7.7'
implementation 'androidx.media:media:1.7.0' implementation 'androidx.media:media:1.7.0'
//Other //Other
@@ -189,7 +189,7 @@ dependencies {
implementation 'com.google.android.flexbox:flexbox:3.0.0' implementation 'com.google.android.flexbox:flexbox:3.0.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'com.arthenica:ffmpeg-kit-full:5.1' implementation 'com.arthenica:ffmpeg-kit-full:5.1'
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0' implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.21'
implementation 'com.github.dhaval2404:imagepicker:2.1' implementation 'com.github.dhaval2404:imagepicker:2.1'
implementation 'com.google.zxing:core:3.4.1' implementation 'com.google.zxing:core:3.4.1'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0' implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
@@ -214,7 +214,7 @@ dependencies {
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
testImplementation "org.jetbrains.kotlin:kotlin-test:1.8.22" testImplementation "org.jetbrains.kotlin:kotlin-test:1.9.21"
testImplementation "org.xmlunit:xmlunit-core:2.9.1" testImplementation "org.xmlunit:xmlunit-core:2.9.1"
testImplementation "org.mockito:mockito-core:5.4.0" testImplementation "org.mockito:mockito-core:5.4.0"
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.1.5'
@@ -0,0 +1,111 @@
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)
}
}
+3 -1
View File
@@ -13,6 +13,7 @@
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -24,7 +25,8 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.FutoVideo" android:theme="@style/Theme.FutoVideo"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
tools:targetApi="31"> tools:targetApi="31"
android:largeHeap="true">
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="@string/authority" android:authorities="@string/authority"
@@ -233,6 +233,9 @@ function pluginRemoteProp(objID, propName) {
function pluginRemoteCall(objID, methodName, args) { function pluginRemoteCall(objID, methodName, args) {
return JSON.parse(syncPOST("/plugin/remoteCall?id=" + objID + "&method=" + methodName, {}, JSON.stringify(args))); return JSON.parse(syncPOST("/plugin/remoteCall?id=" + objID + "&method=" + methodName, {}, JSON.stringify(args)));
} }
function pluginRemoteTest(methodName, args) {
return JSON.parse(syncPOST("/plugin/remoteTest?method=" + methodName, {}, JSON.stringify(args)));
}
function pluginIsLoggedIn(cb, err) { function pluginIsLoggedIn(cb, err) {
fetch("/plugin/isLoggedIn", { fetch("/plugin/isLoggedIn", {
+53 -2
View File
@@ -385,8 +385,8 @@
</v-card-text> </v-card-text>
</v-card> </v-card>
<div style="width: 50%" v-if="Plugin.currentPlugin"> <div style="width: 50%" v-if="Plugin.currentPlugin">
<!--Get Home--> <v-text-field v-model="searchTestMethods" label="Search for source methods.." style="margin-left: 35px; margin-right: 35px;"></v-text-field>
<v-card class="requestCard" v-for="req in Testing.requests"> <v-card class="requestCard" v-for="req in Testing.requests" v-show="req.title.indexOf(searchTestMethods) >= 0">
<v-card-text> <v-card-text>
<div class="title"> <div class="title">
<span v-if="req.isOptional">(Optional)</span> <span v-if="req.isOptional">(Optional)</span>
@@ -416,6 +416,9 @@
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn @click="testSourceRemotely(req)">
Test Android
</v-btn>
<v-btn @click="testSource(req)"> <v-btn @click="testSource(req)">
Test Test
</v-btn> </v-btn>
@@ -545,6 +548,7 @@
new Vue({ new Vue({
el: '#app', el: '#app',
data: { data: {
searchTestMethods: "",
page: "Plugin", page: "Plugin",
pastPluginUrls: [], pastPluginUrls: [],
settings: {}, settings: {},
@@ -860,6 +864,53 @@
"Error: " + ex; "Error: " + ex;
} }
}, },
testSourceRemotely(req) {
const name = req.title;
const parameterVals = req.parameters.map(x=>{
if(x.value && x.value.startsWith && x.value.startsWith("json:"))
return JSON.parse(x.value.substring(5));
return x.value
});
if(name == "enable") {
if(parameterVals.length > 0)
parameterVals[0] = this.Plugin.currentPlugin;
else
parameterVals.push(this.Plugin.currentPlugin);
if(parameterVals.length > 1)
parameterVals[1] = __DEV_SETTINGS;
else
parameterVals.push(__DEV_SETTINGS);
}
const func = source[name];
if(!func)
alert("Test func not found");
try {
const remoteResult = pluginRemoteTest(name, parameterVals);
console.log("Result for " + req.title, remoteResult);
this.Testing.lastResult = "//Results [" + name + "]\n" +
JSON.stringify(remoteResult, null, 3);
this.Testing.lastResultError = "";
}
catch(ex) {
if(ex.plugin_type == "CaptchaRequiredException") {
let shouldCaptcha = confirm("Do you want to request captcha?");
if(shouldCaptcha) {
pluginCaptchaTestPlugin(ex.url, ex.body);
}
}
console.error("Failed to run test for " + req.title, ex);
this.Testing.lastResult = ""
if(ex.message)
this.Testing.lastResultError = "//Results [" + name + "]\n\n" +
"Error: " + ex.message + "\n\n" + ex.stack;
else
this.Testing.lastResultError = "//Results [" + name + "]\n\n" +
"Error: " + ex;
}
},
showTestResults(results) { showTestResults(results) {
}, },
+120 -46
View File
@@ -1,13 +1,37 @@
declare class ScriptException extends Error { declare class ScriptException extends Error {
//If only one parameter is provided, acts as msg
constructor(type: string, msg: string); constructor(type: string, msg: string);
} }
declare class TimeoutException extends ScriptException {
declare class LoginRequiredException extends ScriptException {
constructor(msg: string); constructor(msg: string);
} }
//Alias
declare class ScriptLoginRequiredException extends ScriptException {
constructor(msg: string);
}
declare class CaptchaRequiredException extends ScriptException {
constructor(url: string, body: string);
}
declare class CriticalException extends ScriptException {
constructor(msg: string);
}
declare class UnavailableException extends ScriptException { declare class UnavailableException extends ScriptException {
constructor(msg: string); constructor(msg: string);
} }
declare class AgeException extends ScriptException {
constructor(msg: string);
}
declare class TimeoutException extends ScriptException {
constructor(msg: string);
}
declare class ScriptImplementationException extends ScriptException { declare class ScriptImplementationException extends ScriptException {
constructor(msg: string); constructor(msg: string);
} }
@@ -38,16 +62,23 @@ declare class FilterCapability {
declare class PlatformAuthorLink { declare class PlatformAuthorLink {
constructor(id: PlatformID, name: string, url: string, thumbnail: string, subscribers: integer?); constructor(id: PlatformID, name: string, url: string, thumbnail: string, subscribers: integer?, membershipUrl: string?);
}
declare class PlatformAuthorMembershipLink {
constructor(id: PlatformID, name: string, url: string, thumbnail: string, subscribers: integer?, membershipUrl: string?);
} }
declare interface PlatformContentDef { declare interface PlatformContentDef {
id: PlatformID, id: PlatformID,
name: string, name: string,
thumbnails: Thumbnails,
author: PlatformAuthorLink, author: PlatformAuthorLink,
datetime: integer, datetime: integer,
url: string url: string
} }
declare interface PlatformContent {}
declare interface PlatformNestedMediaContentDef extends PlatformContentDef { declare interface PlatformNestedMediaContentDef extends PlatformContentDef {
contentUrl: string, contentUrl: string,
contentName: string?, contentName: string?,
@@ -59,16 +90,26 @@ declare class PlatformNestedMediaContent {
constructor(obj: PlatformNestedMediaContentDef); constructor(obj: PlatformNestedMediaContentDef);
} }
declare interface PlatformLockedContentDef extends PlatformContentDef {
contentName: string?,
contentThumbnails: Thumbnails?,
unlockUrl: string,
lockDescription: string?,
}
declare class PlatformLockedContent {
constructor(obj: PlatformLockedContentDef);
}
declare interface PlatformVideoDef extends PlatformContentDef { declare interface PlatformVideoDef extends PlatformContentDef {
thumbnails: Thumbnails, thumbnails: Thumbnails,
author: PlatformAuthorLink, author: PlatformAuthorLink,
duration: int, duration: int,
viewCount: long, viewCount: long,
isLive: boolean isLive: boolean,
shareUrl: string?
} }
declare interface PlatformContent {}
declare class PlatformVideo implements PlatformContent { declare class PlatformVideo implements PlatformContent {
constructor(obj: PlatformVideoDef); constructor(obj: PlatformVideoDef);
} }
@@ -77,14 +118,15 @@ declare class PlatformVideo implements PlatformContent {
declare interface PlatformVideoDetailsDef extends PlatformVideoDef { declare interface PlatformVideoDetailsDef extends PlatformVideoDef {
description: string, description: string,
video: VideoSourceDescriptor, video: VideoSourceDescriptor,
live: SubtitleSource[], live: IVideoSource,
rating: IRating rating: IRating,
subtitles: SubtitleSource[]
} }
declare class PlatformVideoDetails extends PlatformVideo { declare class PlatformVideoDetails extends PlatformVideo {
constructor(obj: PlatformVideoDetailsDef); constructor(obj: PlatformVideoDetailsDef);
} }
declare class PlatformPostDef extends PlatformContentDef { declare interface PlatformPostDef extends PlatformContentDef {
thumbnails: string[], thumbnails: string[],
images: string[], images: string[],
description: string description: string
@@ -93,7 +135,7 @@ declare class PlatformPost extends PlatformContent {
constructor(obj: PlatformPostDef) constructor(obj: PlatformPostDef)
} }
declare class PlatformPostDetailsDef extends PlatformPostDef { declare interface PlatformPostDetailsDef extends PlatformPostDef {
rating: IRating, rating: IRating,
textType: int, textType: int,
content: String content: String
@@ -110,8 +152,8 @@ declare interface MuxVideoSourceDescriptorDef {
isUnMuxed: boolean, isUnMuxed: boolean,
videoSources: VideoSource[] videoSources: VideoSource[]
} }
declare class MuxVideoSourceDescriptor implements IVideoSourceDescriptor { declare class VideoSourceDescriptor implements IVideoSourceDescriptor {
constructor(obj: VideoSourceDescriptorDef); constructor(videoSourcesOrObj: VideoSource[]);
} }
declare interface UnMuxVideoSourceDescriptorDef { declare interface UnMuxVideoSourceDescriptorDef {
@@ -129,7 +171,7 @@ declare interface IVideoSource {
declare interface IAudioSource { declare interface IAudioSource {
} }
interface VideoUrlSourceDef implements IVideoSource { declare interface VideoUrlSourceDef implements IVideoSource {
width: integer, width: integer,
height: integer, height: integer,
container: string, container: string,
@@ -139,22 +181,22 @@ interface VideoUrlSourceDef implements IVideoSource {
duration: integer, duration: integer,
url: string url: string
} }
class VideoUrlSource { declare class VideoUrlSource {
constructor(obj: VideoUrlSourceDef); constructor(obj: VideoUrlSourceDef);
getRequestModifier(): RequestModifier?; getRequestModifier(): RequestModifier?;
} }
interface VideoUrlRangeSourceDef extends VideoUrlSource { declare interface VideoUrlRangeSourceDef extends VideoUrlSource {
itagId: integer, itagId: integer,
initStart: integer, initStart: integer,
initEnd: integer, initEnd: integer,
indexStart: integer, indexStart: integer,
indexEnd: integer, indexEnd: integer,
} }
class VideoUrlRangeSource extends VideoUrlSource { declare class VideoUrlRangeSource extends VideoUrlSource {
constructor(obj: YTVideoSourceDef); constructor(obj: YTVideoSourceDef);
} }
interface AudioUrlSourceDef { declare interface AudioUrlSourceDef {
name: string, name: string,
bitrate: integer, bitrate: integer,
container: string, container: string,
@@ -163,24 +205,12 @@ interface AudioUrlSourceDef {
url: string, url: string,
language: string language: string
} }
class AudioUrlSource implements IAudioSource { declare class AudioUrlSource implements IAudioSource {
constructor(obj: AudioUrlSourceDef); constructor(obj: AudioUrlSourceDef);
getRequestModifier(): RequestModifier?; getRequestModifier(): RequestModifier?;
} }
interface IRequest { declare interface AudioUrlRangeSourceDef extends AudioUrlSource {
url: string,
headers: Map<string, string>
}
interface IRequestModifierDef {
allowByteSkip: boolean
}
class RequestModifier {
constructor(obj: IRequestModifierDef) { }
modifyRequest(url: string, headers: Map<string, string>): IRequest;
}
interface AudioUrlRangeSourceDef extends AudioUrlSource {
itagId: integer, itagId: integer,
initStart: integer, initStart: integer,
initEnd: integer, initEnd: integer,
@@ -188,28 +218,44 @@ interface AudioUrlRangeSourceDef extends AudioUrlSource {
indexEnd: integer, indexEnd: integer,
audioChannels: integer audioChannels: integer
} }
class AudioUrlRangeSource extends AudioUrlSource { declare class AudioUrlRangeSource extends AudioUrlSource {
constructor(obj: AudioUrlRangeSourceDef); constructor(obj: AudioUrlRangeSourceDef);
} }
interface HLSSourceDef { declare interface HLSSourceDef {
name: string, name: string,
duration: integer, duration: integer,
url: string url: string,
priority: boolean?,
language: string?
} }
class HLSSource implements IVideoSource { declare class HLSSource implements IVideoSource {
constructor(obj: HLSSourceDef); constructor(obj: HLSSourceDef);
} }
interface DashSourceDef { declare interface DashSourceDef {
name: string, name: string,
duration: integer, duration: integer,
url: string url: string,
language: string?
} }
class DashSource implements IVideoSource { declare class DashSource implements IVideoSource {
constructor(obj: DashSourceDef) constructor(obj: DashSourceDef)
} }
declare interface IRequest {
url: string,
headers: Map<string, string>
}
declare interface IRequestModifierDef {
allowByteSkip: boolean
}
declare class RequestModifier {
constructor(obj: IRequestModifierDef) { }
modifyRequest(url: string, headers: Map<string, string>): IRequest;
}
//Channel //Channel
interface PlatformChannelDef { declare interface PlatformChannelDef {
id: PlatformID, id: PlatformID,
name: string, name: string,
thumbnail: string, thumbnail: string,
@@ -217,12 +263,29 @@ interface PlatformChannelDef {
subscribers: integer, subscribers: integer,
description: string, description: string,
url: string, url: string,
urlAlternatives: string[],
links: Map<string>? links: Map<string>?
} }
class PlatformChannel { declare class PlatformChannel {
constructor(obj: PlatformChannelDef); constructor(obj: PlatformChannelDef);
} }
//Playlist
declare interface PlatformPlaylistDef implements PlatformContent {
videoCount: integer,
thumbnail: string
}
declare class PlatformPlaylist extends PlatformContent {
constructor(obj: PlatformPlaylistDef);
}
declare interface PlatformPlaylistDetailsDef implements PlatformPlaylistDef {
contents: ContentPager
}
declare class PlatformPlaylistDetails extends PlatformContent {
constructor(obj: PlatformPlaylistDetailsDef);
}
//Ratings //Ratings
interface IRating { interface IRating {
type: integer type: integer
@@ -250,7 +313,11 @@ declare class PlatformComment {
constructor(obj: CommentDef); constructor(obj: CommentDef);
} }
declare class PlaybackTracker {
constructor(interval: integer);
setProgress(seconds: integer);
}
declare class LiveEventPager { declare class LiveEventPager {
nextRequest = 4000; nextRequest = 4000;
@@ -261,8 +328,8 @@ declare class LiveEventPager {
nextPage(): LiveEventPager; //Could be self nextPage(): LiveEventPager; //Could be self
} }
class LiveEvent { declare class LiveEvent {
type: String constructor(type: integer);
} }
declare class LiveEventComment extends LiveEvent { declare class LiveEventComment extends LiveEvent {
constructor(name: string, message: string, thumbnail: string?, colorName: string?, badges: string[]); constructor(name: string, message: string, thumbnail: string?, colorName: string?, badges: string[]);
@@ -287,25 +354,31 @@ declare class ContentPager {
constructor(results: PlatformContent[], hasMore: boolean); constructor(results: PlatformContent[], hasMore: boolean);
hasMorePagers(): boolean hasMorePagers(): boolean
nextPage(): VideoPager; //Could be self nextPage(): ContentPager?; //Could be self
} }
declare class VideoPager { declare class VideoPager {
constructor(results: PlatformVideo[], hasMore: boolean); constructor(results: PlatformVideo[], hasMore: boolean);
hasMorePagers(): boolean hasMorePagers(): boolean
nextPage(): VideoPager; //Could be self nextPage(): VideoPager?; //Could be self
} }
declare class ChannelPager { declare class ChannelPager {
constructor(results: PlatformChannel[], hasMore: boolean); constructor(results: PlatformChannel[], hasMore: boolean);
hasMorePagers(): boolean; hasMorePagers(): boolean;
nextPage(): ChannelPager; //Could be self nextPage(): ChannelPager?; //Could be self
}
declare class PlaylistPager {
constructor(results: PlatformPlaylist[], hasMore: boolean);
hasMorePagers(): boolean;
nextPage(): PlaylistPager?;
} }
declare class CommentPager { declare class CommentPager {
constructor(results: PlatformComment[], hasMore: boolean); constructor(results: PlatformComment[], hasMore: boolean);
hasMorePagers(): boolean hasMorePagers(): boolean
nextPage(): CommentPager; //Could be self nextPage(): CommentPager?; //Could be self
} }
interface Map<T> { interface Map<T> {
@@ -341,8 +414,9 @@ interface Source {
getChannelCapabilities(): ResultCapabilities; getChannelCapabilities(): ResultCapabilities;
isContentDetailsUrl(url: string): boolean; isContentDetailsUrl(url: string): boolean;
getContentDetails(url: string): PlatformVideoDetails; getContentDetails(url: string): PlatformContentDetails;
//Optional
getLiveEvents(url: string): LiveEventPager; getLiveEvents(url: string): LiveEventPager;
//Optional //Optional
+18 -4
View File
@@ -37,7 +37,8 @@ let Type = {
NORMAL: 0, NORMAL: 0,
SKIPPABLE: 5, SKIPPABLE: 5,
SKIP: 6 SKIP: 6,
SKIPONCE: 7
} }
}; };
@@ -77,6 +78,11 @@ class ScriptLoginRequiredException extends ScriptException {
super("ScriptLoginRequiredException", msg); super("ScriptLoginRequiredException", msg);
} }
} }
class LoginRequiredException extends ScriptException {
constructor(msg) {
super("ScriptLoginRequiredException", msg);
}
}
class CaptchaRequiredException extends Error { class CaptchaRequiredException extends Error {
constructor(url, body) { constructor(url, body) {
super(JSON.stringify({ 'plugin_type': 'CaptchaRequiredException', url, body })); super(JSON.stringify({ 'plugin_type': 'CaptchaRequiredException', url, body }));
@@ -248,8 +254,8 @@ class PlatformVideoDetails extends PlatformVideo {
this.description = obj.description ?? "";//String this.description = obj.description ?? "";//String
this.video = obj.video ?? {}; //VideoSourceDescriptor this.video = obj.video ?? {}; //VideoSourceDescriptor
this.dash = obj.dash ?? null; //DashSource this.dash = obj.dash ?? null; //DashSource, deprecated
this.hls = obj.hls ?? null; //HLSSource this.hls = obj.hls ?? null; //HLSSource, deprecated
this.live = obj.live ?? null; //VideoSource this.live = obj.live ?? null; //VideoSource
this.rating = obj.rating ?? null; //IRating this.rating = obj.rating ?? null; //IRating
@@ -320,6 +326,8 @@ class VideoUrlSource {
this.bitrate = obj.bitrate ?? 0; this.bitrate = obj.bitrate ?? 0;
this.duration = obj.duration ?? 0; this.duration = obj.duration ?? 0;
this.url = obj.url; this.url = obj.url;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
} }
} }
class VideoUrlRangeSource extends VideoUrlSource { class VideoUrlRangeSource extends VideoUrlSource {
@@ -345,6 +353,8 @@ class AudioUrlSource {
this.duration = obj.duration ?? 0; this.duration = obj.duration ?? 0;
this.url = obj.url; this.url = obj.url;
this.language = obj.language ?? Language.UNKNOWN; this.language = obj.language ?? Language.UNKNOWN;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
} }
} }
class AudioUrlRangeSource extends AudioUrlSource { class AudioUrlRangeSource extends AudioUrlSource {
@@ -370,6 +380,8 @@ class HLSSource {
this.priority = obj.priority ?? false; this.priority = obj.priority ?? false;
if(obj.language) if(obj.language)
this.language = obj.language; this.language = obj.language;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
} }
} }
class DashSource { class DashSource {
@@ -381,13 +393,15 @@ class DashSource {
this.url = obj.url; this.url = obj.url;
if(obj.language) if(obj.language)
this.language = obj.language; this.language = obj.language;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
} }
} }
class RequestModifier { class RequestModifier {
constructor(obj) { constructor(obj) {
obj = obj ?? {}; obj = obj ?? {};
this.allowByteSkip = obj.allowByteSkip; this.allowByteSkip = obj.allowByteSkip; //Kinda deprecated.. wip
} }
} }
@@ -232,7 +232,11 @@ fun Long.formatDuration(): String {
val minutes = (this % 3600000) / 60000 val minutes = (this % 3600000) / 60000
val seconds = (this % 60000) / 1000 val seconds = (this % 60000) / 1000
return String.format("%02d:%02d:%02d", hours, minutes, seconds) return if (hours > 0) {
String.format("%02d:%02d:%02d", hours, minutes, seconds)
} else {
String.format("%02d:%02d", minutes, seconds)
}
} }
fun String.fixHtmlLinks(): Spanned { fun String.fixHtmlLinks(): Spanned {
@@ -1,5 +1,6 @@
package com.futo.platformplayer package com.futo.platformplayer
import android.util.Log
import com.google.common.base.CharMatcher import com.google.common.base.CharMatcher
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.IOException import java.io.IOException
@@ -9,7 +10,6 @@ import java.net.InetAddress
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.charset.Charset
private const val IPV4_PART_COUNT = 4; private const val IPV4_PART_COUNT = 4;
@@ -216,15 +216,20 @@ private fun ByteArray.toInetAddress(): InetAddress {
} }
fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? { fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
val timeout = 2000
if (addresses.isEmpty()) { if (addresses.isEmpty()) {
return null; return null;
} }
if (addresses.size == 1) { if (addresses.size == 1) {
val socket = Socket()
try { try {
return Socket(addresses[0], port); return socket.apply { this.connect(InetSocketAddress(addresses[0], port), timeout) }
} catch (e: Throwable) { } catch (e: Throwable) {
//Ignored. Log.i("getConnectedSocket", "Failed to connect to: ${addresses[0]}", e)
socket.close()
} }
return null; return null;
@@ -249,7 +254,7 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
} }
} }
socket.connect(InetSocketAddress(address, port)); socket.connect(InetSocketAddress(address, port), timeout);
synchronized(syncObject) { synchronized(syncObject) {
if (connectedSocket == null) { if (connectedSocket == null) {
@@ -263,7 +268,7 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
} }
} }
} catch (e: Throwable) { } catch (e: Throwable) {
//Ignore Log.i("getConnectedSocket", "Failed to connect to: $address", e)
} }
}; };
@@ -1,11 +1,13 @@
package com.futo.platformplayer package com.futo.platformplayer
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.AnnouncementType import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.StateAnnouncement import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.adapters.CommentViewHolder
import com.futo.polycentric.core.ProcessHandle import com.futo.polycentric.core.ProcessHandle
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState
import userpackage.Protocol import userpackage.Protocol
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.min import kotlin.math.min
@@ -47,6 +49,15 @@ fun Protocol.Claim.resolveChannelUrls(): List<String> {
} }
suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() { suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() {
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(system))
if (!systemState.servers.contains(PolycentricCache.STAGING_SERVER)) {
removeServer(PolycentricCache.STAGING_SERVER)
}
if (!systemState.servers.contains(PolycentricCache.SERVER)) {
removeServer(PolycentricCache.SERVER)
}
val exceptions = fullyBackfillServers() val exceptions = fullyBackfillServers()
for (pair in exceptions) { for (pair in exceptions) {
val server = pair.key val server = pair.key
@@ -277,7 +277,7 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 9) @FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 9)
var fetchOnTabOpen: Boolean = true; var fetchOnTabOpen: Boolean = true;
@FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 10) @FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 10, "background_update")
@DropdownFieldOptionsId(R.array.background_interval) @DropdownFieldOptionsId(R.array.background_interval)
var subscriptionsBackgroundUpdateInterval: Int = 0; var subscriptionsBackgroundUpdateInterval: Int = 0;
@@ -685,7 +685,9 @@ class Settings : FragmentedStorageFileJson() {
fun manualCheck() { fun manualCheck() {
if (!BuildConfig.IS_PLAYSTORE_BUILD) { if (!BuildConfig.IS_PLAYSTORE_BUILD) {
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
StateUpdate.instance.checkForUpdates(it, true); StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
StateUpdate.instance.checkForUpdates(it, true)
}
} }
} else { } else {
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
@@ -807,7 +809,36 @@ class Settings : FragmentedStorageFileJson() {
var polycentricEnabled: Boolean = true; var polycentricEnabled: Boolean = true;
} }
@FormField(R.string.info, FieldForm.GROUP, -1, 19) @FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
var gestureControls = GestureControls();
@Serializable
class GestureControls {
@FormField(R.string.volume_slider, FieldForm.TOGGLE, R.string.volume_slider_descr, 1)
var volumeSlider: Boolean = true;
@FormField(R.string.brightness_slider, FieldForm.TOGGLE, R.string.brightness_slider_descr, 2)
var brightnessSlider: Boolean = true;
@FormField(R.string.toggle_full_screen, FieldForm.TOGGLE, R.string.toggle_full_screen_descr, 3)
var toggleFullscreen: Boolean = true;
@FormField(R.string.system_brightness, FieldForm.TOGGLE, R.string.system_brightness_descr, 4)
var useSystemBrightness: Boolean = false;
@FormField(R.string.system_volume, FieldForm.TOGGLE, R.string.system_volume_descr, 5)
var useSystemVolume: Boolean = true;
@FormField(R.string.restore_system_brightness, FieldForm.TOGGLE, R.string.restore_system_brightness_descr, 6)
var restoreSystemBrightness: Boolean = true;
@FormField(R.string.zoom_option, FieldForm.TOGGLE, R.string.zoom_option_descr, 7)
var zoom: Boolean = true;
@FormField(R.string.pan_option, FieldForm.TOGGLE, R.string.pan_option_descr, 8)
var pan: Boolean = true;
}
@FormField(R.string.info, FieldForm.GROUP, -1, 20)
var info = Info(); var info = Info();
@Serializable @Serializable
class Info { class Info {
@@ -37,6 +37,7 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -303,12 +304,16 @@ class UIDialogs {
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction) showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
} }
fun showUpdateAvailableDialog(context: Context, lastVersion: Int) { fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
val dialog = AutoUpdateDialog(context); val dialog = AutoUpdateDialog(context);
registerDialogOpened(dialog); registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) }; dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show(); dialog.show();
dialog.setMaxVersion(lastVersion); dialog.setMaxVersion(lastVersion);
if (hideExceptionButtons) {
dialog.hideExceptionButtons()
}
} }
fun showChangelogDialog(context: Context, lastVersion: Int) { fun showChangelogDialog(context: Context, lastVersion: Int) {
@@ -398,13 +403,28 @@ class UIDialogs {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
try { try {
StateApp.withContext { StateApp.withContext {
Toast.makeText(it, text, if (long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT).show(); toast(it, text, long);
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to show toast.", e); Logger.e(TAG, "Failed to show toast.", e);
} }
} }
} }
fun appToast(text: String, long: Boolean = false) {
appToast(ToastView.Toast(text, long))
}
fun appToastError(text: String, long: Boolean) {
StateApp.withContext {
appToast(ToastView.Toast(text, long, it.getColor(R.color.pastel_red)));
};
}
fun appToast(toast: ToastView.Toast) {
StateApp.withContext {
if(it is MainActivity) {
it.showAppToast(toast);
}
}
}
fun showClickableToast(context: Context, text: String, onClick: () -> Unit, isLongDuration: Boolean = false) { fun showClickableToast(context: Context, text: String, onClick: () -> Unit, isLongDuration: Boolean = false) {
//TODO: Is not actually clickable... //TODO: Is not actually clickable...
@@ -1,9 +1,14 @@
package com.futo.platformplayer package com.futo.platformplayer
import android.app.NotificationManager
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
@@ -33,11 +38,17 @@ import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StateSubscriptionGroups
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.LoaderView import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.adapters.viewholders.SubscriptionGroupBarViewHolder
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuFilters import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuFilters
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuRecycler
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput
import com.futo.platformplayer.views.pills.RoundButton import com.futo.platformplayer.views.pills.RoundButton
import com.futo.platformplayer.views.pills.RoundButtonGroup import com.futo.platformplayer.views.pills.RoundButtonGroup
@@ -63,7 +74,7 @@ class UISlideOverlays {
return menu; return menu;
} }
fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup) { fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup): SlideUpMenuOverlay {
val items = arrayListOf<View>(); val items = arrayListOf<View>();
val originalNotif = subscription.doNotifications; val originalNotif = subscription.doNotifications;
@@ -72,20 +83,48 @@ class UISlideOverlays {
val originalVideo = subscription.doFetchVideos; val originalVideo = subscription.doFetchVideos;
val originalPosts = subscription.doFetchPosts; val originalPosts = subscription.doFetchPosts;
val menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, listOf());
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){ StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url); val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
val capabilities = plugin.getChannelCapabilities(); val capabilities = plugin.getChannelCapabilities();
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
var menu: SlideUpMenuOverlay? = null;
items.addAll(listOf( items.addAll(listOf(
SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", { SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", {
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications; subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
}, false), }, false),
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
SlideUpMenuGroup(container.context, "Subscription Groups",
"You can select which groups this subscription is part of.",
-1, listOf()) else null,
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
SlideUpMenuRecycler(container.context, "as") {
val groups = ArrayList<SubscriptionGroup>(StateSubscriptionGroups.instance.getSubscriptionGroups()
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
.sortedBy { !it.selected });
var adapter: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>? = null;
adapter = it.asAny(groups, RecyclerView.HORIZONTAL) {
it.onClick.subscribe {
if(it is SubscriptionGroup.Selectable) {
val actualGroup = StateSubscriptionGroups.instance.getSubscriptionGroup(it.id)
?: return@subscribe;
groups.clear();
if(it.selected)
actualGroup.urls.remove(subscription.channel.url);
else
actualGroup.urls.add(subscription.channel.url);
StateSubscriptionGroups.instance.updateSubscriptionGroup(actualGroup);
groups.addAll(StateSubscriptionGroups.instance.getSubscriptionGroups()
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
.sortedBy { !it.selected });
adapter?.notifyContentChanged();
}
}
};
return@SlideUpMenuRecycler adapter;
} else null,
SlideUpMenuGroup(container.context, "Fetch Settings", SlideUpMenuGroup(container.context, "Fetch Settings",
"Depending on the platform you might not need to enable a type for it to be available.", "Depending on the platform you might not need to enable a type for it to be available.",
-1, listOf()), -1, listOf()),
@@ -114,7 +153,7 @@ class UISlideOverlays {
}, false)*/ }, false)*/
).filterNotNull()); ).filterNotNull());
menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items); menu.setItems(items);
if(subscription.doNotifications) if(subscription.doNotifications)
menu.selectOption(null, "notifications", true, true); menu.selectOption(null, "notifications", true, true);
@@ -131,8 +170,29 @@ class UISlideOverlays {
subscription.save(); subscription.save();
menu.hide(true); menu.hide(true);
if(subscription.doNotifications && !originalNotif && Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) { if(subscription.doNotifications && !originalNotif) {
UIDialogs.toast(container.context, "Enable 'Background Update' in settings for notifications to work"); val mainContext = StateApp.instance.contextOrNull;
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) {
UIDialogs.toast(container.context, "Enable 'Background Update' in settings for notifications to work");
if(mainContext is MainActivity) {
UIDialogs.showDialog(mainContext, R.drawable.ic_settings, "Background Updating Required",
"You need to set a Background Updating interval for notifications", null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Configure", {
val intent = Intent(mainContext, SettingsActivity::class.java);
intent.putExtra("query", mainContext.getString(R.string.background_update));
mainContext.startActivity(intent);
}, UIDialogs.ActionStyle.PRIMARY));
}
return@subscribe;
}
else if(!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) {
UIDialogs.toast(container.context, "Android notifications are disabled");
if(mainContext is MainActivity) {
mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work");
}
}
} }
}; };
menu.onCancel.subscribe { menu.onCancel.subscribe {
@@ -148,6 +208,8 @@ class UISlideOverlays {
menu.show(); menu.show();
} }
} }
return menu;
} }
fun showAddToGroupOverlay(channel: IPlatformVideo, container: ViewGroup) { fun showAddToGroupOverlay(channel: IPlatformVideo, container: ViewGroup) {
@@ -317,7 +379,7 @@ class UISlideOverlays {
videoSources.filter { it is IVideoUrlSource && it.isDownloadable() }.asIterable(), videoSources.filter { it is IVideoUrlSource && it.isDownloadable() }.asIterable(),
Settings.instance.downloads.getDefaultVideoQualityPixels(), Settings.instance.downloads.getDefaultVideoQualityPixels(),
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS
) as IVideoUrlSource; ) as IVideoUrlSource?;
} }
if (audioSources != null) { if (audioSources != null) {
@@ -621,9 +683,17 @@ class UISlideOverlays {
val watchLater = StatePlaylists.instance.getWatchLater(); val watchLater = StatePlaylists.instance.getWatchLater();
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions", items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
(listOf( (listOf(
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), container.context.getString(R.string.download), { SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), "download", {
showDownloadVideoOverlay(video, container, true); showDownloadVideoOverlay(video, container, true);
}, false), }, false),
SlideUpMenuItem(container.context, R.drawable.ic_share, container.context.getString(R.string.share), "Share the video", "share", {
val url = if(video.shareUrl.isNotEmpty()) video.shareUrl else video.url;
container.context.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND;
putExtra(Intent.EXTRA_TEXT, url);
type = "text/plain";
}, null));
}, false),
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, container.context.getString(R.string.hide_creator_from_home), "", "hide_creator", { SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, container.context.getString(R.string.hide_creator_from_home), "", "hide_creator", {
StateMeta.instance.addHiddenCreator(video.author.url); StateMeta.instance.addHiddenCreator(video.author.url);
UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home"); UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
@@ -661,7 +731,7 @@ class UISlideOverlays {
} }
fun showAddToOverlay(video: IPlatformVideo, container: ViewGroup): SlideUpMenuOverlay { fun showAddToOverlay(video: IPlatformVideo, container: ViewGroup, slideUpMenuOverlayUpdated: (SlideUpMenuOverlay) -> Unit): SlideUpMenuOverlay {
val items = arrayListOf<View>(); val items = arrayListOf<View>();
@@ -692,6 +762,13 @@ class UISlideOverlays {
); );
val playlistItems = arrayListOf<SlideUpMenuItem>(); val playlistItems = arrayListOf<SlideUpMenuItem>();
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, container.context.getString(R.string.new_playlist), container.context.getString(R.string.add_to_new_playlist), "add_to_new_playlist", {
slideUpMenuOverlayUpdated(showCreatePlaylistOverlay(container) {
val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
});
}, false))
for (playlist in allPlaylists) { for (playlist in allPlaylists) {
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, playlist.name, "${playlist.videos.size} " + container.context.getString(R.string.videos), "", playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, playlist.name, "${playlist.videos.size} " + container.context.getString(R.string.videos), "",
{ {
@@ -713,7 +790,7 @@ class UISlideOverlays {
} }
fun showMoreButtonOverlay(container: ViewGroup, buttonGroup: RoundButtonGroup, ignoreTags: List<Any> = listOf(), onPinnedbuttons: ((List<RoundButton>)->Unit)? = null): SlideUpMenuOverlay { fun showMoreButtonOverlay(container: ViewGroup, buttonGroup: RoundButtonGroup, ignoreTags: List<Any> = listOf(), invokeParents: Boolean = true, onPinnedbuttons: ((List<RoundButton>)->Unit)? = null): SlideUpMenuOverlay {
val visible = buttonGroup.getVisibleButtons().filter { !ignoreTags.contains(it.tagRef) }; val visible = buttonGroup.getVisibleButtons().filter { !ignoreTags.contains(it.tagRef) };
val hidden = buttonGroup.getInvisibleButtons().filter { !ignoreTags.contains(it.tagRef) }; val hidden = buttonGroup.getInvisibleButtons().filter { !ignoreTags.contains(it.tagRef) };
@@ -721,7 +798,7 @@ class UISlideOverlays {
hidden hidden
.map { btn -> SlideUpMenuItem(container.context, btn.iconResource, btn.text.text.toString(), "", "", { .map { btn -> SlideUpMenuItem(container.context, btn.iconResource, btn.text.text.toString(), "", "", {
btn.handler?.invoke(btn); btn.handler?.invoke(btn);
}, true) as View }.toTypedArray(), }, invokeParents) as View }.toTypedArray(),
arrayOf(SlideUpMenuItem(container.context, R.drawable.ic_pin, container.context.getString(R.string.change_pins), container.context.getString(R.string.decide_which_buttons_should_be_pinned), "", { arrayOf(SlideUpMenuItem(container.context, R.drawable.ic_pin, container.context.getString(R.string.change_pins), container.context.getString(R.string.decide_which_buttons_should_be_pinned), "", {
showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) { showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) {
val selected = it val selected = it
@@ -12,6 +12,7 @@ import android.widget.ScrollView
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
@@ -37,8 +38,10 @@ class AddSourceActivity : AppCompatActivity() {
private lateinit var _sourceHeader: SourceHeaderView; private lateinit var _sourceHeader: SourceHeaderView;
private lateinit var _sourcePermissions: LinearLayout; private lateinit var _sourcePermissions: LinearLayout;
private lateinit var _sourceWarnings: LinearLayout; private lateinit var _sourceWarnings: LinearLayout;
private lateinit var _sourceWarningsContainer: LinearLayout;
private lateinit var _container: ScrollView; private lateinit var _container: ScrollView;
private lateinit var _loader: ImageView; private lateinit var _loader: ImageView;
@@ -79,6 +82,7 @@ class AddSourceActivity : AppCompatActivity() {
_sourcePermissions = findViewById(R.id.source_permissions); _sourcePermissions = findViewById(R.id.source_permissions);
_sourceWarnings = findViewById(R.id.source_warnings); _sourceWarnings = findViewById(R.id.source_warnings);
_sourceWarningsContainer = findViewById(R.id.container_source_warnings);
_container = findViewById(R.id.configContainer); _container = findViewById(R.id.configContainer);
_loader = findViewById(R.id.loader); _loader = findViewById(R.id.loader);
@@ -203,21 +207,30 @@ class AddSourceActivity : AppCompatActivity() {
val pastelRed = ContextCompat.getColor(this, R.color.pastel_red); val pastelRed = ContextCompat.getColor(this, R.color.pastel_red);
for(warning in config.getWarnings(script)) val warnings = config.getWarnings(script);
for(warning in warnings)
_sourceWarnings.addView( _sourceWarnings.addView(
SourceInfoView(this, SourceInfoView(this,
R.drawable.ic_security_pred, R.drawable.ic_security_pred,
warning.first, warning.first,
warning.second) warning.second)
.withDescriptionColor(pastelRed)); .withDescriptionColor(pastelRed));
_sourceWarningsContainer.isVisible = warnings.isNotEmpty();
setLoading(false); setLoading(false);
} }
fun install(config: SourcePluginConfig, script: String) { fun install(config: SourcePluginConfig, script: String) {
val isNew = !StatePlatform.instance.getAvailableClients().any { it.id == config.id };
StatePlugins.instance.installPlugin(this, lifecycleScope, config, script) { StatePlugins.instance.installPlugin(this, lifecycleScope, config, script) {
if(it) if(it) {
StatePlatform.instance.clearUpdateAvailable(config)
if(isNew)
lifecycleScope.launch {
StatePlatform.instance.enableClient(listOf(config.id));
}
backToSources(); backToSources();
}
} }
} }
@@ -16,6 +16,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
lateinit var _buttonBack: ImageButton; lateinit var _buttonBack: ImageButton;
lateinit var _buttonQR: BigButton; lateinit var _buttonQR: BigButton;
lateinit var _buttonBrowse: BigButton;
lateinit var _buttonURL: BigButton; lateinit var _buttonURL: BigButton;
lateinit var _buttonPlugins: BigButton; lateinit var _buttonPlugins: BigButton;
@@ -56,6 +57,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
_buttonBack = findViewById(R.id.button_back); _buttonBack = findViewById(R.id.button_back);
_buttonQR = findViewById(R.id.option_qr); _buttonQR = findViewById(R.id.option_qr);
_buttonBrowse = findViewById(R.id.option_browse);
_buttonURL = findViewById(R.id.option_url); _buttonURL = findViewById(R.id.option_url);
_buttonPlugins = findViewById(R.id.option_plugins); _buttonPlugins = findViewById(R.id.option_plugins);
@@ -74,6 +76,9 @@ class AddSourceOptionsActivity : AppCompatActivity() {
integrator.setCaptureActivity(QRCaptureActivity::class.java); integrator.setCaptureActivity(QRCaptureActivity::class.java);
_qrCodeResultLauncher.launch(integrator.createScanIntent()) _qrCodeResultLauncher.launch(integrator.createScanIntent())
} }
_buttonBrowse.onClick.subscribe {
startActivity(MainActivity.getTabIntent(this, "BROWSE_PLUGINS"));
}
_buttonURL.onClick.subscribe { _buttonURL.onClick.subscribe {
UIDialogs.toast(this, getString(R.string.not_implemented_yet)); UIDialogs.toast(this, getString(R.string.not_implemented_yet));
@@ -4,15 +4,19 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.* import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.R
import com.futo.platformplayer.logging.LogLevel import com.futo.platformplayer.logging.LogLevel
import com.futo.platformplayer.logging.Logging import com.futo.platformplayer.logging.Logging
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateUpdate
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -26,6 +30,7 @@ class ExceptionActivity : AppCompatActivity() {
private lateinit var _buttonSubmit: LinearLayout; private lateinit var _buttonSubmit: LinearLayout;
private lateinit var _buttonRestart: LinearLayout; private lateinit var _buttonRestart: LinearLayout;
private lateinit var _buttonClose: LinearLayout; private lateinit var _buttonClose: LinearLayout;
private lateinit var _buttonCheckForUpdates: LinearLayout;
private var _file: File? = null; private var _file: File? = null;
private var _submitted = false; private var _submitted = false;
@@ -43,6 +48,7 @@ class ExceptionActivity : AppCompatActivity() {
_buttonSubmit = findViewById(R.id.button_submit); _buttonSubmit = findViewById(R.id.button_submit);
_buttonRestart = findViewById(R.id.button_restart); _buttonRestart = findViewById(R.id.button_restart);
_buttonClose = findViewById(R.id.button_close); _buttonClose = findViewById(R.id.button_close);
_buttonCheckForUpdates = findViewById(R.id.button_check_for_updates);
val context = intent.getStringExtra(EXTRA_CONTEXT) ?: getString(R.string.unknown_context); val context = intent.getStringExtra(EXTRA_CONTEXT) ?: getString(R.string.unknown_context);
val stack = intent.getStringExtra(EXTRA_STACK) ?: getString(R.string.something_went_wrong_missing_stack_trace); val stack = intent.getStringExtra(EXTRA_STACK) ?: getString(R.string.something_went_wrong_missing_stack_trace);
@@ -81,6 +87,17 @@ class ExceptionActivity : AppCompatActivity() {
_buttonClose.setOnClickListener { _buttonClose.setOnClickListener {
finish(); finish();
}; };
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
_buttonCheckForUpdates.visibility = View.VISIBLE
_buttonCheckForUpdates.setOnClickListener {
lifecycleScope.launch(Dispatchers.IO) {
StateUpdate.instance.checkForUpdates(this@ExceptionActivity, true, true)
}
}
} else {
_buttonCheckForUpdates.visibility = View.GONE
}
} }
private fun submitFile() { private fun submitFile() {
@@ -10,6 +10,7 @@ import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.platforms.js.SourceAuth import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
@@ -39,6 +40,7 @@ class LoginActivity : AppCompatActivity() {
_textUrl = findViewById(R.id.text_url); _textUrl = findViewById(R.id.text_url);
_buttonClose = findViewById(R.id.button_close); _buttonClose = findViewById(R.id.button_close);
_buttonClose.setOnClickListener { _buttonClose.setOnClickListener {
UIDialogs.toast("Login cancelled", false);
finish(); finish();
} }
@@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration import android.content.res.Configuration
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
@@ -17,14 +18,18 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.motion.widget.MotionLayout import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
@@ -36,12 +41,12 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFrag
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.listeners.OrientationManager import com.futo.platformplayer.listeners.OrientationManager
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.models.UrlVideoWithTime import com.futo.platformplayer.models.UrlVideoWithTime
import com.futo.platformplayer.states.* import com.futo.platformplayer.states.*
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.SubscriptionStorage import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView
import com.google.gson.JsonParser import com.google.gson.JsonParser
import com.google.zxing.integration.android.IntentIntegrator import com.google.zxing.integration.android.IntentIntegrator
import kotlinx.coroutines.* import kotlinx.coroutines.*
@@ -51,6 +56,7 @@ import java.io.PrintWriter
import java.io.StringWriter import java.io.StringWriter
import java.lang.reflect.InvocationTargetException import java.lang.reflect.InvocationTargetException
import java.util.* import java.util.*
import java.util.concurrent.ConcurrentLinkedQueue
class MainActivity : AppCompatActivity, IWithResultLauncher { class MainActivity : AppCompatActivity, IWithResultLauncher {
@@ -62,6 +68,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var rootView : MotionLayout; lateinit var rootView : MotionLayout;
private lateinit var _overlayContainer: FrameLayout; private lateinit var _overlayContainer: FrameLayout;
private lateinit var _toastView: ToastView;
//Segment Containers //Segment Containers
private lateinit var _fragContainerTopBar: FragmentContainerView; private lateinit var _fragContainerTopBar: FragmentContainerView;
@@ -92,6 +99,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragMainSubscriptionsFeed: SubscriptionsFeedFragment; lateinit var _fragMainSubscriptionsFeed: SubscriptionsFeedFragment;
lateinit var _fragMainChannel: ChannelFragment; lateinit var _fragMainChannel: ChannelFragment;
lateinit var _fragMainSources: SourcesFragment; lateinit var _fragMainSources: SourcesFragment;
lateinit var _fragMainTutorial: TutorialFragment;
lateinit var _fragMainPlaylists: PlaylistsFragment; lateinit var _fragMainPlaylists: PlaylistsFragment;
lateinit var _fragMainPlaylist: PlaylistFragment; lateinit var _fragMainPlaylist: PlaylistFragment;
lateinit var _fragWatchlist: WatchLaterFragment; lateinit var _fragWatchlist: WatchLaterFragment;
@@ -134,7 +142,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
try { try {
handleUrlAll(content) runBlocking {
handleUrlAll(content)
}
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.i(TAG, "Failed to handle URL.", e) Logger.i(TAG, "Failed to handle URL.", e)
UIDialogs.toast(this, "Failed to handle URL: ${e.message}") UIDialogs.toast(this, "Failed to handle URL: ${e.message}")
@@ -203,7 +213,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragContainerVideoDetail = findViewById(R.id.fragment_overlay); _fragContainerVideoDetail = findViewById(R.id.fragment_overlay);
_fragContainerOverlay = findViewById(R.id.fragment_overlay_container); _fragContainerOverlay = findViewById(R.id.fragment_overlay_container);
_overlayContainer = findViewById(R.id.overlay_container); _overlayContainer = findViewById(R.id.overlay_container);
//_overlayContainer.visibility = View.GONE; _toastView = findViewById(R.id.toast_view);
//Initialize fragments //Initialize fragments
@@ -219,6 +229,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//Main //Main
_fragMainHome = HomeFragment.newInstance(); _fragMainHome = HomeFragment.newInstance();
_fragMainTutorial = TutorialFragment.newInstance()
_fragMainSuggestions = SuggestionsFragment.newInstance(); _fragMainSuggestions = SuggestionsFragment.newInstance();
_fragMainVideoSearchResults = ContentSearchResultsFragment.newInstance(); _fragMainVideoSearchResults = ContentSearchResultsFragment.newInstance();
_fragMainCreatorSearchResults = CreatorSearchResultsFragment.newInstance(); _fragMainCreatorSearchResults = CreatorSearchResultsFragment.newInstance();
@@ -310,6 +321,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragMainCreatorSearchResults.topBar = _fragTopBarSearch; _fragMainCreatorSearchResults.topBar = _fragTopBarSearch;
_fragMainPlaylistSearchResults.topBar = _fragTopBarSearch; _fragMainPlaylistSearchResults.topBar = _fragTopBarSearch;
_fragMainChannel.topBar = _fragTopBarNavigation; _fragMainChannel.topBar = _fragTopBarNavigation;
_fragMainTutorial.topBar = _fragTopBarNavigation;
_fragMainSubscriptionsFeed.topBar = _fragTopBarGeneral; _fragMainSubscriptionsFeed.topBar = _fragTopBarGeneral;
_fragMainSources.topBar = _fragTopBarAdd; _fragMainSources.topBar = _fragTopBarAdd;
_fragMainPlaylists.topBar = _fragTopBarGeneral; _fragMainPlaylists.topBar = _fragTopBarGeneral;
@@ -321,11 +333,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragDownloads.topBar = _fragTopBarGeneral; _fragDownloads.topBar = _fragTopBarGeneral;
_fragImportSubscriptions.topBar = _fragTopBarImport; _fragImportSubscriptions.topBar = _fragTopBarImport;
_fragImportPlaylists.topBar = _fragTopBarImport; _fragImportPlaylists.topBar = _fragTopBarImport;
_fragSubGroup.topBar = _fragTopBarNavigation;
_fragSubGroupList.topBar = _fragTopBarAdd; _fragSubGroupList.topBar = _fragTopBarAdd;
_fragBrowser.topBar = _fragTopBarNavigation; _fragBrowser.topBar = _fragTopBarNavigation;
fragCurrent = _fragMainHome; fragCurrent = _fragMainHome;
val defaultTab = Settings.instance.tabs.mapNotNull { val defaultTab = Settings.instance.tabs.mapNotNull {
@@ -407,6 +418,16 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateApp.instance.mainAppStartedWithExternalFiles(this); StateApp.instance.mainAppStartedWithExternalFiles(this);
//startActivity(Intent(this, TestActivity::class.java)); //startActivity(Intent(this, TestActivity::class.java));
val sharedPreferences = getSharedPreferences("GrayjayFirstBoot", Context.MODE_PRIVATE)
val isFirstBoot = sharedPreferences.getBoolean("IsFirstBoot", true)
if (isFirstBoot) {
UIDialogs.showConfirmationDialog(this, getString(R.string.do_you_want_to_see_the_tutorials_you_can_find_them_at_any_time_through_the_more_button), {
navigate(_fragMainTutorial)
})
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
}
} }
@@ -463,21 +484,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
_isVisible = true; _isVisible = true;
val videoToOpen = StateSaved.instance.videoToOpen;
if (_wasStopped) {
_wasStopped = false;
if (videoToOpen != null && _fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
Logger.i(TAG, "onResume videoToOpen=$videoToOpen");
if (StatePlatform.instance.hasEnabledVideoClient(videoToOpen.url)) {
navigate(_fragVideoDetail, UrlVideoWithTime(videoToOpen.url, videoToOpen.timeSeconds, false));
_fragVideoDetail.maximizeVideoDetail(true);
}
StateSaved.instance.setVideoToOpenNonBlocking(null);
}
}
} }
override fun onPause() { override fun onPause() {
@@ -532,13 +538,28 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
navigate(_fragMainSources); navigate(_fragMainSources);
} }
}; };
"BROWSE_PLUGINS" -> {
navigate(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
Pair("grayjay") { req ->
StateApp.instance.contextOrNull?.let {
if(it is MainActivity) {
runBlocking {
it.handleUrlAll(req.url.toString());
}
}
};
}
)));
}
} }
} }
} }
try { try {
if (targetData != null) { if (targetData != null) {
handleUrlAll(targetData) runBlocking {
handleUrlAll(targetData)
}
} }
} }
catch(ex: Throwable) { catch(ex: Throwable) {
@@ -546,7 +567,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
} }
fun handleUrlAll(url: String) { suspend fun handleUrlAll(url: String) {
val uri = Uri.parse(url) val uri = Uri.parse(url)
when (uri.scheme) { when (uri.scheme) {
"grayjay" -> { "grayjay" -> {
@@ -630,23 +651,38 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
} }
fun handleUrl(url: String): Boolean { suspend fun handleUrl(url: String): Boolean {
Logger.i(TAG, "handleUrl(url=$url)") Logger.i(TAG, "handleUrl(url=$url)")
if (StatePlatform.instance.hasEnabledVideoClient(url)) { return withContext(Dispatchers.IO) {
navigate(_fragVideoDetail, url); Logger.i(TAG, "handleUrl(url=$url) on IO");
_fragVideoDetail.maximizeVideoDetail(true); if (StatePlatform.instance.hasEnabledVideoClient(url)) {
return true; Logger.i(TAG, "handleUrl(url=$url) found video client");
} else if(StatePlatform.instance.hasEnabledChannelClient(url)) { lifecycleScope.launch(Dispatchers.Main) {
navigate(_fragMainChannel, url); navigate(_fragVideoDetail, url);
lifecycleScope.launch { _fragVideoDetail.maximizeVideoDetail(true);
delay(100); }
_fragVideoDetail.minimizeVideoDetail(); return@withContext true;
}; } else if (StatePlatform.instance.hasEnabledChannelClient(url)) {
return true; Logger.i(TAG, "handleUrl(url=$url) found channel client");
lifecycleScope.launch(Dispatchers.Main) {
navigate(_fragMainChannel, url);
delay(100);
_fragVideoDetail.minimizeVideoDetail();
};
return@withContext true;
} else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) {
Logger.i(TAG, "handleUrl(url=$url) found playlist client");
lifecycleScope.launch(Dispatchers.Main) {
navigate(_fragMainPlaylist, url);
delay(100);
_fragVideoDetail.minimizeVideoDetail();
};
return@withContext true;
}
return@withContext false;
} }
return false;
} }
fun handleContent(file: String, mime: String? = null): Boolean { fun handleContent(file: String, mime: String? = null): Boolean {
Logger.i(TAG, "handleContent(url=$file)"); Logger.i(TAG, "handleContent(url=$file)");
@@ -799,11 +835,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if(_fragBotBarMenu.onBackPressed()) if(_fragBotBarMenu.onBackPressed())
return; return;
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
_fragVideoDetail.onBackPressed())
return; return;
if(!fragCurrent.onBackPressed()) if(!fragCurrent.onBackPressed())
closeSegment(); closeSegment();
} }
@@ -849,7 +883,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_orientationManager.disable(); _orientationManager.disable();
StateApp.instance.mainAppDestroyed(this); StateApp.instance.mainAppDestroyed(this);
StateSaved.instance.setVideoToOpenBlocking(null);
} }
inline fun <reified T> isFragmentActive(): Boolean { inline fun <reified T> isFragmentActive(): Boolean {
@@ -965,6 +998,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
inline fun <reified T : Fragment> getFragment() : T { inline fun <reified T : Fragment> getFragment() : T {
return when(T::class) { return when(T::class) {
HomeFragment::class -> _fragMainHome as T; HomeFragment::class -> _fragMainHome as T;
TutorialFragment::class -> _fragMainTutorial as T;
ContentSearchResultsFragment::class -> _fragMainVideoSearchResults as T; ContentSearchResultsFragment::class -> _fragMainVideoSearchResults as T;
CreatorSearchResultsFragment::class -> _fragMainCreatorSearchResults as T; CreatorSearchResultsFragment::class -> _fragMainCreatorSearchResults as T;
SuggestionsFragment::class -> _fragMainSuggestions as T; SuggestionsFragment::class -> _fragMainSuggestions as T;
@@ -1010,6 +1044,70 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
val notifPermission = "android.permission.POST_NOTIFICATIONS";
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
if (isGranted)
UIDialogs.toast(this, "Notification permission granted");
else
UIDialogs.toast(this, "Notification permission denied");
}
fun requestNotificationPermissions(reason: String) {
when {
ContextCompat.checkSelfPermission(this, notifPermission) == PackageManager.PERMISSION_GRANTED -> {
}
ActivityCompat.shouldShowRequestPermissionRationale(this, notifPermission) -> {
UIDialogs.showDialog(this, R.drawable.ic_notifications, "Notifications Required",
reason, null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Enable", {
requestPermissionLauncher.launch(notifPermission);
}, UIDialogs.ActionStyle.PRIMARY));
}
else -> {
requestPermissionLauncher.launch(notifPermission);
}
}
}
private val _toastQueue = ConcurrentLinkedQueue<ToastView.Toast>();
private var _toastJob: Job? = null;
fun showAppToast(toast: ToastView.Toast) {
synchronized(_toastQueue) {
_toastQueue.add(toast);
if(_toastJob?.isActive != true)
_toastJob = lifecycleScope.launch(Dispatchers.Default) {
launchAppToastJob();
};
}
}
private suspend fun launchAppToastJob() {
Logger.i(TAG, "Starting appToast loop");
while(!_toastQueue.isEmpty()) {
val toast = _toastQueue.poll() ?: continue;
Logger.i(TAG, "Showing next toast (${toast.msg})");
lifecycleScope.launch(Dispatchers.Main) {
if (!_toastView.isVisible) {
Logger.i(TAG, "First showing toast");
_toastView.setToast(toast);
_toastView.show(true);
} else {
_toastView.setToastAnimated(toast);
}
}
if(toast.long)
delay(5000);
else
delay(3000);
}
Logger.i(TAG, "Ending appToast loop");
lifecycleScope.launch(Dispatchers.Main) {
_toastView.hide(true) {
};
}
}
//TODO: Only calls last handler due to missing request codes on ActivityResultLaunchers. //TODO: Only calls last handler due to missing request codes on ActivityResultLaunchers.
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>(); private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
@@ -12,14 +12,13 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricStorage
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.ProcessHandle import com.futo.polycentric.core.ProcessHandle
import com.futo.polycentric.core.Store import com.futo.polycentric.core.Store
import com.futo.polycentric.core.Synchronization
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -71,6 +70,13 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
try { try {
processHandle = ProcessHandle.create(); processHandle = ProcessHandle.create();
Store.instance.addProcessSecret(processHandle.processSecret); Store.instance.addProcessSecret(processHandle.processSecret);
try {
PolycentricStorage.instance.addProcessSecret(processHandle.processSecret)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
}
processHandle.addServer("https://srv1-stg.polycentric.io"); processHandle.addServer("https://srv1-stg.polycentric.io");
processHandle.setUsername(username); processHandle.setUsername(username);
StatePolycentric.instance.setProcessHandle(processHandle); StatePolycentric.instance.setProcessHandle(processHandle);
@@ -8,12 +8,16 @@ import android.widget.ImageButton
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.polycentric.PolycentricStorage
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.overlays.LoaderOverlay
import com.futo.polycentric.core.KeyPair import com.futo.polycentric.core.KeyPair
import com.futo.polycentric.core.Process import com.futo.polycentric.core.Process
import com.futo.polycentric.core.ProcessSecret import com.futo.polycentric.core.ProcessSecret
@@ -21,6 +25,9 @@ import com.futo.polycentric.core.SignedEvent
import com.futo.polycentric.core.Store import com.futo.polycentric.core.Store
import com.futo.polycentric.core.base64UrlToByteArray import com.futo.polycentric.core.base64UrlToByteArray
import com.google.zxing.integration.android.IntentIntegrator import com.google.zxing.integration.android.IntentIntegrator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import userpackage.Protocol import userpackage.Protocol
import userpackage.Protocol.ExportBundle import userpackage.Protocol.ExportBundle
@@ -29,6 +36,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
private lateinit var _buttonScanProfile: LinearLayout; private lateinit var _buttonScanProfile: LinearLayout;
private lateinit var _buttonImportProfile: LinearLayout; private lateinit var _buttonImportProfile: LinearLayout;
private lateinit var _editProfile: EditText; private lateinit var _editProfile: EditText;
private lateinit var _loaderOverlay: LoaderOverlay;
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data) val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
@@ -52,6 +60,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
_buttonHelp = findViewById(R.id.button_help); _buttonHelp = findViewById(R.id.button_help);
_buttonScanProfile = findViewById(R.id.button_scan_profile); _buttonScanProfile = findViewById(R.id.button_scan_profile);
_buttonImportProfile = findViewById(R.id.button_import_profile); _buttonImportProfile = findViewById(R.id.button_import_profile);
_loaderOverlay = findViewById(R.id.loader_overlay);
_editProfile = findViewById(R.id.edit_profile); _editProfile = findViewById(R.id.edit_profile);
findViewById<ImageButton>(R.id.button_back).setOnClickListener { findViewById<ImageButton>(R.id.button_back).setOnClickListener {
finish(); finish();
@@ -94,42 +103,63 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
return; return;
} }
try { _loaderOverlay.show()
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); lifecycleScope.launch(Dispatchers.IO) {
val keyPair = KeyPair.fromProto(exportBundle.keyPair); try {
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 existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey); val exportBundle = ExportBundle.parseFrom(urlInfo.body);
if (existingProcessSecret != null) { val keyPair = KeyPair.fromProto(exportBundle.keyPair);
UIDialogs.toast(this, getString(R.string.this_profile_is_already_imported));
return;
}
val processSecret = ProcessSecret(keyPair, Process.random()); val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
Store.instance.addProcessSecret(processSecret); if (existingProcessSecret != null) {
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.this_profile_is_already_imported));
}
return@launch;
}
val processHandle = processSecret.toProcessHandle(); val processSecret = ProcessSecret(keyPair, Process.random());
Store.instance.addProcessSecret(processSecret);
for (e in exportBundle.events.eventsList) {
try { try {
val se = SignedEvent.fromProto(e); PolycentricStorage.instance.addProcessSecret(processSecret)
Store.instance.putSignedEvent(se);
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Ignored invalid event", e); Logger.e(TAG, "Failed to save process secret to secret storage.", e)
}
val processHandle = processSecret.toProcessHandle();
for (e in exportBundle.events.eventsList) {
try {
val se = SignedEvent.fromProto(e);
Store.instance.putSignedEvent(se);
} catch (e: Throwable) {
Logger.w(TAG, "Ignored invalid event", e);
}
}
StatePolycentric.instance.setProcessHandle(processHandle);
processHandle.fullyBackfillClient(PolycentricCache.SERVER);
withContext(Dispatchers.Main) {
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
finish();
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to import profile", e);
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.failed_to_import_profile) + " '${e.message}'");
}
} finally {
withContext(Dispatchers.Main) {
_loaderOverlay.hide();
} }
} }
StatePolycentric.instance.setProcessHandle(processHandle);
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
finish();
} catch (e: Throwable) {
Logger.w(TAG, "Failed to import profile", e);
UIDialogs.toast(this, getString(R.string.failed_to_import_profile) + " '${e.message}'");
} }
} }
@@ -1,6 +1,8 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.app.Activity import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -12,6 +14,7 @@ import android.webkit.MimeTypeMap
import android.widget.EditText import android.widget.EditText
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
@@ -21,14 +24,16 @@ import com.futo.platformplayer.dp
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.views.overlays.LoaderOverlay
import com.futo.polycentric.core.Store import com.futo.polycentric.core.Store
import com.futo.polycentric.core.Synchronization
import com.futo.polycentric.core.SystemState import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.toBase64Url
import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.github.dhaval2404.imagepicker.ImagePicker import com.github.dhaval2404.imagepicker.ImagePicker
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -46,6 +51,8 @@ class PolycentricProfileActivity : AppCompatActivity() {
private lateinit var _buttonDelete: BigButton; private lateinit var _buttonDelete: BigButton;
private lateinit var _username: String; private lateinit var _username: String;
private lateinit var _imagePolycentric: ImageView; private lateinit var _imagePolycentric: ImageView;
private lateinit var _loaderOverlay: LoaderOverlay;
private lateinit var _textSystem: TextView;
private var _avatarUri: Uri? = null; private var _avatarUri: Uri? = null;
override fun attachBaseContext(newBase: Context?) { override fun attachBaseContext(newBase: Context?) {
@@ -63,28 +70,13 @@ class PolycentricProfileActivity : AppCompatActivity() {
_buttonExport = findViewById(R.id.button_export); _buttonExport = findViewById(R.id.button_export);
_buttonLogout = findViewById(R.id.button_logout); _buttonLogout = findViewById(R.id.button_logout);
_buttonDelete = findViewById(R.id.button_delete); _buttonDelete = findViewById(R.id.button_delete);
_loaderOverlay = findViewById(R.id.loader_overlay);
_textSystem = findViewById(R.id.text_system)
findViewById<ImageButton>(R.id.button_back).setOnClickListener { findViewById<ImageButton>(R.id.button_back).setOnClickListener {
saveIfRequired(); saveIfRequired();
finish(); finish();
}; };
lifecycleScope.launch(Dispatchers.IO) {
try {
val processHandle = StatePolycentric.instance.processHandle!!;
Synchronization.fullyBackFillClient(processHandle, processHandle.system, "https://srv1-stg.polycentric.io");
withContext(Dispatchers.Main) {
updateUI();
}
} catch (e: Throwable) {
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_backfill_client));
}
}
}
updateUI();
_imagePolycentric.setOnClickListener { _imagePolycentric.setOnClickListener {
ImagePicker.with(this) ImagePicker.with(this)
.cropSquare() .cropSquare()
@@ -120,6 +112,37 @@ class PolycentricProfileActivity : AppCompatActivity() {
finish(); finish();
}); });
} }
_textSystem.setOnLongClickListener {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip: ClipData = ClipData.newPlainText("system", _textSystem.text)
clipboard.setPrimaryClip(clip)
return@setOnLongClickListener true
}
updateUI()
StatePolycentric.instance.processHandle?.let { processHandle ->
_loaderOverlay.show()
lifecycleScope.launch(Dispatchers.IO) {
try {
processHandle.fullyBackfillClient(PolycentricCache.SERVER)
withContext(Dispatchers.Main) {
updateUI();
}
} catch (e: Throwable) {
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_backfill_client));
}
} finally {
withContext(Dispatchers.Main) {
_loaderOverlay.hide()
}
}
}
}
} }
private fun saveIfRequired() { private fun saveIfRequired() {
@@ -128,13 +151,17 @@ class PolycentricProfileActivity : AppCompatActivity() {
var hasChanges = false; var hasChanges = false;
val username = _editName.text.toString(); val username = _editName.text.toString();
if (username.length < 3) { if (username.length < 3) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.name_must_be_at_least_3_characters_long)); withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.name_must_be_at_least_3_characters_long));
}
return@launch; return@launch;
} }
val processHandle = StatePolycentric.instance.processHandle; val processHandle = StatePolycentric.instance.processHandle;
if (processHandle == null) { if (processHandle == null) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.process_handle_unset)); withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.process_handle_unset));
}
return@launch; return@launch;
} }
@@ -219,6 +246,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
private fun updateUI() { private fun updateUI() {
val processHandle = StatePolycentric.instance.processHandle!!; val processHandle = StatePolycentric.instance.processHandle!!;
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(processHandle.system)) val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(processHandle.system))
_textSystem.text = processHandle.system.key.toBase64Url()
_username = systemState.username; _username = systemState.username;
_editName.text.clear(); _editName.text.clear();
_editName.text.append(_username); _editName.text.append(_username);
@@ -1,8 +1,10 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
@@ -12,6 +14,8 @@ import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -33,6 +37,14 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
lateinit var overlay: FrameLayout; lateinit var overlay: FrameLayout;
val notifPermission = "android.permission.POST_NOTIFICATIONS";
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
if (isGranted)
UIDialogs.toast(this, "Notification permission granted");
else
UIDialogs.toast(this, "Notification permission denied");
}
override fun attachBaseContext(newBase: Context?) { override fun attachBaseContext(newBase: Context?) {
Logger.i("SettingsActivity", "SettingsActivity.attachBaseContext") Logger.i("SettingsActivity", "SettingsActivity.attachBaseContext")
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase)) super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
@@ -58,6 +70,33 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
Logger.i("SettingsActivity", "App language change detected, propogating to shared preferences"); Logger.i("SettingsActivity", "App language change detected, propogating to shared preferences");
StateApp.instance.setLocaleSetting(this, Settings.instance.language.getAppLanguageLocaleString()); StateApp.instance.setLocaleSetting(this, Settings.instance.language.getAppLanguageLocaleString());
} }
if(field.descriptor?.id == "background_update") {
Logger.i("SettingsActivity", "Detected change in background work ${field.value}");
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval > 0) {
val notifManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
if(!notifManager.areNotificationsEnabled()) {
UIDialogs.toast(this, "Notifications aren't enabled");
when {
ContextCompat.checkSelfPermission(this, notifPermission) == PackageManager.PERMISSION_GRANTED -> {
}
ActivityCompat.shouldShowRequestPermissionRationale(this, notifPermission) -> {
UIDialogs.showDialog(this, R.drawable.ic_notifications, "Notifications Required",
"Notifications need to be enabled for background updating to function", null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Enable", {
requestPermissionLauncher.launch(notifPermission);
}, UIDialogs.ActionStyle.PRIMARY));
}
else -> {
requestPermissionLauncher.launch(notifPermission);
}
}
}
}
}
}; };
_buttonBack.setOnClickListener { _buttonBack.setOnClickListener {
finish(); finish();
@@ -72,7 +111,10 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
reloadSettings(); reloadSettings();
} }
var isFirstLoad = true;
fun reloadSettings() { fun reloadSettings() {
val firstLoad = isFirstLoad;
isFirstLoad = false;
_form.setSearchVisible(false); _form.setSearchVisible(false);
_loaderView.start(); _loaderView.start();
_form.fromObject(lifecycleScope, Settings.instance) { _form.fromObject(lifecycleScope, Settings.instance) {
@@ -90,6 +132,13 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
UIDialogs.toast(this, getString(R.string.you_are_now_in_developer_mode)); UIDialogs.toast(this, getString(R.string.you_are_now_in_developer_mode));
} }
}; };
if(firstLoad) {
val query = intent.getStringExtra("query");
if(!query.isNullOrEmpty()) {
_form.setSearchQuery(query);
}
}
}; };
} }
@@ -135,6 +184,7 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
resultLauncher.launch(intent); resultLauncher.launch(intent);
} }
companion object { companion object {
//TODO: Temporary for solving Settings issues //TODO: Temporary for solving Settings issues
@SuppressLint("StaticFieldLeak") @SuppressLint("StaticFieldLeak")
@@ -14,7 +14,8 @@ enum class ChapterType(val value: Int) {
NORMAL(0), NORMAL(0),
SKIPPABLE(5), SKIPPABLE(5),
SKIP(6); SKIP(6),
SKIPONCE(7);
@@ -19,8 +19,9 @@ class PolycentricPlatformComment : IPlatformComment {
val eventPointer: Pointer; val eventPointer: Pointer;
val reference: Reference; val reference: Reference;
val parentReference: Reference?;
constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, eventPointer: Pointer, replyCount: Int? = null) { constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, eventPointer: Pointer, parentReference: Reference?, replyCount: Int? = null) {
this.contextUrl = contextUrl; this.contextUrl = contextUrl;
this.author = author; this.author = author;
this.message = msg; this.message = msg;
@@ -29,6 +30,7 @@ class PolycentricPlatformComment : IPlatformComment {
this.replyCount = replyCount; this.replyCount = replyCount;
this.eventPointer = eventPointer; this.eventPointer = eventPointer;
this.reference = eventPointer.toReference(); this.reference = eventPointer.toReference();
this.parentReference = parentReference;
} }
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> { override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> {
@@ -36,10 +38,11 @@ class PolycentricPlatformComment : IPlatformComment {
} }
fun cloneWithUpdatedReplyCount(replyCount: Int?): PolycentricPlatformComment { fun cloneWithUpdatedReplyCount(replyCount: Int?): PolycentricPlatformComment {
return PolycentricPlatformComment(contextUrl, author, message, rating, date, eventPointer, replyCount); return PolycentricPlatformComment(contextUrl, author, message, rating, date, eventPointer, parentReference, replyCount);
} }
companion object { companion object {
private const val TAG = "PolycentricPlatformComment"
val MAX_COMMENT_SIZE = 2000 val MAX_COMMENT_SIZE = 2000
} }
} }
@@ -3,4 +3,5 @@ package com.futo.platformplayer.api.media.models.modifier
interface IModifierOptions { interface IModifierOptions {
val applyAuthClient: String?; val applyAuthClient: String?;
val applyCookieClient: String?; val applyCookieClient: String?;
val applyOtherHeaders: Boolean;
} }
@@ -11,4 +11,5 @@ class SourcePluginAuthConfig(
val userAgent: String? = null, val userAgent: String? = null,
val loginButton: String? = null, val loginButton: String? = null,
val domainHeadersToFind: Map<String, List<String>>? = null, val domainHeadersToFind: Map<String, List<String>>? = null,
val loginWarning: String? = null
) { } ) { }
@@ -2,6 +2,9 @@ package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.views.fields.DropdownFieldOptions import com.futo.platformplayer.views.fields.DropdownFieldOptions
import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormField import com.futo.platformplayer.views.fields.FormField
@@ -55,7 +58,16 @@ class SourcePluginDescriptor {
onCaptchaChanged.emit(); onCaptchaChanged.emit();
} }
fun getCaptchaData(): SourceCaptchaData? { fun getCaptchaData(): SourceCaptchaData? {
return SourceCaptchaData.fromEncrypted(captchaEncrypted); try {
return SourceCaptchaData.fromEncrypted(captchaEncrypted);
}
catch(ex: Throwable) {
Logger.e("SourcePluginDescriptor", "Captcha decode failed, disabling auth.", ex);
StateAnnouncement.instance.registerAnnouncement("CAP_BROKEN_" + config.id,
"Captcha corrupted for plugin [${config.name}]",
"Something went wrong in the stored captcha, you'll have to login again", AnnouncementType.SESSION);
return null;
}
} }
fun updateAuth(str: SourceAuth?) { fun updateAuth(str: SourceAuth?) {
@@ -63,12 +75,24 @@ class SourcePluginDescriptor {
onAuthChanged.emit(); onAuthChanged.emit();
} }
fun getAuth(): SourceAuth? { fun getAuth(): SourceAuth? {
return SourceAuth.fromEncrypted(authEncrypted); try {
return SourceAuth.fromEncrypted(authEncrypted);
}
catch(ex: Throwable) {
Logger.e("SourcePluginDescriptor", "Authentication decode failed, disabling auth.", ex);
StateAnnouncement.instance.registerAnnouncement("AUTH_BROKEN_" + config.id,
"Authentication corrupted for plugin [${config.name}]",
"Something went wrong in the stored authentication, you'll have to login again", AnnouncementType.SESSION);
return null;
}
} }
@Serializable @Serializable
class AppPluginSettings { class AppPluginSettings {
@FormField(R.string.check_for_updates_setting, FieldForm.TOGGLE, R.string.check_for_updates_setting_description, 1)
var checkForUpdates: Boolean = true;
@FormField(R.string.visibility, "group", R.string.enable_where_this_plugins_content_are_visible, 2) @FormField(R.string.visibility, "group", R.string.enable_where_this_plugins_content_are_visible, 2)
var tabEnabled = TabEnabled(); var tabEnabled = TabEnabled();
@Serializable @Serializable
@@ -23,21 +23,31 @@ class JSRequest : IRequest {
_v8Options = options; _v8Options = options;
initialize(plugin, originalUrl, originalHeaders); initialize(plugin, originalUrl, originalHeaders);
} }
constructor(plugin: JSClient, obj: V8ValueObject, originalUrl: String?, originalHeaders: Map<String, String>?) { constructor(plugin: JSClient, obj: V8ValueObject, originalUrl: String?, originalHeaders: Map<String, String>?, applyOtherHeadersByDefault: Boolean = false) {
val contextName = "ModifyRequestResponse"; val contextName = "ModifyRequestResponse";
val config = plugin.config; val config = plugin.config;
_v8Url = obj.getOrDefault<String>(config, "url", contextName, null); _v8Url = obj.getOrDefault<String>(config, "url", contextName, null);
_v8Headers = obj.getOrDefault<Map<String, String>>(config, "headers", contextName, null); _v8Headers = obj.getOrDefault<Map<String, String>>(config, "headers", contextName, null);
_v8Options = obj.getOrDefault<V8ValueObject>(config, "options", "JSRequestModifier.options", null)?.let { _v8Options = obj.getOrDefault<V8ValueObject>(config, "options", "JSRequestModifier.options", null)?.let {
Options(config, it); Options(config, it, applyOtherHeadersByDefault);
} } ?: Options(null, null, applyOtherHeadersByDefault);
initialize(plugin, originalUrl, originalHeaders); initialize(plugin, originalUrl, originalHeaders);
} }
private fun initialize(plugin: JSClient, originalUrl: String?, originalHeaders: Map<String, String>?) { private fun initialize(plugin: JSClient, originalUrl: String?, originalHeaders: Map<String, String>?) {
val config = plugin.config; val config = plugin.config;
url = _v8Url ?: originalUrl; url = _v8Url ?: originalUrl;
headers = _v8Headers ?: originalHeaders ?: mapOf();
if(_v8Options?.applyOtherHeaders ?: false) {
val headersToSet = _v8Headers?.toMutableMap() ?: mutableMapOf();
if (originalHeaders != null)
for (head in originalHeaders)
if (!headersToSet.containsKey(head.key))
headersToSet[head.key] = head.value;
headers = headersToSet;
}
else
headers = _v8Headers ?: originalHeaders ?: mapOf();
if(_v8Options != null) { if(_v8Options != null) {
if(_v8Options.applyCookieClient != null && url != null) { if(_v8Options.applyCookieClient != null && url != null) {
@@ -68,10 +78,18 @@ class JSRequest : IRequest {
class Options: IModifierOptions { class Options: IModifierOptions {
override val applyAuthClient: String?; override val applyAuthClient: String?;
override val applyCookieClient: String?; override val applyCookieClient: String?;
override val applyOtherHeaders: Boolean;
constructor(config: IV8PluginConfig, obj: V8ValueObject) {
constructor(config: IV8PluginConfig, obj: V8ValueObject, applyOtherHeadersByDefault: Boolean = false) {
applyAuthClient = obj.getOrDefault(config, "applyAuthClient", "JSRequestModifier.options.applyAuthClient", null); applyAuthClient = obj.getOrDefault(config, "applyAuthClient", "JSRequestModifier.options.applyAuthClient", null);
applyCookieClient = obj.getOrDefault(config, "applyCookieClient", "JSRequestModifier.options.applyCookieClient", null); applyCookieClient = obj.getOrDefault(config, "applyCookieClient", "JSRequestModifier.options.applyCookieClient", null);
applyOtherHeaders = obj.getOrDefault(config, "applyOtherHeaders", "JSRequestModifier.options.applyOtherHeaders", applyOtherHeadersByDefault) ?: applyOtherHeadersByDefault;
}
constructor(applyAuthClient: String? = null, applyCookieClient: String? = null, applyOtherHeaders: Boolean = false) {
this.applyAuthClient = applyAuthClient;
this.applyCookieClient = applyCookieClient;
this.applyOtherHeaders = applyOtherHeaders;
} }
} }
@@ -40,6 +40,7 @@ class JSRequestModifier: IRequestModifier {
} as V8ValueObject; } as V8ValueObject;
val req = JSRequest(_plugin, result, url, headers); val req = JSRequest(_plugin, result, url, headers);
result.close();
return req; return req;
} }
@@ -33,7 +33,7 @@ abstract class JSSource {
this.type = type; this.type = type;
_requestModifier = obj.getOrDefault<V8ValueObject>(_config, "requestModifier", "JSSource.requestModifier", null)?.let { _requestModifier = obj.getOrDefault<V8ValueObject>(_config, "requestModifier", "JSSource.requestModifier", null)?.let {
JSRequest(plugin, it, null, null); JSRequest(plugin, it, null, null, true);
} }
hasRequestModifier = _requestModifier != null || obj.has("getRequestModifier"); hasRequestModifier = _requestModifier != null || obj.has("getRequestModifier");
} }
@@ -6,11 +6,9 @@ import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
class JSVideoSourceDescriptor: VideoMuxedSourceDescriptor { class JSVideoSourceDescriptor : VideoMuxedSourceDescriptor {
protected val _obj: V8ValueObject; protected val _obj: V8ValueObject;
override val isUnMuxed: Boolean; override val isUnMuxed: Boolean;
@@ -138,7 +138,7 @@ class AirPlayCastingDevice : CastingDevice {
try { try {
val connectedSocket = getConnectedSocket(adrs.toList(), port); val connectedSocket = getConnectedSocket(adrs.toList(), port);
if (connectedSocket == null) { if (connectedSocket == null) {
delay(3000); delay(1000);
continue; continue;
} }
@@ -17,6 +17,8 @@ import org.json.JSONObject
import java.io.DataInputStream import java.io.DataInputStream
import java.io.DataOutputStream import java.io.DataOutputStream
import java.net.InetAddress import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocket
@@ -50,6 +52,8 @@ class ChromecastCastingDevice : CastingDevice {
private var _transportId: String? = null; private var _transportId: String? = null;
private var _launching = false; private var _launching = false;
private var _mediaSessionId: Int? = null; private var _mediaSessionId: Int? = null;
private var _thread: Thread? = null;
private var _pingThread: Thread? = null;
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() { constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name; this.name = name;
@@ -270,7 +274,6 @@ class ChromecastCastingDevice : CastingDevice {
} }
override fun start() { override fun start() {
val adrs = addresses ?: return;
if (_started) { if (_started) {
return; return;
} }
@@ -283,152 +286,178 @@ class ChromecastCastingDevice : CastingDevice {
_launching = true; _launching = true;
_scopeIO?.cancel(); ensureThreadsStarted();
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.") Logger.i(TAG, "Started.");
_scopeIO = CoroutineScope(Dispatchers.IO); }
Thread { fun ensureThreadsStarted() {
connectionState = CastConnectionState.CONNECTING; val adrs = addresses ?: return;
while (_scopeIO?.isActive == true) { val thread = _thread
try { val pingThread = _pingThread
val connectedSocket = getConnectedSocket(adrs.toList(), port); if (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive) {
if (connectedSocket == null) { Log.i(TAG, "Restarting threads because one of the threads has died")
Thread.sleep(3000);
continue;
}
usedRemoteAddress = connectedSocket.inetAddress; _scopeIO?.cancel();
localAddress = connectedSocket.localAddress; Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
connectedSocket.close(); _scopeIO = CoroutineScope(Dispatchers.IO);
break;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
}
}
val sslContext = SSLContext.getInstance("TLS"); _thread = Thread {
sslContext.init(null, trustAllCerts, null);
val factory = sslContext.socketFactory;
//Connection loop
while (_scopeIO?.isActive == true) {
Logger.i(TAG, "Connecting to Chromecast.");
connectionState = CastConnectionState.CONNECTING; connectionState = CastConnectionState.CONNECTING;
try { var connectedSocket: Socket? = null
_socket?.close()
_socket = factory.createSocket(usedRemoteAddress, port) 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(3000);
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(3000);
continue;
}
getStatus();
val buffer = ByteArray(4096);
Logger.i(TAG, "Started receiving.");
while (_scopeIO?.isActive == true) { while (_scopeIO?.isActive == true) {
try { try {
val inputStream = _inputStream ?: break; val resultSocket = getConnectedSocket(adrs.toList(), port);
Log.d(TAG, "Receiving next packet..."); if (resultSocket == null) {
val b1 = inputStream.readUnsignedByte(); Thread.sleep(1000);
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());
continue; continue;
} }
Log.d(TAG, "Received header indicating $size bytes. Waiting for message."); connectedSocket = resultSocket
inputStream.read(buffer, 0, size); usedRemoteAddress = connectedSocket.inetAddress;
localAddress = connectedSocket.localAddress;
//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 message = ChromeCast.CastMessage.parseFrom(messageBytes);
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
Logger.i(TAG, "Received message: $message");
}
try {
handleMessage(message);
} catch (e:Throwable) {
Logger.w(TAG, "Failed to handle message.", e);
}
} catch (e: java.net.SocketException) {
Logger.e(TAG, "Socket exception while receiving.", e);
break; break;
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Exception while receiving.", e); Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
break;
} }
} }
_socket?.close();
Logger.i(TAG, "Socket disconnected.");
connectionState = CastConnectionState.CONNECTING; val sslContext = SSLContext.getInstance("TLS");
Thread.sleep(3000); sslContext.init(null, trustAllCerts, null);
}
Logger.i(TAG, "Stopped connection loop."); val factory = sslContext.socketFactory;
connectionState = CastConnectionState.DISCONNECTED;
}.start();
//Start ping loop val address = InetSocketAddress(usedRemoteAddress, port)
Thread {
Logger.i(TAG, "Started ping loop.")
val pingObject = JSONObject(); //Connection loop
pingObject.put("type", "PING"); while (_scopeIO?.isActive == true) {
Logger.i(TAG, "Connecting to Chromecast.");
connectionState = CastConnectionState.CONNECTING;
while (_scopeIO?.isActive == true) { try {
try { _socket?.close()
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.heartbeat", pingObject.toString()); if (connectedSocket != null) {
Thread.sleep(5000); Logger.i(TAG, "Using connected socket.")
} catch (e: Throwable) { _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(4096);
Logger.i(TAG, "Started receiving.");
while (_scopeIO?.isActive == true) {
try {
val inputStream = _inputStream ?: break;
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());
continue;
}
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 message = ChromeCast.CastMessage.parseFrom(messageBytes);
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
Logger.i(TAG, "Received message: $message");
}
try {
handleMessage(message);
} catch (e:Throwable) {
Logger.w(TAG, "Failed to handle message.", e);
}
} 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 ping loop."); Logger.i(TAG, "Stopped connection loop.");
}.start(); connectionState = CastConnectionState.DISCONNECTED;
}.apply { start() };
Logger.i(TAG, "Started."); //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) { private fun sendChannelMessage(sourceId: String, destinationId: String, namespace: String, json: String) {
@@ -593,6 +622,8 @@ class ChromecastCastingDevice : CastingDevice {
Logger.i(TAG, "Cancelled scopeIO without open socket.") Logger.i(TAG, "Cancelled scopeIO without open socket.")
} }
_pingThread = null;
_thread = null;
_scopeIO = null; _scopeIO = null;
_socket = null; _socket = null;
_outputStream = null; _outputStream = null;
@@ -1,8 +1,12 @@
package com.futo.platformplayer.casting package com.futo.platformplayer.casting
import android.os.Looper import android.os.Looper
import android.util.Base64
import android.util.Log import android.util.Log
import com.futo.platformplayer.UIDialogs 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.FCastPlayMessage
import com.futo.platformplayer.casting.models.FCastPlaybackErrorMessage import com.futo.platformplayer.casting.models.FCastPlaybackErrorMessage
import com.futo.platformplayer.casting.models.FCastPlaybackUpdateMessage import com.futo.platformplayer.casting.models.FCastPlaybackUpdateMessage
@@ -11,6 +15,7 @@ import com.futo.platformplayer.casting.models.FCastSetSpeedMessage
import com.futo.platformplayer.casting.models.FCastSetVolumeMessage import com.futo.platformplayer.casting.models.FCastSetVolumeMessage
import com.futo.platformplayer.casting.models.FCastVersionMessage import com.futo.platformplayer.casting.models.FCastVersionMessage
import com.futo.platformplayer.casting.models.FCastVolumeUpdateMessage import com.futo.platformplayer.casting.models.FCastVolumeUpdateMessage
import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.getConnectedSocket import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
@@ -23,25 +28,45 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.IOException import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.math.BigInteger
import java.net.InetAddress import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket 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) { enum class Opcode(val value: Byte) {
NONE(0), None(0),
PLAY(1), Play(1),
PAUSE(2), Pause(2),
RESUME(3), Resume(3),
STOP(4), Stop(4),
SEEK(5), Seek(5),
PLAYBACK_UPDATE(6), PlaybackUpdate(6),
VOLUME_UPDATE(7), VolumeUpdate(7),
SET_VOLUME(8), SetVolume(8),
PLAYBACK_ERROR(9), PlaybackError(9),
SET_SPEED(10), SetSpeed(10),
VERSION(11) 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 : CastingDevice { class FCastCastingDevice : CastingDevice {
@@ -58,11 +83,15 @@ class FCastCastingDevice : CastingDevice {
var port: Int = 0; var port: Int = 0;
private var _socket: Socket? = null; private var _socket: Socket? = null;
private var _outputStream: DataOutputStream? = null; private var _outputStream: OutputStream? = null;
private var _inputStream: DataInputStream? = null; private var _inputStream: InputStream? = null;
private var _scopeIO: CoroutineScope? = null; private var _scopeIO: CoroutineScope? = null;
private var _started: Boolean = false; private var _started: Boolean = false;
private var _version: Long = 1; private var _version: Long = 1;
private var _thread: Thread? = null
private var _pingThread: Thread? = null
private var _lastPongTime = -1L
private var _outputStreamLock = Object()
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() { constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name; this.name = name;
@@ -94,7 +123,7 @@ class FCastCastingDevice : CastingDevice {
setTime(resumePosition); setTime(resumePosition);
setDuration(duration); setDuration(duration);
sendMessage(Opcode.PLAY, FCastPlayMessage( send(Opcode.Play, FCastPlayMessage(
container = contentType, container = contentType,
url = contentId, url = contentId,
time = resumePosition, time = resumePosition,
@@ -118,7 +147,7 @@ class FCastCastingDevice : CastingDevice {
setTime(resumePosition); setTime(resumePosition);
setDuration(duration); setDuration(duration);
sendMessage(Opcode.PLAY, FCastPlayMessage( send(Opcode.Play, FCastPlayMessage(
container = contentType, container = contentType,
content = content, content = content,
time = resumePosition, time = resumePosition,
@@ -134,7 +163,7 @@ class FCastCastingDevice : CastingDevice {
} }
setVolume(volume); setVolume(volume);
sendMessage(Opcode.SET_VOLUME, FCastSetVolumeMessage(volume)) send(Opcode.SetVolume, FCastSetVolumeMessage(volume))
} }
override fun changeSpeed(speed: Double) { override fun changeSpeed(speed: Double) {
@@ -143,7 +172,7 @@ class FCastCastingDevice : CastingDevice {
} }
setSpeed(speed); setSpeed(speed);
sendMessage(Opcode.SET_SPEED, FCastSetSpeedMessage(speed)) send(Opcode.SetSpeed, FCastSetSpeedMessage(speed))
} }
override fun seekVideo(timeSeconds: Double) { override fun seekVideo(timeSeconds: Double) {
@@ -151,7 +180,7 @@ class FCastCastingDevice : CastingDevice {
return; return;
} }
sendMessage(Opcode.SEEK, FCastSeekMessage( send(Opcode.Seek, FCastSeekMessage(
time = timeSeconds time = timeSeconds
)); ));
} }
@@ -161,7 +190,7 @@ class FCastCastingDevice : CastingDevice {
return; return;
} }
sendMessage(Opcode.RESUME); send(Opcode.Resume);
} }
override fun pauseVideo() { override fun pauseVideo() {
@@ -169,7 +198,7 @@ class FCastCastingDevice : CastingDevice {
return; return;
} }
sendMessage(Opcode.PAUSE); send(Opcode.Pause);
} }
override fun stopVideo() { override fun stopVideo() {
@@ -177,12 +206,18 @@ class FCastCastingDevice : CastingDevice {
return; return;
} }
sendMessage(Opcode.STOP); send(Opcode.Stop);
} }
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean { private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
if(Looper.getMainLooper().thread == Thread.currentThread()) { if(Looper.getMainLooper().thread == Thread.currentThread()) {
_scopeIO?.launch { action(); } _scopeIO?.launch {
try {
action();
} catch (e: Throwable) {
Logger.e(TAG, "Failed to invoke in IO scope.", e)
}
}
return true; return true;
} }
@@ -201,7 +236,6 @@ class FCastCastingDevice : CastingDevice {
} }
override fun start() { override fun start() {
val adrs = addresses ?: return;
if (_started) { if (_started) {
return; return;
} }
@@ -209,123 +243,206 @@ class FCastCastingDevice : CastingDevice {
_started = true; _started = true;
Logger.i(TAG, "Starting..."); Logger.i(TAG, "Starting...");
_scopeIO?.cancel(); ensureThreadStarted();
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
_scopeIO = CoroutineScope(Dispatchers.IO);
Thread {
connectionState = CastConnectionState.CONNECTING;
while (_scopeIO?.isActive == true) {
try {
val connectedSocket = getConnectedSocket(adrs.toList(), port);
if (connectedSocket == null) {
Thread.sleep(3000);
continue;
}
usedRemoteAddress = connectedSocket.inetAddress;
localAddress = connectedSocket.localAddress;
connectedSocket.close();
break;
} catch (e: Throwable) {
Logger.w(ChromecastCastingDevice.TAG, "Failed to get setup initial connection to FastCast device.", e)
}
}
//Connection loop
while (_scopeIO?.isActive == true) {
Logger.i(TAG, "Connecting to FastCast.");
connectionState = CastConnectionState.CONNECTING;
try {
_socket = Socket(usedRemoteAddress, port);
Logger.i(TAG, "Successfully connected to FastCast at $usedRemoteAddress:$port");
try {
_outputStream = DataOutputStream(_socket?.outputStream);
_inputStream = DataInputStream(_socket?.inputStream);
} catch (e: Throwable) {
Logger.i(TAG, "Failed to authenticate to FastCast.", e);
}
} catch (e: IOException) {
_socket?.close();
Logger.i(TAG, "Failed to connect to FastCast.", e);
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(3000);
continue;
}
localAddress = _socket?.localAddress;
connectionState = CastConnectionState.CONNECTED;
val buffer = ByteArray(4096);
Logger.i(TAG, "Started receiving.");
var exceptionOccurred = false;
while (_scopeIO?.isActive == true && !exceptionOccurred) {
try {
val inputStream = _inputStream ?: break;
Log.d(TAG, "Receiving next packet...");
val b1 = inputStream.readUnsignedByte();
val b2 = inputStream.readUnsignedByte();
val b3 = inputStream.readUnsignedByte();
val b4 = inputStream.readUnsignedByte();
val size = ((b4.toLong() shl 24) or (b3.toLong() shl 16) or (b2.toLong() shl 8) or b1.toLong()).toInt();
if (size > buffer.size) {
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
inputStream.skip(size.toLong());
continue;
}
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
inputStream.read(buffer, 0, size);
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.entries.first { it.value == opcode }, json);
} catch (e:Throwable) {
Logger.w(TAG, "Failed to handle message.", e);
}
} catch (e: java.net.SocketException) {
Logger.e(TAG, "Socket exception while receiving.", e);
exceptionOccurred = true;
} catch (e: Throwable) {
Logger.e(TAG, "Exception while receiving.", e);
exceptionOccurred = true;
}
}
try {
_socket?.close();
Logger.i(TAG, "Socket disconnected.");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to close socket.", e)
}
connectionState = CastConnectionState.CONNECTING;
Thread.sleep(3000);
}
Logger.i(TAG, "Stopped connection loop.");
connectionState = CastConnectionState.DISCONNECTED;
}.start();
Logger.i(TAG, "Started."); 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)
}
}
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;
connectionState = CastConnectionState.CONNECTED;
_lastPongTime = -1L
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].toLong() shl 24) or (buffer[2].toLong() shl 16) or (buffer[1].toLong() shl 8) or buffer[0].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) {
try {
send(Opcode.Ping)
} 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)
}
}
/*if (_lastPongTime != -1L && System.currentTimeMillis() - _lastPongTime > 6000) {
Logger.w(TAG, "Closing socket due to last pong time being larger than 6 seconds.")
try {
_socket?.close()
} catch (e: Throwable) {
Log.w(TAG, "Failed to close socket.", e)
}
}*/
Thread.sleep(2000)
}
Logger.i(TAG, "Stopped ping loop.");
}.apply { start() }
} else {
Log.i(TAG, "Thread was still alive, not restarted")
}
}
private fun handleMessage(opcode: Opcode, json: String? = null) { private fun handleMessage(opcode: Opcode, json: String? = null) {
Log.i(TAG, "Processing packet (opcode: $opcode, size: ${json?.length ?: 0})")
when (opcode) { when (opcode) {
Opcode.PLAYBACK_UPDATE -> { Opcode.PlaybackUpdate -> {
if (json == null) { if (json == null) {
Logger.w(TAG, "Got playback update without JSON, ignoring."); Logger.w(TAG, "Got playback update without JSON, ignoring.");
return; return;
@@ -339,7 +456,7 @@ class FCastCastingDevice : CastingDevice {
else -> false else -> false
} }
} }
Opcode.VOLUME_UPDATE -> { Opcode.VolumeUpdate -> {
if (json == null) { if (json == null) {
Logger.w(TAG, "Got volume update without JSON, ignoring."); Logger.w(TAG, "Got volume update without JSON, ignoring.");
return; return;
@@ -348,7 +465,7 @@ class FCastCastingDevice : CastingDevice {
val volumeUpdate = FCastCastingDevice.json.decodeFromString<FCastVolumeUpdateMessage>(json); val volumeUpdate = FCastCastingDevice.json.decodeFromString<FCastVolumeUpdateMessage>(json);
setVolume(volumeUpdate.volume, volumeUpdate.generationTime); setVolume(volumeUpdate.volume, volumeUpdate.generationTime);
} }
Opcode.PLAYBACK_ERROR -> { Opcode.PlaybackError -> {
if (json == null) { if (json == null) {
Logger.w(TAG, "Got playback error without JSON, ignoring."); Logger.w(TAG, "Got playback error without JSON, ignoring.");
return; return;
@@ -357,7 +474,7 @@ class FCastCastingDevice : CastingDevice {
val playbackError = FCastCastingDevice.json.decodeFromString<FCastPlaybackErrorMessage>(json); val playbackError = FCastCastingDevice.json.decodeFromString<FCastPlaybackErrorMessage>(json);
Logger.e(TAG, "Remote casting playback error received: $playbackError") Logger.e(TAG, "Remote casting playback error received: $playbackError")
} }
Opcode.VERSION -> { Opcode.Version -> {
if (json == null) { if (json == null) {
Logger.w(TAG, "Got version without JSON, ignoring."); Logger.w(TAG, "Got version without JSON, ignoring.");
return; return;
@@ -367,72 +484,54 @@ class FCastCastingDevice : CastingDevice {
_version = version.version; _version = version.version;
Logger.i(TAG, "Remote version received: $version") Logger.i(TAG, "Remote version received: $version")
} }
Opcode.Ping -> send(Opcode.Pong)
Opcode.Pong -> _lastPongTime = System.currentTimeMillis()
else -> { } else -> { }
} }
} }
private fun sendMessage(opcode: Opcode) { private fun send(opcode: Opcode, message: String? = null) {
try { ensureNotMainThread()
val size = 1;
val outputStream = _outputStream; synchronized (_outputStreamLock) {
if (outputStream == null) { try {
Logger.w(TAG, "Failed to send $size bytes, output stream is null."); val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0)
return; 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
} }
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);
Log.d(TAG, "Sent $size bytes.");
} catch (e: Throwable) {
Logger.i(TAG, "Failed to send message.", e);
} }
} }
private inline fun <reified T> sendMessage(opcode: Opcode, message: T) { private inline fun <reified T> send(opcode: Opcode, message: T) {
try { try {
val data: ByteArray; send(opcode, message?.let { Json.encodeToString(it) })
var jsonString: String? = null;
if (message != null) {
jsonString = json.encodeToString(message);
data = jsonString.encodeToByteArray();
} else {
data = ByteArray(0);
}
val size = 1 + data.size;
val outputStream = _outputStream;
if (outputStream == null) {
Logger.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: '$jsonString'.");
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.i(TAG, "Failed to send message.", e); Log.i(TAG, "Failed to encode message to string.", e)
throw e
} }
} }
@@ -441,6 +540,9 @@ class FCastCastingDevice : CastingDevice {
usedRemoteAddress = null; usedRemoteAddress = null;
localAddress = null; localAddress = null;
_started = false; _started = false;
//TODO: Kill and/or join thread?
_thread = null;
_pingThread = null;
val socket = _socket; val socket = _socket;
val scopeIO = _scopeIO; val scopeIO = _scopeIO;
@@ -450,6 +552,8 @@ class FCastCastingDevice : CastingDevice {
scopeIO.launch { scopeIO.launch {
socket.close(); socket.close();
_inputStream?.close()
_outputStream?.close()
connectionState = CastConnectionState.DISCONNECTED; connectionState = CastConnectionState.DISCONNECTED;
scopeIO.cancel(); scopeIO.cancel();
Logger.i(TAG, "Cancelled scopeIO with open socket.") Logger.i(TAG, "Cancelled scopeIO with open socket.")
@@ -471,7 +575,65 @@ class FCastCastingDevice : CastingDevice {
} }
companion object { companion object {
val TAG = "FastCastCastingDevice"; val TAG = "FCastCastingDevice";
private val json = Json { ignoreUnknownKeys = true } 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))
}
} }
} }
@@ -205,11 +205,20 @@ class StateCasting {
} }
fun onResume() { fun onResume() {
val resumeCastingDevice = _resumeCastingDevice val ad = activeDevice
if (resumeCastingDevice != null) { if (ad != null) {
connectDevice(deviceFromCastingDeviceInfo(resumeCastingDevice)) if (ad is FCastCastingDevice) {
_resumeCastingDevice = null ad.ensureThreadStarted()
Log.i(TAG, "_resumeCastingDevice set to null onResume") } else if (ad is ChromecastCastingDevice) {
ad.ensureThreadsStarted()
}
} else {
val resumeCastingDevice = _resumeCastingDevice
if (resumeCastingDevice != null) {
connectDevice(deviceFromCastingDeviceInfo(resumeCastingDevice))
_resumeCastingDevice = null
Log.i(TAG, "_resumeCastingDevice set to null onResume")
}
} }
} }
@@ -50,4 +50,23 @@ data class FCastPlaybackErrorMessage(
@Serializable @Serializable
data class FCastVersionMessage( data class FCastVersionMessage(
val version: Long 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
) )
@@ -9,7 +9,10 @@ import com.futo.platformplayer.api.http.server.HttpGET
import com.futo.platformplayer.api.http.server.HttpPOST import com.futo.platformplayer.api.http.server.HttpPOST
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.engine.dev.V8RemoteObject import com.futo.platformplayer.engine.dev.V8RemoteObject
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.gsonStandard import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.gsonStandard
@@ -20,18 +23,29 @@ import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateAssets import com.futo.platformplayer.states.StateAssets
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.google.gson.ExclusionStrategy
import com.google.gson.FieldAttributes
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonParser import com.google.gson.JsonParser
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.lang.reflect.Field
import java.lang.reflect.InvocationTargetException import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Modifier
import java.util.UUID import java.util.UUID
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.memberFunctions
import kotlin.reflect.jvm.javaType
import kotlin.reflect.jvm.jvmErasure import kotlin.reflect.jvm.jvmErasure
class DeveloperEndpoints(private val context: Context) { class DeveloperEndpoints(private val context: Context) {
private val TAG = "DeveloperEndpoints"; private val TAG = "DeveloperEndpoints";
private val _client = ManagedHttpClient(); private val _client = ManagedHttpClient();
private var _testPlugin: V8Plugin? = null; private var _testPlugin: V8Plugin? = null;
private var _testPluginFull: JSClient? = null;
private val testPluginOrThrow: V8Plugin get() = _testPlugin ?: throw IllegalStateException("Attempted to use test plugin without plugin"); private val testPluginOrThrow: V8Plugin get() = _testPlugin ?: throw IllegalStateException("Attempted to use test plugin without plugin");
private val _testPluginVariables: HashMap<String, V8RemoteObject> = hashMapOf(); private val _testPluginVariables: HashMap<String, V8RemoteObject> = hashMapOf();
@@ -190,6 +204,17 @@ class DeveloperEndpoints(private val context: Context) {
val client = JSHttpClient(null, null, null, config); val client = JSHttpClient(null, null, null, config);
val clientAuth = JSHttpClient(null, null, null, config); val clientAuth = JSHttpClient(null, null, null, config);
_testPlugin = V8Plugin(StateApp.instance.context, config, null, client, clientAuth); _testPlugin = V8Plugin(StateApp.instance.context, config, null, client, clientAuth);
try {
val script = _client.get(config.absoluteScriptUrl);
_testPluginFull = JSClient(StateApp.instance.context, SourcePluginDescriptor(
config, null, null, null
), null, script.body?.string() ?: "");
_testPluginFull!!.initialize();
}
catch (ex: Throwable) {
Logger.e(TAG, "Loading full client failed", ex);
_testPluginFull = null;
}
context.respondJson(200, testPluginOrThrow.getPackageVariables()); context.respondJson(200, testPluginOrThrow.getPackageVariables());
} }
@@ -440,6 +465,68 @@ class DeveloperEndpoints(private val context: Context) {
} }
} }
private val _fieldAttributesField = FieldAttributes::class.java.getDeclaredField("field");
init {
_fieldAttributesField.isAccessible = true;
}
private val _remoteTestGson = GsonBuilder()
.setExclusionStrategies(object : ExclusionStrategy {
override fun shouldSkipClass(clazz: Class<*>?): Boolean {
return clazz?.simpleName == "JSClient" ||
clazz?.simpleName == "KSerializer[]" ||
clazz?.simpleName == "V8ValueObject";
}
override fun shouldSkipField(f: FieldAttributes?): Boolean {
val isPublic = f?.hasModifier(Modifier.PUBLIC) ?: true;
if(!isPublic) {
val underlyingField = _fieldAttributesField.get(f) as Field;
return !(underlyingField.declaringClass as Class).methods.any { it.name == "get" + underlyingField.name.replaceFirstChar { it.uppercaseChar() } && Modifier.isPublic(it.modifiers) };
}
else
return !isPublic;
}
}).create();
@HttpPOST("/plugin/remoteTest")
fun pluginRemoteTest(context: HttpContext) {
val method = context.query.getOrDefault("method", "");
try {
val parameters = context.readContentString();
val paras = JsonParser.parseString(parameters);
if(!paras.isJsonArray)
throw IllegalArgumentException("Expected json array as body");
val plugin = _testPluginFull ?: throw IllegalStateException("Plugin not loaded");
val function = plugin::class.memberFunctions.filter { it.findAnnotation<JSDocs>() != null }
.find { it.name == method };
if(function == null)
throw java.lang.IllegalArgumentException("Plugin method [${function}] not found");
val callResult = function.call(*(listOf(plugin) + paras.asJsonArray.take(function.parameters.size - 1).mapIndexed { index, jsonElement ->
//For now, manual conversion.
val parameter = function.parameters[index + 1];
val value = _remoteTestGson.fromJson<Any>(jsonElement, parameter.type.javaType);
return@mapIndexed value;
}).toTypedArray());
val json = if(callResult is IPager<*>)
_remoteTestGson.toJson(callResult.getResults())
else
_remoteTestGson.toJson(callResult);
//val json = wrapRemoteResult(callResult, false);
context.respondCode(200, json);
}
catch(ex: InvocationTargetException) {
Logger.e(TAG, "Remote test for [${method}] is failed", ex.targetException);
context.respondCode(500, ex.targetException.message ?: "", "text/plain")
}
catch(ex: Exception) {
Logger.e(TAG, "Remote test for [${method}] is failed", ex);
context.respondCode(500, ex.message ?: "", "text/plain")
}
}
//Internal calls //Internal calls
@HttpPOST("/get") @HttpPOST("/get")
fun get(context: HttpContext) { fun get(context: HttpContext) {
@@ -96,6 +96,11 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.") Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.")
} }
fun hideExceptionButtons() {
_buttonNever.visibility = View.GONE
_buttonShowChangelog.visibility = View.GONE
}
private fun update() { private fun update() {
_buttonShowChangelog.visibility = Button.GONE; _buttonShowChangelog.visibility = Button.GONE;
_buttonNever.visibility = Button.GONE; _buttonNever.visibility = Button.GONE;
@@ -6,11 +6,12 @@ import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.text.Editable import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.WindowManager import android.view.WindowManager
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.* import android.widget.EditText
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
@@ -25,7 +26,11 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.* import com.futo.polycentric.core.ClaimType
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -93,7 +98,7 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
val comment = _editComment.text.toString(); val comment = _editComment.text.toString();
val processHandle = StatePolycentric.instance.processHandle!! val processHandle = StatePolycentric.instance.processHandle!!
val eventPointer = processHandle.post(comment, null, ref) val eventPointer = processHandle.post(comment, ref)
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
@@ -118,7 +123,8 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
msg = comment, msg = comment,
rating = RatingLikeDislikes(0, 0), rating = RatingLikeDislikes(0, 0),
date = OffsetDateTime.now(), date = OffsetDateTime.now(),
eventPointer = eventPointer eventPointer = eventPointer,
parentReference = ref
)); ));
dismiss(); dismiss();
@@ -1,43 +1,34 @@
package com.futo.platformplayer.dialogs package com.futo.platformplayer.dialogs
import android.app.Activity
import android.app.AlertDialog import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.content.Intent
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.widget.Button import android.widget.Button
import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.AddSourceActivity
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.QRCaptureActivity
import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.CastingDevice
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.adapters.DeviceAdapter import com.futo.platformplayer.views.adapters.DeviceAdapter
import com.google.zxing.integration.android.IntentIntegrator
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID
class ConnectCastingDialog(context: Context?) : AlertDialog(context) { class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
private lateinit var _imageLoader: ImageView; private lateinit var _imageLoader: ImageView;
private lateinit var _buttonClose: Button; private lateinit var _buttonClose: Button;
private lateinit var _buttonAdd: Button; private lateinit var _buttonAdd: ImageButton;
private lateinit var _buttonScanQR: Button; private lateinit var _buttonScanQR: ImageButton;
private lateinit var _textNoDevicesFound: TextView; private lateinit var _textNoDevicesFound: TextView;
private lateinit var _textNoDevicesRemembered: TextView; private lateinit var _textNoDevicesRemembered: TextView;
private lateinit var _recyclerDevices: RecyclerView; private lateinit var _recyclerDevices: RecyclerView;
@@ -80,6 +71,14 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE; _textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE; _recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
}; };
_rememberedAdapter.onConnect.subscribe { _ ->
dismiss()
UIDialogs.showCastingDialog(context)
}
_adapter.onConnect.subscribe { _ ->
dismiss()
UIDialogs.showCastingDialog(context)
}
_recyclerRememberedDevices.adapter = _rememberedAdapter; _recyclerRememberedDevices.adapter = _rememberedAdapter;
_recyclerRememberedDevices.layoutManager = LinearLayoutManager(context); _recyclerRememberedDevices.layoutManager = LinearLayoutManager(context);
@@ -133,17 +133,19 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
StateCasting.instance.onActiveDeviceVolumeChanged.remove(this); StateCasting.instance.onActiveDeviceVolumeChanged.remove(this);
StateCasting.instance.onActiveDeviceVolumeChanged.subscribe { StateCasting.instance.onActiveDeviceVolumeChanged.subscribe {
_sliderVolume.value = it.toFloat(); _sliderVolume.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo);
}; };
StateCasting.instance.onActiveDeviceTimeChanged.remove(this); StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
StateCasting.instance.onActiveDeviceTimeChanged.subscribe { StateCasting.instance.onActiveDeviceTimeChanged.subscribe {
_sliderPosition.value = it.toFloat(); _sliderPosition.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderPosition.valueTo);
}; };
StateCasting.instance.onActiveDeviceDurationChanged.remove(this); StateCasting.instance.onActiveDeviceDurationChanged.remove(this);
StateCasting.instance.onActiveDeviceDurationChanged.subscribe { StateCasting.instance.onActiveDeviceDurationChanged.subscribe {
_sliderPosition.valueTo = it.toFloat(); val dur = it.toFloat().coerceAtLeast(1.0f)
_sliderPosition.value = _sliderPosition.value.coerceAtLeast(0.0f).coerceAtMost(dur);
_sliderPosition.valueTo = dur
}; };
_device = StateCasting.instance.activeDevice; _device = StateCasting.instance.activeDevice;
@@ -152,6 +154,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
setLoading(!isConnected); setLoading(!isConnected);
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { setLoading(connectionState != CastConnectionState.CONNECTED); }; StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { setLoading(connectionState != CastConnectionState.CONNECTED); };
updateDevice();
}; };
updateDevice(); updateDevice();
@@ -181,10 +184,13 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
} }
_textName.text = d.name; _textName.text = d.name;
_sliderVolume.value = d.volume.toFloat();
_sliderPosition.valueFrom = 0.0f; _sliderPosition.valueFrom = 0.0f;
_sliderPosition.valueTo = d.duration.toFloat(); _sliderVolume.valueFrom = 0.0f;
_sliderPosition.value = d.time.toFloat(); _sliderVolume.value = d.volume.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo);
val dur = d.duration.toFloat().coerceAtLeast(1.0f)
_sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur)
_sliderPosition.valueTo = dur
if (d.canSetVolume) { if (d.canSetVolume) {
_layoutVolumeAdjustable.visibility = View.VISIBLE; _layoutVolumeAdjustable.visibility = View.VISIBLE;
@@ -193,6 +199,44 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_layoutVolumeAdjustable.visibility = View.GONE; _layoutVolumeAdjustable.visibility = View.GONE;
_layoutVolumeFixed.visibility = View.VISIBLE; _layoutVolumeFixed.visibility = View.VISIBLE;
} }
val interactiveControls = listOf(
_sliderPosition,
_sliderVolume,
_buttonPrevious,
_buttonPlay,
_buttonPause,
_buttonStop,
_buttonNext
)
when (d.connectionState) {
CastConnectionState.CONNECTED -> {
enableControls(interactiveControls)
}
CastConnectionState.CONNECTING,
CastConnectionState.DISCONNECTED -> {
disableControls(interactiveControls)
}
}
}
private fun enableControls(views: List<View>) {
views.forEach { enableControl(it) }
}
private fun enableControl(view: View) {
view.alpha = 1.0f
view.isEnabled = true
}
private fun disableControls(views: List<View>) {
views.forEach { disableControl(it) }
}
private fun disableControl(view: View) {
view.alpha = 0.4f
view.isEnabled = false
} }
private fun setLoading(isLoading: Boolean) { private fun setLoading(isLoading: Boolean) {
@@ -337,8 +337,10 @@ class VideoDownload {
}); });
} }
var wasSuccesful = false;
try { try {
awaitAll(*sourcesToDownload.toTypedArray()); awaitAll(*sourcesToDownload.toTypedArray());
wasSuccesful = true;
} }
catch(runtimeEx: RuntimeException) { catch(runtimeEx: RuntimeException) {
if(runtimeEx.cause != null) if(runtimeEx.cause != null)
@@ -349,6 +351,29 @@ class VideoDownload {
catch(ex: Throwable) { catch(ex: Throwable) {
throw ex; throw ex;
} }
finally {
if(!wasSuccesful) {
try {
if(videoFilePath != null) {
val remainingVideo = File(videoFilePath!!);
if (remainingVideo.exists()) {
Logger.i(TAG, "Deleting remaining video file");
remainingVideo.delete();
}
}
if(audioFilePath != null) {
val remainingAudio = File(audioFilePath!!);
if (remainingAudio.exists()) {
Logger.i(TAG, "Deleting remaining audio file");
remainingAudio.delete();
}
}
}
catch(iex: Throwable) {
Logger.e(TAG, "Failed to delete files after failure:\n${iex.message}", iex);
}
}
}
} }
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
@@ -2,6 +2,7 @@ package com.futo.platformplayer.engine
import android.content.Context import android.content.Context
import com.caoccao.javet.exceptions.JavetCompilationException import com.caoccao.javet.exceptions.JavetCompilationException
import com.caoccao.javet.exceptions.JavetException
import com.caoccao.javet.exceptions.JavetExecutionException import com.caoccao.javet.exceptions.JavetExecutionException
import com.caoccao.javet.interop.V8Host import com.caoccao.javet.interop.V8Host
import com.caoccao.javet.interop.V8Runtime import com.caoccao.javet.interop.V8Runtime
@@ -10,6 +11,7 @@ import com.caoccao.javet.values.primitive.V8ValueBoolean
import com.caoccao.javet.values.primitive.V8ValueInteger import com.caoccao.javet.values.primitive.V8ValueInteger
import com.caoccao.javet.values.primitive.V8ValueString import com.caoccao.javet.values.primitive.V8ValueString
import com.caoccao.javet.values.reference.V8ValueObject import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
@@ -173,8 +175,16 @@ class V8Plugin {
isStopped = true; isStopped = true;
_runtime?.let { _runtime?.let {
_runtime = null; _runtime = null;
if(!it.isClosed && !it.isDead) if(!it.isClosed && !it.isDead) {
it.close(); try {
it.close();
}
catch(ex: JavetException) {
//In case race conditions are going on, already closed runtimes are fine.
if(ex.message?.contains("Runtime is already closed") != true)
throw ex;
}
}
Logger.i(TAG, "Stopped plugin [${config.name}]"); Logger.i(TAG, "Stopped plugin [${config.name}]");
}; };
} }
@@ -5,8 +5,11 @@ import com.caoccao.javet.annotations.V8Function
import com.caoccao.javet.annotations.V8Property import com.caoccao.javet.annotations.V8Property
import com.caoccao.javet.enums.V8ConversionMode import com.caoccao.javet.enums.V8ConversionMode
import com.caoccao.javet.enums.V8ProxyMode import com.caoccao.javet.enums.V8ProxyMode
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.engine.internal.V8BindObject import com.futo.platformplayer.engine.internal.V8BindObject
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
@@ -65,7 +68,7 @@ class PackageDOMParser : V8Package {
return result; return result;
} }
@V8Property @V8Property
fun attributes(): Map<String, String> = _element.attributes().dataset(); fun attributes(): Map<String, String> = _element.attributes().associate { Pair(it.key, it.value) }
@V8Property @V8Property
fun innerHTML(): String = _element.html(); fun innerHTML(): String = _element.html();
@V8Property @V8Property
@@ -138,10 +141,32 @@ class PackageDOMParser : V8Package {
super.dispose(); super.dispose();
} }
@V8Function
fun toNodeTree(): SerializedNode {
return SerializedNode(
childNodes().map { it.toNodeTree() },
_element.tagName(),
_element.text(),
attributes()
);
}
@V8Function
fun toNodeTreeJson(): String {
return Json.encodeToString(SerializedNode.serializer(), toNodeTree());
}
companion object { companion object {
fun parse(parser: PackageDOMParser, str: String): DOMNode { fun parse(parser: PackageDOMParser, str: String): DOMNode {
return DOMNode(parser, Jsoup.parse(str)); return DOMNode(parser, Jsoup.parse(str));
} }
} }
@Serializable
class SerializedNode(
val children: List<SerializedNode>,
val name: String,
val value: String,
val attributes: Map<String, String>
);
} }
} }
@@ -27,6 +27,7 @@ import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.fragment.mainactivity.main.FeedView import com.futo.platformplayer.fragment.mainactivity.main.FeedView
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -336,8 +337,11 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
context?.let { context?.let {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
try { try {
val channel = if(kv.value is ChannelException) (kv.value as ChannelException).channelNameOrUrl else null;
if(jsVideoPager != null) if(jsVideoPager != null)
UIDialogs.toast(it, "Plugin ${jsVideoPager.getPluginConfig().name} failed:\n${kv.value.message}", false); UIDialogs.toast(it, "Plugin ${jsVideoPager.getPluginConfig().name} failed:\n" +
(if(!channel.isNullOrEmpty()) "(${channel}) " else "") +
"${kv.value.message}", false);
else else
UIDialogs.toast(it, kv.value.message ?: "", false); UIDialogs.toast(it, kv.value.message ?: "", false);
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -247,11 +247,14 @@ class MenuBottomBarFragment : MainActivityFragment() {
val defs = currentButtonDefinitions?.toMutableList() ?: return val defs = currentButtonDefinitions?.toMutableList() ?: return
val metrics = StateApp.instance.displayMetrics ?: resources.displayMetrics; val metrics = StateApp.instance.displayMetrics ?: resources.displayMetrics;
_buttonsVisible = floor(metrics.widthPixels.toDouble() / 65.dp(resources).toDouble()).roundToInt(); _buttonsVisible = floor(metrics.widthPixels.toDouble() / 65.dp(resources).toDouble()).roundToInt();
if (_buttonsVisible - 1 >= defs.size) { if (_buttonsVisible >= defs.size) {
updateBottomMenuButtons(defs.toMutableList(), false); updateBottomMenuButtons(defs.toMutableList(), false);
} else if (_buttonsVisible > 0) {
updateBottomMenuButtons(defs.take(_buttonsVisible - 1).toMutableList(), true);
updateMoreButtons(defs.drop(_buttonsVisible - 1).toMutableList());
} else { } else {
updateBottomMenuButtons(defs.slice(IntRange(0, _buttonsVisible - 2)).toMutableList(), true); updateBottomMenuButtons(mutableListOf(), false)
updateMoreButtons(defs.slice(IntRange(_buttonsVisible - 1, defs.size - 1)).toMutableList()); updateMoreButtons(defs.toMutableList())
} }
} }
@@ -289,10 +292,17 @@ class MenuBottomBarFragment : MainActivityFragment() {
buttonDefinitions.find { d -> d.id == it.id } buttonDefinitions.find { d -> d.id == it.id }
}.toMutableList() }.toMutableList()
if (!StatePayment.instance.hasPaid) { //Add unconfigured tabs with default values
newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate<BuyFragment>() })) buttonDefinitions.forEach { buttonDefinition ->
if (!Settings.instance.tabs.any { it.id == buttonDefinition.id }) {
newCurrentButtonDefinitions.add(buttonDefinition)
}
} }
newCurrentButtonDefinitions.add(ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz, R.string.faq, canToggle = false, { false }, {
if (!StatePayment.instance.hasPaid) {
newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid_filled, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate<BuyFragment>() }))
}
newCurrentButtonDefinitions.add(ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz_fill, R.string.faq, canToggle = false, { false }, {
it.navigate<BrowserFragment>(Settings.URL_FAQ); it.navigate<BrowserFragment>(Settings.URL_FAQ);
})) }))
@@ -349,7 +359,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>() }), ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>() }),
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>() }), ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>() }),
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>() }), ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>() }),
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings, R.string.settings, canToggle = false, { false }, { ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>() }),
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
val c = it.context ?: return@ButtonDefinition; val c = it.context ?: return@ButtonDefinition;
Logger.i(TAG, "settings preventPictureInPicture()"); Logger.i(TAG, "settings preventPictureInPicture()");
it.requireFragment<VideoDetailFragment>().preventPictureInPicture(); it.requireFragment<VideoDetailFragment>().preventPictureInPicture();
@@ -22,15 +22,17 @@ class BrowserFragment : MainFragment() {
override val hasBottomBar: Boolean get() = true; override val hasBottomBar: Boolean get() = true;
private var _webview: WebView? = null; private var _webview: WebView? = null;
private val _webviewWithoutHandling = object: WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
return false;
}
};
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = inflater.inflate(R.layout.fragment_browser, container, false); val view = inflater.inflate(R.layout.fragment_browser, container, false);
_webview = view.findViewById<WebView?>(R.id.webview).apply { _webview = view.findViewById<WebView?>(R.id.webview).apply {
this.webViewClient = object: WebViewClient() { this.webViewClient = _webviewWithoutHandling;
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
return false;
}
};
this.settings.javaScriptEnabled = true; this.settings.javaScriptEnabled = true;
CookieManager.getInstance().setAcceptCookie(true); CookieManager.getInstance().setAcceptCookie(true);
this.settings.domStorageEnabled = true; this.settings.domStorageEnabled = true;
@@ -41,8 +43,26 @@ class BrowserFragment : MainFragment() {
override fun onShownWithView(parameter: Any?, isBack: Boolean) { override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack) super.onShownWithView(parameter, isBack)
if(parameter is String) if(parameter is String) {
_webview?.webViewClient = _webviewWithoutHandling;
_webview?.loadUrl(parameter); _webview?.loadUrl(parameter);
}
else if(parameter is NavigateOptions) {
if(parameter.urlHandlers != null && parameter.urlHandlers.isNotEmpty())
_webview?.webViewClient = object: WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val schema = request?.url?.scheme;
if(schema != null && parameter.urlHandlers.containsKey(schema)) {
parameter.urlHandlers[schema]?.invoke(request);
return true;
}
return false;
}
};
else
_webview?.webViewClient = _webviewWithoutHandling;
_webview?.loadUrl(parameter.url);
}
else else
_webview?.loadUrl("about:blank"); _webview?.loadUrl("about:blank");
} }
@@ -59,4 +79,9 @@ class BrowserFragment : MainFragment() {
companion object { companion object {
fun newInstance() = BrowserFragment().apply {} fun newInstance() = BrowserFragment().apply {}
} }
class NavigateOptions(
val url: String,
val urlHandlers: Map<String, (WebResourceRequest)->Unit>? = null
)
} }
@@ -418,6 +418,7 @@ class ChannelFragment : MainFragment() {
_buttonSubscribe.setSubscribeChannel(channel); _buttonSubscribe.setSubscribeChannel(channel);
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE; _buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
_textChannel.text = channel.name;
_textChannelSub.text = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} " + context.getString(R.string.subscribers).lowercase() else ""; _textChannelSub.text = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} " + context.getString(R.string.subscribers).lowercase() else "";
//TODO: Find a better way to access the adapter fragments.. //TODO: Find a better way to access the adapter fragments..
@@ -465,7 +466,7 @@ class ChannelFragment : MainFragment() {
_creatorThumbnail.setThumbnail(avatar, animate); _creatorThumbnail.setThumbnail(avatar, animate);
} else { } else {
_creatorThumbnail.setThumbnail(channel?.thumbnail, animate); _creatorThumbnail.setThumbnail(channel?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate); _creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
} }
val banner = profile?.systemState?.banner?.selectHighestResolutionImage() val banner = profile?.systemState?.banner?.selectHighestResolutionImage()
@@ -117,6 +117,7 @@ class CommentsFragment : MainFragment() {
val holder = CommentWithReferenceViewHolder(viewGroup, _cache); val holder = CommentWithReferenceViewHolder(viewGroup, _cache);
holder.onDelete.subscribe(::onDelete); holder.onDelete.subscribe(::onDelete);
holder.onRepliesClick.subscribe(::onRepliesClick); holder.onRepliesClick.subscribe(::onRepliesClick);
holder.onClick.subscribe(::onClick);
return@InsertedViewAdapterWithLoader holder; return@InsertedViewAdapterWithLoader holder;
} }
); );
@@ -200,6 +201,17 @@ class CommentsFragment : MainFragment() {
return false return false
} }
private fun onClick(c: IPlatformComment) {
if (c !is PolycentricPlatformComment) {
return
}
val parentRef = c.parentReference
if (parentRef != null && _repliesOverlay.handleParentClick(c.contextUrl, parentRef)) {
setRepliesOverlayVisible(true, true)
}
}
private fun onRepliesClick(c: IPlatformComment) { private fun onRepliesClick(c: IPlatformComment) {
val replyCount = c.replyCount ?: 0; val replyCount = c.replyCount ?: 0;
var metadata = ""; var metadata = "";
@@ -154,8 +154,14 @@ class ContentSearchResultsFragment : MainFragment() {
}; };
onSearch.subscribe(this) { onSearch.subscribe(this) {
if(it.isHttpUrl()) if(it.isHttpUrl()) {
navigate<VideoDetailFragment>(it); if(StatePlatform.instance.hasEnabledPlaylistClient(it))
navigate<PlaylistFragment>(it);
else if(StatePlatform.instance.hasEnabledChannelClient(it))
navigate<ChannelFragment>(it);
else
navigate<VideoDetailFragment>(it);
}
else else
setQuery(it, true); setQuery(it, true);
}; };
@@ -15,18 +15,17 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.structures.IAsyncPager import com.futo.platformplayer.api.media.structures.IAsyncPager
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.PlatformContentPager
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.others.TagsView
import com.futo.platformplayer.views.adapters.HistoryListViewHolder import com.futo.platformplayer.views.adapters.HistoryListViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.others.TagsView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -52,6 +51,7 @@ class HistoryFragment : MainFragment() {
override fun onShownWithView(parameter: Any?, isBack: Boolean) { override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack) super.onShownWithView(parameter, isBack)
_view?.setPager(StateHistory.instance.getHistoryPager()); _view?.setPager(StateHistory.instance.getHistoryPager());
(topBar as NavigationTopBarFragment?)?.onShown("History");
} }
@SuppressLint("ViewConstructor") @SuppressLint("ViewConstructor")
@@ -8,6 +8,7 @@ import android.view.ViewGroup
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.EmptyPager
@@ -17,15 +18,23 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.states.AnnouncementType import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.StateAnnouncement import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.NoResultsView
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.InsertedViewHolder import com.futo.platformplayer.views.adapters.InsertedViewHolder
import com.futo.platformplayer.views.announcements.AnnouncementView import com.futo.platformplayer.views.announcements.AnnouncementView
import com.futo.platformplayer.views.buttons.BigButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.UUID import java.util.UUID
@@ -147,6 +156,42 @@ class HomeFragment : MainFragment() {
finishRefreshLayoutLoader(); finishRefreshLayoutLoader();
} }
override fun getEmptyPagerView(): View? {
val dp10 = 10.dp(resources);
val dp30 = 30.dp(resources);
val pluginsExist = StatePlatform.instance.getAvailableClients().isNotEmpty();
if(StatePlatform.instance.getEnabledClients().isEmpty())
//Initial setup
return NoResultsView(context, "No enabled sources", if(pluginsExist)
"Enable or install some sources"
else "This Grayjay version comes without any sources, install sources externally or using the button below.", R.drawable.ic_sources,
listOf(BigButton(context, "Browse Online Sources", "View official sources online", R.drawable.ic_explore) {
fragment.navigate<BrowserFragment>(BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
Pair("grayjay") { req ->
StateApp.instance.contextOrNull?.let {
if(it is MainActivity) {
runBlocking {
it.handleUrlAll(req.url.toString());
}
}
};
}
)));
}.withMargin(dp10, dp30),
if(pluginsExist) BigButton(context, "Sources", "Go to the sources tab", R.drawable.ic_creators) {
fragment.navigate<SourcesFragment>();
}.withMargin(dp10, dp30) else null).filterNotNull()
);
else
return NoResultsView(context, "Nothing to see here", "The enabled sources do not have any results.", R.drawable.ic_help,
listOf(BigButton(context, "Sources", "Go to the sources tab", R.drawable.ic_creators) {
fragment.navigate<SourcesFragment>();
}.withMargin(dp10, dp30))
);
return null;
}
override fun reload() { override fun reload() {
loadResults(); loadResults();
} }
@@ -161,13 +206,15 @@ class HomeFragment : MainFragment() {
} }
private fun loadedResult(pager : IPager<IPlatformContent>) { private fun loadedResult(pager : IPager<IPlatformContent>) {
if (pager is EmptyPager<IPlatformContent>) { if (pager is EmptyPager<IPlatformContent>) {
StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), context.getString(R.string.no_home_available), context.getString(R.string.no_home_page_is_available_please_check_if_you_are_connected_to_the_internet_and_refresh), AnnouncementType.SESSION); //StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), context.getString(R.string.no_home_available), context.getString(R.string.no_home_page_is_available_please_check_if_you_are_connected_to_the_internet_and_refresh), AnnouncementType.SESSION);
} }
Logger.i(TAG, "Got new home pager ${pager}"); Logger.i(TAG, "Got new home pager ${pager}");
finishRefreshLayoutLoader(); finishRefreshLayoutLoader();
setLoading(false); setLoading(false);
setPager(pager); setPager(pager);
if(pager.getResults().isEmpty() && !pager.hasMorePages())
setEmptyPager(true);
} }
} }
@@ -314,8 +314,8 @@ class PostDetailFragment : MainFragment {
private fun updatePolycentricRating() { private fun updatePolycentricRating() {
_rating.visibility = View.GONE; _rating.visibility = View.GONE;
val value = _post?.id?.value ?: _postOverview?.id?.value ?: return; val ref = Models.referenceFromBuffer((_post?.url ?: _postOverview?.url)?.toByteArray() ?: return)
val ref = Models.referenceFromBuffer(value.toByteArray()); val extraBytesRef = (_post?.id?.value ?: _postOverview?.id?.value)?.let { if (it.isNotEmpty()) it.toByteArray() else null }
val version = _version; val version = _version;
_rating.onLikeDislikeUpdated.remove(this); _rating.onLikeDislikeUpdated.remove(this);
@@ -333,7 +333,8 @@ class PostDetailFragment : MainFragment {
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType( Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(
ContentType.OPINION.value).setValue( ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.dislike.data)).build() ByteString.copyFrom(Opinion.dislike.data)).build()
) ),
extraByteReferences = listOfNotNull(extraBytesRef)
); );
if (version != _version) { if (version != _version) {
@@ -342,8 +343,8 @@ class PostDetailFragment : MainFragment {
val likes = queryReferencesResponse.countsList[0]; val likes = queryReferencesResponse.countsList[0];
val dislikes = queryReferencesResponse.countsList[1]; val dislikes = queryReferencesResponse.countsList[1];
val hasLiked = StatePolycentric.instance.hasLiked(ref); val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
val hasDisliked = StatePolycentric.instance.hasDisliked(ref); val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (version != _version) { if (version != _version) {
@@ -468,9 +469,7 @@ class PostDetailFragment : MainFragment {
if (_postOverview == null) { if (_postOverview == null) {
fetchPolycentricProfile(); fetchPolycentricProfile();
updatePolycentricRating(); updatePolycentricRating();
_addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray()));
val ref = value.id.value?.let { Models.referenceFromBuffer(it.toByteArray()); };
_addCommentView.setContext(value.url, ref);
} }
updateCommentType(true); updateCommentType(true);
@@ -489,9 +488,7 @@ class PostDetailFragment : MainFragment {
_textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count? _textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count?
_textContent.text = value.description.fixHtmlWhitespace(); _textContent.text = value.description.fixHtmlWhitespace();
_platformIndicator.setPlatformFromClientID(value.id.pluginId); _platformIndicator.setPlatformFromClientID(value.id.pluginId);
_addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray()));
val ref = value.id.value?.let { Models.referenceFromBuffer(it.toByteArray()); };
_addCommentView.setContext(value.url, ref);
updatePolycentricRating(); updatePolycentricRating();
fetchPolycentricProfile(); fetchPolycentricProfile();
@@ -636,12 +633,12 @@ class PostDetailFragment : MainFragment {
if (cachedPolycentricProfile?.profile == null) { if (cachedPolycentricProfile?.profile == null) {
_layoutMonetization.visibility = View.GONE; _layoutMonetization.visibility = View.GONE;
_creatorThumbnail.setHarborAvailable(false, animate); _creatorThumbnail.setHarborAvailable(false, animate, null);
return; return;
} }
_layoutMonetization.visibility = View.VISIBLE; _layoutMonetization.visibility = View.VISIBLE;
_creatorThumbnail.setHarborAvailable(true, animate); _creatorThumbnail.setHarborAvailable(true, animate, cachedPolycentricProfile.profile.system.toProto());
} }
private fun fetchPost() { private fun fetchPost() {
@@ -665,14 +662,16 @@ class PostDetailFragment : MainFragment {
private fun fetchPolycentricComments() { private fun fetchPolycentricComments() {
Logger.i(TAG, "fetchPolycentricComments") Logger.i(TAG, "fetchPolycentricComments")
val post = _post; val post = _post;
val idValue = post?.id?.value val ref = (_post?.url ?: _postOverview?.url)?.toByteArray()?.let { Models.referenceFromBuffer(it) }
if (idValue == null) { val extraBytesRef = (_post?.id?.value ?: _postOverview?.id?.value)?.let { if (it.isNotEmpty()) it.toByteArray() else null }
Logger.w(TAG, "Failed to fetch polycentric comments because id was null")
if (ref == null) {
Logger.w(TAG, "Failed to fetch polycentric comments because url was not set null")
_commentsList.clear(); _commentsList.clear();
return return
} }
_commentsList.load(false) { StatePolycentric.instance.getCommentPager(post.url, Models.referenceFromBuffer(idValue.toByteArray())); }; _commentsList.load(false) { StatePolycentric.instance.getCommentPager(post!!.url, ref, listOfNotNull(extraBytesRef)); };
} }
private fun updateCommentType(reloadComments: Boolean) { private fun updateCommentType(reloadComments: Boolean) {
@@ -295,17 +295,24 @@ class SourceDetailFragment : MainFragment() {
} }
} }
val isEmbedded = StatePlugins.instance.getEmbeddedSources(context).any { it.key == config.id };
val clientIfExists = if(config.id != StateDeveloper.DEV_ID) val clientIfExists = if(config.id != StateDeveloper.DEV_ID)
StatePlugins.instance.getPlugin(config.id); StatePlugins.instance.getPlugin(config.id);
else null; else null;
groups.add( groups.add(
BigButtonGroup(c, context.getString(R.string.management), BigButtonGroup(c, context.getString(R.string.management),
BigButton(c, context.getString(R.string.uninstall), context.getString(R.string.removes_the_plugin_from_the_app), R.drawable.ic_block) { if(!isEmbedded) BigButton(c, context.getString(R.string.uninstall), context.getString(R.string.removes_the_plugin_from_the_app), R.drawable.ic_block) {
uninstallSource(); uninstallSource();
}.withBackground(R.drawable.background_big_button_red).apply { }.withBackground(R.drawable.background_big_button_red).apply {
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0); setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
}; };
} else BigButton(c, context.getString(R.string.uninstall), "Cannot uninstall embedded plugins", R.drawable.ic_block, {}).apply {
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
};
this.alpha = 0.5f
}, },
if(clientIfExists?.captchaEncrypted != null) if(clientIfExists?.captchaEncrypted != null)
BigButton(c, context.getString(R.string.delete_captcha), context.getString(R.string.deletes_stored_captcha_answer_for_this_plugin), R.drawable.ic_block) { BigButton(c, context.getString(R.string.delete_captcha), context.getString(R.string.deletes_stored_captcha_answer_for_this_plugin), R.drawable.ic_block) {
@@ -325,7 +332,6 @@ class SourceDetailFragment : MainFragment() {
_sourceButtons.addView(group); _sourceButtons.addView(group);
} }
val isEmbedded = StatePlugins.instance.getEmbeddedSources(context).any { it.key == config.id };
val advancedButtons = BigButtonGroup(c, "Advanced", val advancedButtons = BigButtonGroup(c, "Advanced",
BigButton(c, "Edit Code", "Modify the source of this plugin", R.drawable.ic_code) { BigButton(c, "Edit Code", "Modify the source of this plugin", R.drawable.ic_code) {
@@ -333,9 +339,15 @@ class SourceDetailFragment : MainFragment() {
this.alpha = 0.5f; this.alpha = 0.5f;
}, },
if(isEmbedded) BigButton(c, "Reinstall", "Modify the source of this plugin", R.drawable.ic_refresh) { if(isEmbedded) BigButton(c, "Reinstall", "Modify the source of this plugin", R.drawable.ic_refresh) {
StatePlugins.instance.updateEmbeddedPlugins(context, listOf(config.id), true); val embeddedConfig = StatePlugins.instance.getEmbeddedPluginConfigFromID(context, config.id);
reloadSource(config.id);
UIDialogs.toast(context, "Embedded plugin reinstalled, may require refresh"); UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, "Are you sure you want to downgrade (${config.version}=>${embeddedConfig?.version})?",
"This will revert the plugin back to the originally embedded version.\nVersion change: ${config.version}=>${embeddedConfig?.version}", null,
0, UIDialogs.Action("Cancel", {}), UIDialogs.Action("Reinstall", {
StatePlugins.instance.updateEmbeddedPlugins(context, listOf(config.id), true);
reloadSource(config.id);
UIDialogs.toast(context, "Embedded plugin reinstalled, may require refresh");
}, UIDialogs.ActionStyle.DANGEROUS));
}.apply { }.apply {
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0); setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
@@ -354,11 +366,22 @@ class SourceDetailFragment : MainFragment() {
if(config.authentication == null) if(config.authentication == null)
return; return;
LoginActivity.showLogin(StateApp.instance.context, config) { if(config.authentication.loginWarning != null) {
StatePlugins.instance.setPluginAuth(config.id, it); UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, "Login Warning",
config.authentication.loginWarning, null, 0,
reloadSource(config.id); UIDialogs.Action("Cancel", {}, UIDialogs.ActionStyle.NONE),
}; UIDialogs.Action("Login", {
LoginActivity.showLogin(StateApp.instance.context, config) {
StatePlugins.instance.setPluginAuth(config.id, it);
reloadSource(config.id);
};
}, UIDialogs.ActionStyle.PRIMARY))
}
else
LoginActivity.showLogin(StateApp.instance.context, config) {
StatePlugins.instance.setPluginAuth(config.id, it);
reloadSource(config.id);
};
} }
private fun logoutSource(clear: Boolean = true) { private fun logoutSource(clear: Boolean = true) {
val config = _config ?: return; val config = _config ?: return;
@@ -454,6 +477,7 @@ class SourceDetailFragment : MainFragment() {
} }
}); });
} }
private fun checkForUpdatesSource() { private fun checkForUpdatesSource() {
val c = _config ?: return; val c = _config ?: return;
val sourceUrl = c.sourceUrl ?: return; val sourceUrl = c.sourceUrl ?: return;
@@ -8,6 +8,7 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.core.view.isVisible
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@@ -25,6 +26,7 @@ import com.futo.platformplayer.views.adapters.DisabledSourceView
import com.futo.platformplayer.views.adapters.EnabledSourceAdapter import com.futo.platformplayer.views.adapters.EnabledSourceAdapter
import com.futo.platformplayer.views.adapters.EnabledSourceViewHolder import com.futo.platformplayer.views.adapters.EnabledSourceViewHolder
import com.futo.platformplayer.views.adapters.ItemMoveCallback import com.futo.platformplayer.views.adapters.ItemMoveCallback
import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.views.sources.SourceUnderConstructionView import com.futo.platformplayer.views.sources.SourceUnderConstructionView
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import java.util.Collections import java.util.Collections
@@ -39,10 +41,12 @@ class SourcesFragment : MainFragment() {
override fun onShownWithView(parameter: Any?, isBack: Boolean) { override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack) super.onShownWithView(parameter, isBack)
if(topBar is AddTopBarFragment) if(topBar is AddTopBarFragment) {
(topBar as AddTopBarFragment).onAdd.clear();
(topBar as AddTopBarFragment).onAdd.subscribe { (topBar as AddTopBarFragment).onAdd.subscribe {
startActivity(Intent(requireContext(), AddSourceOptionsActivity::class.java)); startActivity(Intent(requireContext(), AddSourceOptionsActivity::class.java));
}; };
}
_view?.reloadSources(); _view?.reloadSources();
} }
@@ -84,6 +88,14 @@ class SourcesFragment : MainFragment() {
_containerDisabledViews = findViewById(R.id.container_disabled_views); _containerDisabledViews = findViewById(R.id.container_disabled_views);
_containerConstruction = findViewById(R.id.container_construction); _containerConstruction = findViewById(R.id.container_construction);
if(StatePlatform.instance.getAvailableClients().isEmpty()) {
findViewById<LinearLayout>(R.id.no_sources).isVisible = true;
findViewById<LinearLayout>(R.id.plugin_disclaimer).isVisible = false;
}
findViewById<BigButton>(R.id.button_add_sources).onClick.subscribe {
fragment.startActivity(Intent(context, AddSourceOptionsActivity::class.java));
};
for(inConstructSource in StatePlugins.instance.getSourcesUnderConstruction(context)) for(inConstructSource in StatePlugins.instance.getSourcesUnderConstruction(context))
_containerConstruction.addView(SourceUnderConstructionView(context, inConstructSource.key, inConstructSource.value)); _containerConstruction.addView(SourceUnderConstructionView(context, inConstructSource.key, inConstructSource.value));
@@ -109,8 +121,6 @@ class SourcesFragment : MainFragment() {
adapterSourcesEnabled.notifyItemMoved(fromPosition, toPosition); adapterSourcesEnabled.notifyItemMoved(fromPosition, toPosition);
onEnabledChanged(enabledSources); onEnabledChanged(enabledSources);
if(toPosition == 0)
onPrimaryChanged(enabledSources.first());
StatePlatform.instance.setPlatformOrder(enabledSources.map { it.name }); StatePlatform.instance.setPlatformOrder(enabledSources.map { it.name });
}; };
@@ -131,8 +141,6 @@ class SourcesFragment : MainFragment() {
updateContainerVisibility(); updateContainerVisibility();
onEnabledChanged(enabledSources); onEnabledChanged(enabledSources);
if(index == 0)
onPrimaryChanged(enabledSources.first());
if(enabledSources.size <= 1) if(enabledSources.size <= 1)
setCanRemove(false); setCanRemove(false);
@@ -219,9 +227,6 @@ class SourcesFragment : MainFragment() {
_adapterSourcesEnabled.canRemove = canRemove; _adapterSourcesEnabled.canRemove = canRemove;
} }
private fun onPrimaryChanged(client: IPlatformClient) {
StatePlatform.instance.selectPrimaryClient(client.id);
}
private fun onEnabledChanged(clients: List<IPlatformClient>) { private fun onEnabledChanged(clients: List<IPlatformClient>) {
runBlocking { runBlocking {
StatePlatform.instance.selectClients(*clients.map { it.id }.toTypedArray()); StatePlatform.instance.selectClients(*clients.map { it.id }.toTypedArray());
@@ -4,35 +4,34 @@ import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.getSystemService
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.states.StateSubscriptionGroups import com.futo.platformplayer.states.StateSubscriptionGroups
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.AnyAdapterView import com.futo.platformplayer.views.AnyAdapterView
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.SearchView import com.futo.platformplayer.views.SearchView
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.viewholders.CreatorBarViewHolder import com.futo.platformplayer.views.adapters.viewholders.CreatorBarViewHolder
import com.futo.platformplayer.views.overlays.CreatorSelectOverlay
import com.futo.platformplayer.views.overlays.ImageVariableOverlay import com.futo.platformplayer.views.overlays.ImageVariableOverlay
import com.futo.platformplayer.views.overlays.OverlayTopbar
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput
import com.google.android.material.imageview.ShapeableImageView import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.shape.CornerFamily import com.google.android.material.shape.CornerFamily
@@ -60,6 +59,11 @@ class SubscriptionGroupFragment : MainFragment() {
return view; return view;
} }
override fun onHide() {
super.onHide();
_view?.onHide();
}
companion object { companion object {
private const val TAG = "SourcesFragment"; private const val TAG = "SourcesFragment";
fun newInstance() = SubscriptionGroupFragment().apply {} fun newInstance() = SubscriptionGroupFragment().apply {}
@@ -69,6 +73,7 @@ class SubscriptionGroupFragment : MainFragment() {
private class SubscriptionGroupView: ConstraintLayout { private class SubscriptionGroupView: ConstraintLayout {
private val _fragment: SubscriptionGroupFragment; private val _fragment: SubscriptionGroupFragment;
private val _topbar: OverlayTopbar;
private val _textGroupTitleContainer: LinearLayout; private val _textGroupTitleContainer: LinearLayout;
private val _textGroupTitle: TextView; private val _textGroupTitle: TextView;
private val _imageGroup: ShapeableImageView; private val _imageGroup: ShapeableImageView;
@@ -81,26 +86,25 @@ class SubscriptionGroupFragment : MainFragment() {
private val _buttonSettings: ImageButton; private val _buttonSettings: ImageButton;
private val _buttonDelete: ImageButton; private val _buttonDelete: ImageButton;
private val _enabledCreators: ArrayList<IPlatformChannel> = arrayListOf(); private val _buttonAddCreator: FrameLayout;
private val _disabledCreators: ArrayList<IPlatformChannel> = arrayListOf();
private val _enabledCreatorsFiltered: ArrayList<IPlatformChannel> = arrayListOf();
private val _disabledCreatorsFiltered: ArrayList<IPlatformChannel> = arrayListOf();
private val _containerEnabled: LinearLayout; private val _enabledCreators: ArrayList<IPlatformChannel> = arrayListOf();
private val _containerDisabled: LinearLayout; private val _enabledCreatorsFiltered: ArrayList<IPlatformChannel> = arrayListOf();
private val _recyclerCreatorsEnabled: AnyAdapterView<IPlatformChannel, CreatorBarViewHolder>; private val _recyclerCreatorsEnabled: AnyAdapterView<IPlatformChannel, CreatorBarViewHolder>;
private val _recyclerCreatorsDisabled: AnyAdapterView<IPlatformChannel, CreatorBarViewHolder>;
private val _overlay: FrameLayout; private val _overlay: FrameLayout;
private var _group: SubscriptionGroup? = null; private var _group: SubscriptionGroup? = null;
private var _didDelete: Boolean = false;
constructor(context: Context, fragment: SubscriptionGroupFragment): super(context) { constructor(context: Context, fragment: SubscriptionGroupFragment): super(context) {
inflate(context, R.layout.fragment_subscriptions_group, this); inflate(context, R.layout.fragment_subscriptions_group, this);
_fragment = fragment; _fragment = fragment;
_overlay = findViewById(R.id.overlay); _overlay = findViewById(R.id.overlay);
_topbar = findViewById(R.id.topbar);
_searchBar = findViewById(R.id.search_bar); _searchBar = findViewById(R.id.search_bar);
_textGroupTitleContainer = findViewById(R.id.text_group_title_container); _textGroupTitleContainer = findViewById(R.id.text_group_title_container);
_textGroupTitle = findViewById(R.id.text_group_title); _textGroupTitle = findViewById(R.id.text_group_title);
@@ -110,33 +114,51 @@ class SubscriptionGroupFragment : MainFragment() {
_textGroupMeta = findViewById(R.id.text_group_meta); _textGroupMeta = findViewById(R.id.text_group_meta);
_buttonSettings = findViewById(R.id.button_settings); _buttonSettings = findViewById(R.id.button_settings);
_buttonDelete = findViewById(R.id.button_delete); _buttonDelete = findViewById(R.id.button_delete);
_buttonAddCreator = findViewById(R.id.button_creator_add);
_imageGroup.setBackgroundColor(Color.GRAY); _imageGroup.setBackgroundColor(Color.GRAY);
_topbar.onClose.subscribe {
fragment.close(true);
}
_buttonAddCreator.setOnClickListener {
addCreators();
}
val dp6 = 6.dp(resources); val dp6 = 6.dp(resources);
_imageGroup.shapeAppearanceModel = ShapeAppearanceModel.builder() _imageGroup.shapeAppearanceModel = ShapeAppearanceModel.builder()
.setAllCorners(CornerFamily.ROUNDED, dp6.toFloat()) .setAllCorners(CornerFamily.ROUNDED, dp6.toFloat())
.build() .build()
_containerEnabled = findViewById(R.id.container_enabled);
_containerDisabled = findViewById(R.id.container_disabled);
_recyclerCreatorsEnabled = findViewById<RecyclerView>(R.id.recycler_creators_enabled).asAny(_enabledCreatorsFiltered) { _recyclerCreatorsEnabled = findViewById<RecyclerView>(R.id.recycler_creators_enabled).asAny(_enabledCreatorsFiltered) {
it.itemView.setPadding(0, dp6, 0, dp6); it.itemView.setPadding(0, dp6, 0, dp6);
it.onClick.subscribe { channel -> it.onClick.subscribe { channel ->
disableCreator(channel); //disableCreator(channel);
UIDialogs.showDialog(context, R.drawable.ic_trash, "Delete", "Are you sure you want to delete\n[${channel.name}]?", null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Delete", {
_group?.let {
it.urls.remove(channel.url);
save();
reloadCreators(it);
}
}, UIDialogs.ActionStyle.DANGEROUS))
}; };
} }
/*
_recyclerCreatorsDisabled = findViewById<RecyclerView>(R.id.recycler_creators_disabled).asAny(_disabledCreatorsFiltered) { _recyclerCreatorsDisabled = findViewById<RecyclerView>(R.id.recycler_creators_disabled).asAny(_disabledCreatorsFiltered) {
it.itemView.setPadding(0, dp6, 0, dp6); it.itemView.setPadding(0, dp6, 0, dp6);
it.onClick.subscribe { channel -> it.onClick.subscribe { channel ->
enableCreator(channel); enableCreator(channel);
}; };
} }*/
_recyclerCreatorsEnabled.view.layoutManager = GridLayoutManager(context, 5).apply { _recyclerCreatorsEnabled.view.layoutManager = GridLayoutManager(context, 5).apply {
this.orientation = LinearLayoutManager.VERTICAL; this.orientation = LinearLayoutManager.VERTICAL;
}; };
/*
_recyclerCreatorsDisabled.view.layoutManager = GridLayoutManager(context, 5).apply { _recyclerCreatorsDisabled.view.layoutManager = GridLayoutManager(context, 5).apply {
this.orientation = LinearLayoutManager.VERTICAL; this.orientation = LinearLayoutManager.VERTICAL;
}; };*/
_textGroupTitleContainer.setOnClickListener { _textGroupTitleContainer.setOnClickListener {
_group?.let { editName(it) }; _group?.let { editName(it) };
@@ -154,10 +176,15 @@ class SubscriptionGroupFragment : MainFragment() {
} }
_buttonDelete.setOnClickListener { _buttonDelete.setOnClickListener {
_group?.let { _group?.let { g ->
StateSubscriptionGroups.instance.deleteSubscriptionGroup(it.id); UIDialogs.showDialog(context, R.drawable.ic_trash, "Delete Group", "Are you sure you want to this group?\n[${g.name}]?", null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Delete", {
StateSubscriptionGroups.instance.deleteSubscriptionGroup(g.id);
_didDelete = true;
fragment.close(true);
}, UIDialogs.ActionStyle.DANGEROUS))
}; };
fragment.close(true);
} }
_buttonSettings.visibility = View.GONE; _buttonSettings.visibility = View.GONE;
@@ -165,6 +192,12 @@ class SubscriptionGroupFragment : MainFragment() {
filterCreators(); filterCreators();
} }
_topbar.setButtons(
Pair(R.drawable.ic_share) {
UIDialogs.toast(context, "Coming soon");
}
);
setGroup(null); setGroup(null);
} }
@@ -208,9 +241,44 @@ class SubscriptionGroupFragment : MainFragment() {
overlay.removeAllViews(); overlay.removeAllViews();
} }
} }
fun addCreators() {
val overlay = CreatorSelectOverlay(context, _enabledCreators.map { it.url });
_overlay.removeAllViews();
_overlay.addView(overlay);
_overlay.alpha = 0f
_overlay.visibility = View.VISIBLE;
_overlay.animate().alpha(1f).setDuration(300).start();
overlay.onSelected.subscribe {
_group?.let { g ->
if(g.urls.isEmpty() && g.image == null) {
//Obtain image
for(sub in it) {
val sub = StateSubscriptions.instance.getSubscription(sub);
if(sub != null && sub.channel.thumbnail != null) {
g.image = ImageVariable.fromUrl(sub.channel.thumbnail!!);
g.image?.setImageView(_imageGroup);
g.image?.setImageView(_imageGroupBackground);
break;
}
}
}
for(url in it) {
if(!g.urls.contains(url))
g.urls.add(url);
}
save();
reloadCreators(g);
}
};
overlay.onClose.subscribe {
_overlay.visibility = View.GONE;
overlay.removeAllViews();
}
}
fun setGroup(group: SubscriptionGroup?) { fun setGroup(group: SubscriptionGroup?) {
_didDelete = false;
_group = group; _group = group;
_textGroupTitle.text = group?.name; _textGroupTitle.text = group?.name;
@@ -227,76 +295,39 @@ class SubscriptionGroupFragment : MainFragment() {
reloadCreators(group); reloadCreators(group);
} }
fun onHide() {
if(!_didDelete && _group != null && StateSubscriptionGroups.instance.getSubscriptionGroup(_group!!.id) === null) {
UIDialogs.toast(context, "Group creation cancelled");
}
}
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
private fun reloadCreators(group: SubscriptionGroup?) { private fun reloadCreators(group: SubscriptionGroup?) {
_enabledCreators.clear(); _enabledCreators.clear();
_disabledCreators.clear(); //_disabledCreators.clear();
if(group != null) { if(group != null) {
val urls = group.urls.toList(); val urls = group.urls.toList();
val subs = StateSubscriptions.instance.getSubscriptions().map { it.channel } val subs = StateSubscriptions.instance.getSubscriptions().map { it.channel }
_enabledCreators.addAll(subs.filter { urls.contains(it.url) }); _enabledCreators.addAll(subs.filter { urls.contains(it.url) });
_disabledCreators.addAll(subs.filter { !urls.contains(it.url) });
} }
updateMeta();
filterCreators(); filterCreators();
} }
private fun filterCreators() { private fun filterCreators() {
val query = _searchBar.textSearch.text.toString().lowercase(); val query = _searchBar.textSearch.text.toString().lowercase();
val filteredEnabled = _enabledCreators.filter { it.name.lowercase().contains(query) }; val filteredEnabled = _enabledCreators.filter { it.name.lowercase().contains(query) };
val filteredDisabled = _disabledCreators.filter { it.name.lowercase().contains(query) };
//Optimize //Optimize
_enabledCreatorsFiltered.clear(); _enabledCreatorsFiltered.clear();
_enabledCreatorsFiltered.addAll(filteredEnabled); _enabledCreatorsFiltered.addAll(filteredEnabled);
_disabledCreatorsFiltered.clear();
_disabledCreatorsFiltered.addAll(filteredDisabled);
_recyclerCreatorsEnabled.notifyContentChanged(); _recyclerCreatorsEnabled.notifyContentChanged();
_recyclerCreatorsDisabled.notifyContentChanged();
}
private fun enableCreator(channel: IPlatformChannel) {
val index = _disabledCreatorsFiltered.indexOf(channel);
if (index >= 0) {
_disabledCreators.remove(channel)
_disabledCreatorsFiltered.remove(channel);
_recyclerCreatorsDisabled.adapter.notifyItemRangeRemoved(index);
_enabledCreators.add(channel);
_enabledCreatorsFiltered.add(channel);
_recyclerCreatorsEnabled.adapter.notifyItemInserted(_enabledCreatorsFiltered.size - 1);
_group?.let {
if(!it.urls.contains(channel.url)) {
it.urls.add(channel.url);
save();
}
}
updateMeta();
}
}
private fun disableCreator(channel: IPlatformChannel) {
val index = _enabledCreatorsFiltered.indexOf(channel);
if (index >= 0) {
_enabledCreators.remove(channel)
_enabledCreatorsFiltered.removeAt(index);
_recyclerCreatorsEnabled.adapter.notifyItemRangeRemoved(index);
_disabledCreators.add(channel);
_disabledCreatorsFiltered.add(channel);
_recyclerCreatorsDisabled.adapter.notifyItemInserted(_disabledCreatorsFiltered.size - 1);
_group?.let {
it.urls.remove(channel.url);
save();
}
updateMeta();
}
} }
private fun updateMeta() { private fun updateMeta() {
_textGroupMeta.text = "${_enabledCreators.size} creators"; _textGroupMeta.text = "${_group?.urls?.size} creators";
} }
} }
} }
@@ -107,12 +107,14 @@ class SubscriptionGroupListFragment : MainFragment() {
updateGroups(); updateGroups();
} }
if(topBar is AddTopBarFragment) if(topBar is AddTopBarFragment) {
(topBar as AddTopBarFragment).onAdd.clear();
(topBar as AddTopBarFragment).onAdd.subscribe { (topBar as AddTopBarFragment).onAdd.subscribe {
_overlay?.let { _overlay?.let {
UISlideOverlays.showCreateSubscriptionGroup(it) UISlideOverlays.showCreateSubscriptionGroup(it)
} }
}; };
}
} }
private fun updateGroups() { private fun updateGroups() {
@@ -10,6 +10,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@@ -24,12 +25,14 @@ import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.FragmentedStorageFileJson import com.futo.platformplayer.stores.FragmentedStorageFileJson
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.NoResultsView import com.futo.platformplayer.views.NoResultsView
import com.futo.platformplayer.views.ToastView
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.InsertedViewHolder import com.futo.platformplayer.views.adapters.InsertedViewHolder
@@ -43,6 +46,7 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.nio.channels.Channel
import java.time.OffsetDateTime import java.time.OffsetDateTime
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
@@ -52,6 +56,7 @@ class SubscriptionsFeedFragment : MainFragment() {
override val hasBottomBar: Boolean get() = true; override val hasBottomBar: Boolean get() = true;
private var _view: SubscriptionsFeedView? = null; private var _view: SubscriptionsFeedView? = null;
private var _group: SubscriptionGroup? = null;
private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null; private var _cachedRecyclerData: FeedView.RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null;
override fun onShownWithView(parameter: Any?, isBack: Boolean) { override fun onShownWithView(parameter: Any?, isBack: Boolean) {
@@ -72,6 +77,8 @@ class SubscriptionsFeedFragment : MainFragment() {
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = SubscriptionsFeedView(this, inflater, _cachedRecyclerData); val view = SubscriptionsFeedView(this, inflater, _cachedRecyclerData);
_view = view; _view = view;
if(_group != null)
view.selectSubgroup(_group);
return view; return view;
} }
@@ -80,6 +87,7 @@ class SubscriptionsFeedFragment : MainFragment() {
val view = _view; val view = _view;
if (view != null) { if (view != null) {
_cachedRecyclerData = view.recyclerData; _cachedRecyclerData = view.recyclerData;
_group = view.subGroup;
view.cleanup(); view.cleanup();
_view = null; _view = null;
} }
@@ -100,18 +108,11 @@ class SubscriptionsFeedFragment : MainFragment() {
class SubscriptionsFeedView : ContentFeedView<SubscriptionsFeedFragment> { class SubscriptionsFeedView : ContentFeedView<SubscriptionsFeedFragment> {
override val shouldShowTimeBar: Boolean get() = Settings.instance.subscriptions.progressBar override val shouldShowTimeBar: Boolean get() = Settings.instance.subscriptions.progressBar
private var _subGroup: SubscriptionGroup? = null; var subGroup: SubscriptionGroup? = null;
constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) { constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
Logger.i(TAG, "SubscriptionsFeedFragment constructor()"); Logger.i(TAG, "SubscriptionsFeedFragment constructor()");
StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.subscribe(this) { progress, total -> StateSubscriptions.instance.global.onUpdateProgress.subscribe(this) { progress, total ->
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
setProgress(progress, total);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to set progress", e);
}
}
}; };
StateSubscriptions.instance.onSubscriptionsChanged.subscribe(this) { _, added -> StateSubscriptions.instance.onSubscriptionsChanged.subscribe(this) { _, added ->
@@ -137,8 +138,9 @@ class SubscriptionsFeedFragment : MainFragment() {
recyclerData.lastLoad.getNowDiffSeconds() > 60 ) { recyclerData.lastLoad.getNowDiffSeconds() > 60 ) {
recyclerData.lastLoad = OffsetDateTime.now(); recyclerData.lastLoad = OffsetDateTime.now();
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5 && Settings.instance.subscriptions.fetchOnTabOpen) if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5 && Settings.instance.subscriptions.fetchOnTabOpen) {
loadResults(false); loadResults(false);
}
else if(recyclerData.results.size == 0) { else if(recyclerData.results.size == 0) {
loadCache(); loadCache();
setLoading(false); setLoading(false);
@@ -162,15 +164,27 @@ class SubscriptionsFeedFragment : MainFragment() {
} }
} }
if (!StateSubscriptions.instance.isGlobalUpdating) { if (!StateSubscriptions.instance.global.isGlobalUpdating) {
finishRefreshLayoutLoader(); finishRefreshLayoutLoader();
} }
StateSubscriptions.instance.onFeedProgress.subscribe(this) { id, progress, total ->
if(subGroup?.id == id)
fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
setProgress(progress, total);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to set progress", e);
}
}
}
} }
override fun cleanup() { override fun cleanup() {
super.cleanup() super.cleanup()
StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.remove(this); StateSubscriptions.instance.global.onUpdateProgress.remove(this);
StateSubscriptions.instance.onSubscriptionsChanged.remove(this); StateSubscriptions.instance.onSubscriptionsChanged.remove(this);
StateSubscriptions.instance.onFeedProgress.remove(this);
} }
override val feedStyle: FeedStyle get() = Settings.instance.subscriptions.getSubscriptionsFeedStyle(); override val feedStyle: FeedStyle get() = Settings.instance.subscriptions.getSubscriptionsFeedStyle();
@@ -184,6 +198,7 @@ class SubscriptionsFeedFragment : MainFragment() {
val allowContentTypes: MutableList<ContentType> = mutableListOf(ContentType.MEDIA, ContentType.POST); val allowContentTypes: MutableList<ContentType> = mutableListOf(ContentType.MEDIA, ContentType.POST);
var allowLive: Boolean = true; var allowLive: Boolean = true;
var allowPlanned: Boolean = false; var allowPlanned: Boolean = false;
var allowWatched: Boolean = true;
override fun encode(): String { override fun encode(): String {
return Json.encodeToString(this); return Json.encodeToString(this);
} }
@@ -194,8 +209,9 @@ class SubscriptionsFeedFragment : MainFragment() {
private var _bypassRateLimit = false; private var _bypassRateLimit = false;
private val _lastExceptions: List<Throwable>? = null; private val _lastExceptions: List<Throwable>? = null;
private val _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({StateApp.instance.scope}, { withRefresh -> private val _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({StateApp.instance.scope}, { withRefresh ->
val group = subGroup;
if(!_bypassRateLimit) { if(!_bypassRateLimit) {
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount(); val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount(group);
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n"); val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n");
val rateLimitPlugins = subRequestCounts.filter { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true } val rateLimitPlugins = subRequestCounts.filter { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true }
Logger.w(TAG, "Trying to refreshing subscriptions with requests:\n" + reqCountStr); Logger.w(TAG, "Trying to refreshing subscriptions with requests:\n" + reqCountStr);
@@ -203,9 +219,10 @@ class SubscriptionsFeedFragment : MainFragment() {
throw RateLimitException(rateLimitPlugins.map { it.key.id }); throw RateLimitException(rateLimitPlugins.map { it.key.id });
} }
_bypassRateLimit = false; _bypassRateLimit = false;
val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(StateApp.instance.scope, withRefresh); val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(StateApp.instance.scope, withRefresh, group);
val feed = StateSubscriptions.instance.getFeed(group?.id);
val currentExs = StateSubscriptions.instance.globalSubscriptionExceptions; val currentExs = feed?.exceptions ?: listOf();
if(currentExs != _lastExceptions && currentExs.any()) if(currentExs != _lastExceptions && currentExs.any())
handleExceptions(currentExs); handleExceptions(currentExs);
@@ -252,6 +269,11 @@ class SubscriptionsFeedFragment : MainFragment() {
} }
}; };
fun selectSubgroup(g: SubscriptionGroup?) {
if(g != null)
_subscriptionBar?.selectGroup(g);
}
private fun initializeToolbarContent() { private fun initializeToolbarContent() {
_subscriptionBar = SubscriptionBar(context).apply { _subscriptionBar = SubscriptionBar(context).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
@@ -261,8 +283,17 @@ class SubscriptionsFeedFragment : MainFragment() {
if(g is SubscriptionGroup.Add) if(g is SubscriptionGroup.Add)
UISlideOverlays.showCreateSubscriptionGroup(_overlayContainer); UISlideOverlays.showCreateSubscriptionGroup(_overlayContainer);
else { else {
_subGroup = g; subGroup = g;
loadCache(); //TODO: Proper subset update setProgress(0, 0);
if(Settings.instance.subscriptions.fetchOnTabOpen) {
loadCache();
loadResults(false);
}
else if(g != null && StateSubscriptions.instance.getFeed(g.id) != null) {
loadResults(false);
}
else
loadCache();
} }
}; };
_subscriptionBar?.onHoldGroup?.subscribe { g -> _subscriptionBar?.onHoldGroup?.subscribe { g ->
@@ -275,7 +306,8 @@ class SubscriptionsFeedFragment : MainFragment() {
SubscriptionBar.Toggle(context.getString(R.string.videos), _filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), it); }, SubscriptionBar.Toggle(context.getString(R.string.videos), _filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), it); },
SubscriptionBar.Toggle(context.getString(R.string.posts), _filterSettings.allowContentTypes.contains(ContentType.POST)) { toggleFilterContentType(ContentType.POST, it); }, SubscriptionBar.Toggle(context.getString(R.string.posts), _filterSettings.allowContentTypes.contains(ContentType.POST)) { toggleFilterContentType(ContentType.POST, it); },
SubscriptionBar.Toggle(context.getString(R.string.live), _filterSettings.allowLive) { _filterSettings.allowLive = it; _filterSettings.save(); loadResults(false); }, SubscriptionBar.Toggle(context.getString(R.string.live), _filterSettings.allowLive) { _filterSettings.allowLive = it; _filterSettings.save(); loadResults(false); },
SubscriptionBar.Toggle(context.getString(R.string.planned), _filterSettings.allowPlanned) { _filterSettings.allowPlanned = it; _filterSettings.save(); loadResults(false); } SubscriptionBar.Toggle(context.getString(R.string.planned), _filterSettings.allowPlanned) { _filterSettings.allowPlanned = it; _filterSettings.save(); loadResults(false); },
SubscriptionBar.Toggle(context.getString(R.string.watched), _filterSettings.allowWatched) { _filterSettings.allowWatched = it; _filterSettings.save(); loadResults(false); }
); );
} }
@@ -303,10 +335,13 @@ class SubscriptionsFeedFragment : MainFragment() {
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> { override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
val nowSoon = OffsetDateTime.now().plusMinutes(5); val nowSoon = OffsetDateTime.now().plusMinutes(5);
val filterGroup = _subGroup; val filterGroup = subGroup;
return results.filter { return results.filter {
val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType); val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType);
if(it is IPlatformVideo && it.duration > 0 && !_filterSettings.allowWatched && StateHistory.instance.isHistoryWatched(it.url, it.duration))
return@filter false;
//TODO: Check against a sub cache //TODO: Check against a sub cache
if(filterGroup != null && !filterGroup.urls.contains(it.author.url)) if(filterGroup != null && !filterGroup.urls.contains(it.author.url))
return@filter false; return@filter false;
@@ -403,7 +438,7 @@ class SubscriptionsFeedFragment : MainFragment() {
context?.let { context?.let {
fragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
try { try {
if (exs.size <= 8) { if (exs.size <= 3) {
for (ex in exs) { for (ex in exs) {
var toShow = ex; var toShow = ex;
var channel: String? = null; var channel: String? = null;
@@ -413,15 +448,17 @@ class SubscriptionsFeedFragment : MainFragment() {
} }
Logger.e(TAG, "Channel [${channel}] failed", ex); Logger.e(TAG, "Channel [${channel}] failed", ex);
if (toShow is PluginException) if (toShow is PluginException)
UIDialogs.toast( UIDialogs.appToast(ToastView.Toast(
it, toShow.message +
context.getString(R.string.plugin_pluginname_failed_message).replace("{pluginName}", toShow.config.name).replace("{message}", toShow.message ?: "") (if(channel != null) "\nChannel: " + channel else ""), false, null,
"Plugin ${toShow.config.name} failed")
); );
else else
UIDialogs.toast(it, ex.message ?: ""); UIDialogs.appToast(ex.message ?: "");
} }
} }
else { else {
val failedChannels = exs.filterIsInstance<ChannelException>().map { it.channelNameOrUrl }.distinct().toList();
val failedPlugins = exs.filter { it is PluginException || (it is ChannelException && it.cause is PluginException) } val failedPlugins = exs.filter { it is PluginException || (it is ChannelException && it.cause is PluginException) }
.map { if(it is ChannelException) (it.cause as PluginException) else if(it is PluginException) it else null } .map { if(it is ChannelException) (it.cause as PluginException) else if(it is PluginException) it else null }
.filter { it != null } .filter { it != null }
@@ -429,7 +466,10 @@ class SubscriptionsFeedFragment : MainFragment() {
.map { it!! } .map { it!! }
.toList(); .toList();
for(distinctPluginFail in failedPlugins) for(distinctPluginFail in failedPlugins)
UIDialogs.toast(it, context.getString(R.string.plugin_pluginname_failed_message).replace("{pluginName}", distinctPluginFail.config.name).replace("{message}", distinctPluginFail.message ?: "")); UIDialogs.appToast(context.getString(R.string.plugin_pluginname_failed_message).replace("{pluginName}", distinctPluginFail.config.name).replace("{message}", distinctPluginFail.message ?: ""));
if(failedChannels.isNotEmpty())
UIDialogs.appToast(ToastView.Toast(failedChannels.take(3).map { "- ${it}" }.joinToString("\n") +
(if(failedChannels.size >= 3) "\nAnd ${failedChannels.size - 3} more" else ""), false, null, "Failed Channels"));
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to handle exceptions", e) Logger.e(TAG, "Failed to handle exceptions", e)
@@ -0,0 +1,221 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnail
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
import com.futo.platformplayer.views.pills.WidePillButton
import java.time.OffsetDateTime
class TutorialFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var _view: TutorialView? = null;
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
(topBar as NavigationTopBarFragment?)?.onShown(getString(R.string.tutorials));
}
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = TutorialView(this, inflater);
_view = view;
return view;
}
override fun onDestroyMainView() {
super.onDestroyMainView();
_view = null;
}
@SuppressLint("ViewConstructor")
class TutorialView : LinearLayout {
val fragment: TutorialFragment
constructor(fragment: TutorialFragment, inflater: LayoutInflater) : super(inflater.context) {
this.fragment = fragment
orientation = VERTICAL
addView(createHeader("Initial setup"))
initialSetupVideos.forEach {
addView(createTutorialPill(R.drawable.ic_movie, it.name, it.description).apply {
onClick.subscribe {
fragment.navigate<VideoDetailFragment>(it)
}
})
}
addView(createHeader("Features"))
featuresVideos.forEach {
addView(createTutorialPill(R.drawable.ic_movie, it.name, it.description).apply {
onClick.subscribe {
fragment.navigate<VideoDetailFragment>(it)
}
})
}
}
private fun createHeader(t: String): TextView {
return TextView(context).apply {
textSize = 24.0f
typeface = resources.getFont(R.font.inter_regular)
text = t
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply {
setMargins(15.dp(resources), 10.dp(resources), 15.dp(resources), 12.dp(resources))
}
}
}
private fun createTutorialPill(iconPrefix: Int, t: String, d: String): WidePillButton {
return WidePillButton(context).apply {
setIconPrefix(iconPrefix)
setText(t)
setDescription(d)
setIconSuffix(R.drawable.ic_play_notif)
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
setMargins(15.dp(resources), 0, 15.dp(resources), 12.dp(resources))
}
}
}
}
class TutorialVideoSourceDescriptor(url: String, duration: Long, width: Int, height: Int) : VideoUnMuxedSourceDescriptor() {
override val videoSources: Array<IVideoSource> = arrayOf(
VideoUrlSource("Original", url, width, height, duration, "video/mp4")
)
override val audioSources: Array<IAudioSource> = arrayOf()
}
class TutorialVideo(
uuid: String,
override val name: String,
override val description: String,
thumbnailUrl: String,
videoUrl: String,
override val duration: Long,
width: Int = 1920,
height: Int = 1080
) : IPlatformVideoDetails {
override val id: PlatformID = PlatformID("tutorial", uuid)
override val contentType: ContentType = ContentType.MEDIA
override val preview: IVideoSourceDescriptor? = null
override val live: IVideoSource? = null
override val dash: IDashManifestSource? = null
override val hls: IHLSManifestSource? = null
override val subtitles: List<ISubtitleSource> = emptyList()
override val shareUrl: String = videoUrl
override val url: String = videoUrl
override val datetime: OffsetDateTime? = OffsetDateTime.parse("2023-12-18T00:00:00Z")
override val thumbnails: Thumbnails = Thumbnails(arrayOf(Thumbnail(thumbnailUrl)))
override val author: PlatformAuthorLink = PlatformAuthorLink(PlatformID("tutorial", "f422ced6-b551-4b62-818e-27a4f5f4918a"), "Grayjay", "", "https://releases.grayjay.app/tutorials/author.jpeg")
override val isLive: Boolean = false
override val rating: IRating = RatingLikes(-1)
override val viewCount: Long = -1
override val video: IVideoSourceDescriptor = TutorialVideoSourceDescriptor(videoUrl, duration, width, height)
override fun getComments(client: IPlatformClient): IPager<IPlatformComment> {
return EmptyPager()
}
override fun getPlaybackTracker(): IPlaybackTracker? {
return null
}
}
companion object {
val TAG = "HomeFragment";
fun newInstance() = TutorialFragment().apply {}
val initialSetupVideos = listOf(
TutorialVideo(
uuid = "228be579-ec52-4d93-b9eb-ca74ec08c58a",
name = "How to install",
description = "Learn how to install Grayjay.",
thumbnailUrl = "https://releases.grayjay.app/tutorials/how-to-install.jpg",
videoUrl = "https://releases.grayjay.app/tutorials/how-to-install.mp4",
duration = 52
),
TutorialVideo(
uuid = "3b99ebfe-2640-4643-bfe0-a0cf04261fc5",
name = "Getting started",
description = "Learn how to get started with Grayjay. How do you install plugins?",
thumbnailUrl = "https://releases.grayjay.app/tutorials/getting-started.jpg",
videoUrl = "https://releases.grayjay.app/tutorials/getting-started.mp4",
duration = 50
),
TutorialVideo(
uuid = "793aa009-516c-4581-b82f-a8efdfef4c27",
name = "Is Grayjay free?",
description = "Learn how Grayjay is monetized. How do we make money?",
thumbnailUrl = "https://releases.grayjay.app/tutorials/pay.jpg",
videoUrl = "https://releases.grayjay.app/tutorials/pay.mp4",
duration = 52
)
)
val featuresVideos = listOf(
TutorialVideo(
uuid = "d2238d88-4252-4a91-a12d-b90c049bb7cf",
name = "Searching",
description = "Learn about searching in Grayjay. How can I find channels, videos or playlists?",
thumbnailUrl = "https://releases.grayjay.app/tutorials/search.jpg",
videoUrl = "https://releases.grayjay.app/tutorials/search.mp4",
duration = 39
),
TutorialVideo(
uuid = "d2238d88-4252-4a91-a12d-b90c049bb7cf",
name = "Comments",
description = "Learn about Polycentric comments in Grayjay.",
thumbnailUrl = "https://releases.grayjay.app/tutorials/polycentric.jpg",
videoUrl = "https://releases.grayjay.app/tutorials/polycentric.mp4",
duration = 64
),
TutorialVideo(
uuid = "94d36959-e3fc-4c24-a988-89147067a179",
name = "Casting",
description = "Learn about casting in Grayjay. How do I show video on my TV?",
thumbnailUrl = "https://releases.grayjay.app/tutorials/how-to-cast.jpg",
videoUrl = "https://releases.grayjay.app/tutorials/how-to-cast.mp4",
duration = 79
),
TutorialVideo(
uuid = "5128c2e3-852b-4281-869b-efea2ec82a0e",
name = "Monetization",
description = "How can I monetize as a creator?",
thumbnailUrl = "https://releases.grayjay.app/tutorials/monetization.jpg",
videoUrl = "https://releases.grayjay.app/tutorials/monetization.mp4",
duration = 47,
1080,
1920
)
)
}
}
@@ -25,8 +25,6 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.PlatformVideoWithTime import com.futo.platformplayer.models.PlatformVideoWithTime
import com.futo.platformplayer.models.UrlVideoWithTime import com.futo.platformplayer.models.UrlVideoWithTime
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StateSaved
import com.futo.platformplayer.states.VideoToOpen
import com.futo.platformplayer.views.containers.SingleViewTouchableMotionLayout import com.futo.platformplayer.views.containers.SingleViewTouchableMotionLayout
class VideoDetailFragment : MainFragment { class VideoDetailFragment : MainFragment {
@@ -171,14 +169,14 @@ class VideoDetailFragment : MainFragment {
_view!!.transitionToStart(); _view!!.transitionToStart();
} }
fun maximizeVideoDetail(instant: Boolean = false) { fun maximizeVideoDetail(instant: Boolean = false) {
if(_maximizeProgress > 0.9f && state != State.MAXIMIZED) { if((_maximizeProgress > 0.9f || instant) && state != State.MAXIMIZED) {
state = State.MAXIMIZED; state = State.MAXIMIZED;
onMaximized.emit(); onMaximized.emit();
} }
_view?.let { _view?.let {
if(!instant) if(!instant) {
it.transitionToEnd(); it.transitionToEnd();
else { } else {
it.progress = 1f; it.progress = 1f;
onTransitioning.emit(true); onTransitioning.emit(true);
} }
@@ -372,11 +370,6 @@ class VideoDetailFragment : MainFragment {
Logger.v(TAG, "shouldStop: $shouldStop"); Logger.v(TAG, "shouldStop: $shouldStop");
if(shouldStop) { if(shouldStop) {
_viewDetail?.let {
val v = it.video ?: return@let;
StateSaved.instance.setVideoToOpenBlocking(VideoToOpen(v.url, (it.lastPositionMilliseconds / 1000.0f).toLong()));
}
_viewDetail?.onStop(); _viewDetail?.onStop();
StateCasting.instance.onStop(); StateCasting.instance.onStop();
Logger.v(TAG, "called onStop() shouldStop: $shouldStop"); Logger.v(TAG, "called onStop() shouldStop: $shouldStop");
@@ -431,6 +424,7 @@ class VideoDetailFragment : MainFragment {
changeOrientation(OrientationManager.Orientation.PORTRAIT); changeOrientation(OrientationManager.Orientation.PORTRAIT);
} }
isFullscreen = fullscreen; isFullscreen = fullscreen;
_view?.allowMotion = !fullscreen;
} }
private fun changeOrientation(orientation: OrientationManager.Orientation) { private fun changeOrientation(orientation: OrientationManager.Orientation) {
Logger.i(TAG, "Orientation Change:" + orientation.name); Logger.i(TAG, "Orientation Change:" + orientation.name);
@@ -50,6 +50,7 @@ import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetExcept
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
import com.futo.platformplayer.api.media.models.chapters.ChapterType import com.futo.platformplayer.api.media.models.chapters.ChapterType
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
@@ -144,10 +145,11 @@ import com.futo.polycentric.core.Opinion
import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.protobuf.ByteString import com.google.protobuf.ByteString
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import userpackage.Protocol import userpackage.Protocol
import java.time.OffsetDateTime import java.time.OffsetDateTime
@@ -251,6 +253,7 @@ class VideoDetailView : ConstraintLayout {
private val _layoutRating: LinearLayout; private val _layoutRating: LinearLayout;
private val _imageDislikeIcon: ImageView; private val _imageDislikeIcon: ImageView;
private val _imageLikeIcon: ImageView; private val _imageLikeIcon: ImageView;
private val _layoutToggleCommentSection: LinearLayout;
private val _monetization: MonetizationView; private val _monetization: MonetizationView;
@@ -327,6 +330,7 @@ class VideoDetailView : ConstraintLayout {
_upNext = findViewById(R.id.up_next); _upNext = findViewById(R.id.up_next);
_textCommentType = findViewById(R.id.text_comment_type); _textCommentType = findViewById(R.id.text_comment_type);
_toggleCommentType = findViewById(R.id.toggle_comment_type); _toggleCommentType = findViewById(R.id.toggle_comment_type);
_layoutToggleCommentSection = findViewById(R.id.layout_toggle_comment_section);
_overlayContainer = findViewById(R.id.overlay_container); _overlayContainer = findViewById(R.id.overlay_container);
_overlay_quality_container = findViewById(R.id.videodetail_quality_overview); _overlay_quality_container = findViewById(R.id.videodetail_quality_overview);
@@ -369,7 +373,7 @@ class VideoDetailView : ConstraintLayout {
_buttonSubscribe.onSubscribed.subscribe { _buttonSubscribe.onSubscribed.subscribe {
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer); _slideUpOverlay = UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
}; };
_container_content_liveChat.onRaidNow.subscribe { _container_content_liveChat.onRaidNow.subscribe {
@@ -394,6 +398,10 @@ class VideoDetailView : ConstraintLayout {
} }
} }
}; };
_monetization.onUrlTap.subscribe {
fragment.navigate<BrowserFragment>(it);
onMinimize.emit();
}
_player.attachPlayer(); _player.attachPlayer();
@@ -433,18 +441,21 @@ class VideoDetailView : ConstraintLayout {
_buttonPins.alwaysShowLastButton = true; _buttonPins.alwaysShowLastButton = true;
var buttonMore: RoundButton? = null; var buttonMore: RoundButton? = null;
buttonMore = RoundButton(context, R.drawable.ic_menu, "More", TAG_MORE) { buttonMore = RoundButton(context, R.drawable.ic_menu, context.getString(R.string.more), TAG_MORE) {
_slideUpOverlay = UISlideOverlays.showMoreButtonOverlay(_overlayContainer, _buttonPins, listOf(TAG_MORE)) {selected -> _slideUpOverlay = UISlideOverlays.showMoreButtonOverlay(_overlayContainer, _buttonPins, listOf(TAG_MORE), false) {selected ->
_buttonPins.setButtons(*(selected + listOf(buttonMore!!)).toTypedArray()); _buttonPins.setButtons(*(selected + listOf(buttonMore!!)).toTypedArray());
_buttonPinStore.set(*selected.filter { it.tagRef is String }.map{ it.tagRef as String }.toTypedArray()) _buttonPinStore.set(*selected.filter { it.tagRef is String }.map{ it.tagRef as String }.toTypedArray())
_buttonPinStore.save(); _buttonPinStore.save();
} };
}; };
_buttonMore = buttonMore; _buttonMore = buttonMore;
updateMoreButtons(); updateMoreButtons();
_channelButton.setOnClickListener { _channelButton.setOnClickListener {
if (video is TutorialFragment.TutorialVideo) {
return@setOnClickListener
}
(video?.author ?: _searchVideo?.author)?.let { (video?.author ?: _searchVideo?.author)?.let {
fragment.navigate<ChannelFragment>(it); fragment.navigate<ChannelFragment>(it);
fragment.lifecycleScope.launch { fragment.lifecycleScope.launch {
@@ -459,20 +470,29 @@ class VideoDetailView : ConstraintLayout {
_cast.onSettingsClick.subscribe { showVideoSettings() }; _cast.onSettingsClick.subscribe { showVideoSettings() };
_player.onVideoSettings.subscribe { showVideoSettings() }; _player.onVideoSettings.subscribe { showVideoSettings() };
_player.onToggleFullScreen.subscribe(::handleFullScreen); _player.onToggleFullScreen.subscribe(::handleFullScreen);
_player.onChapterChanged.subscribe { chapter, isScrub ->
val onChapterChanged = { chapter: IChapter?, isScrub: Boolean ->
if(_layoutSkip.visibility == VISIBLE && chapter?.type != ChapterType.SKIPPABLE) if(_layoutSkip.visibility == VISIBLE && chapter?.type != ChapterType.SKIPPABLE)
_layoutSkip.visibility = GONE; _layoutSkip.visibility = GONE;
if(!isScrub) { if(!isScrub) {
if(chapter?.type == ChapterType.SKIPPABLE) { if(chapter?.type == ChapterType.SKIPPABLE) {
_layoutSkip.visibility = VISIBLE; _layoutSkip.visibility = VISIBLE;
} } else if(chapter?.type == ChapterType.SKIP || chapter?.type == ChapterType.SKIPONCE) {
else if(chapter?.type == ChapterType.SKIP) { val ad = StateCasting.instance.activeDevice
_player.seekTo((chapter.timeEnd * 1000).toLong()); if (ad != null) {
ad.seekVideo(chapter.timeEnd)
} else {
_player.seekTo((chapter.timeEnd * 1000).toLong());
}
UIDialogs.toast(context, "Skipped chapter [${chapter.name}]", false); UIDialogs.toast(context, "Skipped chapter [${chapter.name}]", false);
} }
} }
} };
_player.onChapterChanged.subscribe(onChapterChanged);
_cast.onChapterChanged.subscribe(onChapterChanged);
_cast.onMinimizeClick.subscribe { _cast.onMinimizeClick.subscribe {
_player.setFullScreen(false); _player.setFullScreen(false);
@@ -667,9 +687,17 @@ class VideoDetailView : ConstraintLayout {
}; };
_layoutSkip.setOnClickListener { _layoutSkip.setOnClickListener {
val currentChapter = _player.getCurrentChapter(_player.position); val ad = StateCasting.instance.activeDevice;
if(currentChapter?.type == ChapterType.SKIPPABLE) { if (ad != null) {
_player.seekTo((currentChapter.timeEnd * 1000).toLong()); val currentChapter = _cast.getCurrentChapter((ad.time * 1000).toLong());
if(currentChapter?.type == ChapterType.SKIPPABLE) {
ad.seekVideo(currentChapter.timeEnd);
}
} else {
val currentChapter = _player.getCurrentChapter(_player.position);
if(currentChapter?.type == ChapterType.SKIPPABLE) {
_player.seekTo((currentChapter.timeEnd * 1000).toLong());
}
} }
} }
} }
@@ -737,7 +765,9 @@ class VideoDetailView : ConstraintLayout {
fun updateMoreButtons() { fun updateMoreButtons() {
val buttons = listOf(RoundButton(context, R.drawable.ic_add, context.getString(R.string.add), TAG_ADD) { val buttons = listOf(RoundButton(context, R.drawable.ic_add, context.getString(R.string.add), TAG_ADD) {
(video ?: _searchVideo)?.let { (video ?: _searchVideo)?.let {
_slideUpOverlay = UISlideOverlays.showAddToOverlay(it, _overlayContainer); _slideUpOverlay = UISlideOverlays.showAddToOverlay(it, _overlayContainer) {
_slideUpOverlay = it
};
} }
}, },
if(video?.isLive ?: false) if(video?.isLive ?: false)
@@ -750,6 +780,7 @@ class VideoDetailView : ConstraintLayout {
Logger.e(TAG, "Failed to reopen live chat", ex); Logger.e(TAG, "Failed to reopen live chat", ex);
} }
} }
_slideUpOverlay?.hide();
} else null, } else null,
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.background), TAG_BACKGROUND) { RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.background), TAG_BACKGROUND) {
if(!allowBackground) { if(!allowBackground) {
@@ -762,6 +793,7 @@ class VideoDetailView : ConstraintLayout {
allowBackground = false; allowBackground = false;
it.text.text = resources.getString(R.string.background); it.text.text = resources.getString(R.string.background);
} }
_slideUpOverlay?.hide();
}, },
RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) { RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) {
video?.let { video?.let {
@@ -774,11 +806,13 @@ class VideoDetailView : ConstraintLayout {
preventPictureInPicture = true; preventPictureInPicture = true;
shareVideo(); shareVideo();
}; };
_slideUpOverlay?.hide();
}, },
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.overlay), TAG_OVERLAY) { RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.overlay), TAG_OVERLAY) {
this.startPictureInPicture(); this.startPictureInPicture();
fragment.forcePictureInPicture(); fragment.forcePictureInPicture();
//PiPActivity.startPiP(context); //PiPActivity.startPiP(context);
_slideUpOverlay?.hide();
}, },
RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) { RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) {
video?.let { video?.let {
@@ -786,9 +820,11 @@ class VideoDetailView : ConstraintLayout {
fragment.navigate<BrowserFragment>(url); fragment.navigate<BrowserFragment>(url);
fragment.minimizeVideoDetail(); fragment.minimizeVideoDetail();
}; };
_slideUpOverlay?.hide();
}, },
RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") { RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") {
reloadVideo(); reloadVideo();
_slideUpOverlay?.hide();
}).filterNotNull(); }).filterNotNull();
if(!_buttonPinStore.getAllValues().any()) if(!_buttonPinStore.getAllValues().any())
_buttonPins.setButtons(*(buttons + listOf(_buttonMore)).toTypedArray()); _buttonPins.setButtons(*(buttons + listOf(_buttonMore)).toTypedArray());
@@ -824,14 +860,19 @@ class VideoDetailView : ConstraintLayout {
} }
} }
} }
suspend fun getHistoryIndex(video: IPlatformVideo): DBHistory.Index = withContext(Dispatchers.IO){
val current = _historyIndex;
if(current == null || current.url != video.url) { private val _historyIndexLock = Mutex(false);
val index = StateHistory.instance.getHistoryByVideo(video, true)!!; suspend fun getHistoryIndex(video: IPlatformVideo): DBHistory.Index? = withContext(Dispatchers.IO){
_historyIndex = index; _historyIndexLock.withLock {
return@withContext index; val current = _historyIndex;
if(current == null || current.url != video.url) {
val index = StateHistory.instance.getHistoryByVideo(video, true);
_historyIndex = index;
return@withContext index;
}
return@withContext current;
} }
return@withContext current;
} }
@@ -968,6 +1009,9 @@ class VideoDetailView : ConstraintLayout {
fun setVideo(url: String, resumeSeconds: Long = 0, playWhenReady: Boolean = true) { fun setVideo(url: String, resumeSeconds: Long = 0, playWhenReady: Boolean = true) {
Logger.i(TAG, "setVideo url=$url resumeSeconds=$resumeSeconds playWhenReady=$playWhenReady") Logger.i(TAG, "setVideo url=$url resumeSeconds=$resumeSeconds playWhenReady=$playWhenReady")
if(this.video?.url == url)
return;
_searchVideo = null; _searchVideo = null;
video = null; video = null;
_playbackTracker = null; _playbackTracker = null;
@@ -995,9 +1039,12 @@ class VideoDetailView : ConstraintLayout {
switchContentView(_container_content_main); switchContentView(_container_content_main);
} }
fun setVideoOverview(video: IPlatformVideo, fetch: Boolean = true, resumeSeconds: Long = 0) { fun setVideoOverview(video: IPlatformVideo, fetch: Boolean = true, resumeSeconds: Long = 0, bypassSameVideoCheck: Boolean = false) {
Logger.i(TAG, "setVideoOverview") Logger.i(TAG, "setVideoOverview")
if(!bypassSameVideoCheck && this.video?.url == video.url)
return;
val cachedVideo = StateDownloads.instance.getCachedVideo(video.id); val cachedVideo = StateDownloads.instance.getCachedVideo(video.id);
if(cachedVideo != null) { if(cachedVideo != null) {
setVideoDetails(cachedVideo, true); setVideoDetails(cachedVideo, true);
@@ -1092,10 +1139,13 @@ class VideoDetailView : ConstraintLayout {
switchContentView(_container_content_main); switchContentView(_container_content_main);
} }
@OptIn(ExperimentalCoroutinesApi::class) //@OptIn(ExperimentalCoroutinesApi::class)
fun setVideoDetails(videoDetail: IPlatformVideoDetails, newVideo: Boolean = false) { fun setVideoDetails(videoDetail: IPlatformVideoDetails, newVideo: Boolean = false) {
Logger.i(TAG, "setVideoDetails (${videoDetail.name})") Logger.i(TAG, "setVideoDetails (${videoDetail.name})")
if(newVideo && this.video?.url == videoDetail.url)
return;
if (newVideo) { if (newVideo) {
_lastVideoSource = null; _lastVideoSource = null;
_lastAudioSource = null; _lastAudioSource = null;
@@ -1115,6 +1165,7 @@ class VideoDetailView : ConstraintLayout {
if(videoDetail is VideoLocal) { if(videoDetail is VideoLocal) {
videoLocal = videoDetail; videoLocal = videoDetail;
video = videoDetail; video = videoDetail;
this.video = video;
val videoTask = StatePlatform.instance.getContentDetails(videoDetail.url); val videoTask = StatePlatform.instance.getContentDetails(videoDetail.url);
videoTask.invokeOnCompletion { ex -> videoTask.invokeOnCompletion { ex ->
if(ex != null) { if(ex != null) {
@@ -1145,10 +1196,12 @@ class VideoDetailView : ConstraintLayout {
//TODO: Implement video.getContentChapters() //TODO: Implement video.getContentChapters()
val chapters = null ?: StatePlatform.instance.getContentChapters(video.url); val chapters = null ?: StatePlatform.instance.getContentChapters(video.url);
_player.setChapters(chapters); _player.setChapters(chapters);
_cast.setChapters(chapters);
} }
catch(ex: Throwable) { catch(ex: Throwable) {
Logger.e(TAG, "Failed to get chapters", ex); Logger.e(TAG, "Failed to get chapters", ex);
_player.setChapters(null); _player.setChapters(null);
_cast.setChapters(null);
/*withContext(Dispatchers.Main) { /*withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to get chapters\n" + ex.message); UIDialogs.toast(context, "Failed to get chapters\n" + ex.message);
@@ -1180,12 +1233,17 @@ class VideoDetailView : ConstraintLayout {
}; };
} }
val ref = video.id.value?.let { Models.referenceFromBuffer(it.toByteArray()) }; val ref = Models.referenceFromBuffer(video.url.toByteArray())
_addCommentView.setContext(video.url, ref); val extraBytesRef = video.id.value?.let { if (it.isNotEmpty()) it.toByteArray() else null }
_addCommentView.setContext(video.url, ref)
_player.setMetadata(video.name, video.author.name); _player.setMetadata(video.name, video.author.name);
_toggleCommentType.setValue(!Settings.instance.other.polycentricEnabled || Settings.instance.comments.defaultCommentSection == 1, false); if (video is TutorialFragment.TutorialVideo) {
_toggleCommentType.setValue(false, false);
} else {
_toggleCommentType.setValue(!Settings.instance.other.polycentricEnabled || Settings.instance.comments.defaultCommentSection == 1, false);
}
updateCommentType(true); updateCommentType(true);
//UI //UI
@@ -1238,57 +1296,54 @@ class VideoDetailView : ConstraintLayout {
_rating.onLikeDislikeUpdated.remove(this); _rating.onLikeDislikeUpdated.remove(this);
if (ref != null) { _rating.visibility = View.GONE;
_rating.visibility = View.GONE;
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val queryReferencesResponse = ApiMethods.getQueryReferences(PolycentricCache.SERVER, ref, null,null, val queryReferencesResponse = ApiMethods.getQueryReferences(PolycentricCache.SERVER, ref, null,null,
arrayListOf( arrayListOf(
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(ContentType.OPINION.value).setValue( Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.like.data)).build(), ByteString.copyFrom(Opinion.like.data)).build(),
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(ContentType.OPINION.value).setValue( Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(ContentType.OPINION.value).setValue(
ByteString.copyFrom(Opinion.dislike.data)).build() ByteString.copyFrom(Opinion.dislike.data)).build()
) ),
); extraByteReferences = listOfNotNull(extraBytesRef)
);
val likes = queryReferencesResponse.countsList[0]; val likes = queryReferencesResponse.countsList[0];
val dislikes = queryReferencesResponse.countsList[1]; val dislikes = queryReferencesResponse.countsList[1];
val hasLiked = StatePolycentric.instance.hasLiked(ref); val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
val hasDisliked = StatePolycentric.instance.hasDisliked(ref); val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_rating.visibility = View.VISIBLE; _rating.visibility = View.VISIBLE;
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked); _rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
_rating.onLikeDislikeUpdated.subscribe(this) { args -> _rating.onLikeDislikeUpdated.subscribe(this) { args ->
if (args.hasLiked) { if (args.hasLiked) {
args.processHandle.opinion(ref, Opinion.like); args.processHandle.opinion(ref, Opinion.like);
} else if (args.hasDisliked) { } else if (args.hasDisliked) {
args.processHandle.opinion(ref, Opinion.dislike); args.processHandle.opinion(ref, Opinion.dislike);
} else { } else {
args.processHandle.opinion(ref, Opinion.neutral); args.processHandle.opinion(ref, Opinion.neutral);
}
fragment.lifecycleScope.launch(Dispatchers.IO) {
try {
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e)
} }
}
fragment.lifecycleScope.launch(Dispatchers.IO) { StatePolycentric.instance.updateLikeMap(ref, args.hasLiked, args.hasDisliked)
try { };
Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e)
}
}
StatePolycentric.instance.updateLikeMap(ref, args.hasLiked, args.hasDisliked)
};
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
_rating.visibility = View.GONE;
} }
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
_rating.visibility = View.GONE;
} }
} else {
_rating.visibility = View.GONE;
} }
when (video.rating) { when (video.rating) {
@@ -1331,32 +1386,36 @@ class VideoDetailView : ConstraintLayout {
val toResume = _videoResumePositionMilliseconds; val toResume = _videoResumePositionMilliseconds;
_videoResumePositionMilliseconds = 0; _videoResumePositionMilliseconds = 0;
loadCurrentVideo(toResume); loadCurrentVideo(toResume);
_player.setGestureSoundFactor(1.0f); if (!Settings.instance.gestureControls.useSystemVolume) {
_player.setGestureSoundFactor(1.0f);
}
updateQueueState(); updateQueueState();
fragment.lifecycleScope.launch(Dispatchers.IO) { if (video !is TutorialFragment.TutorialVideo) {
val historyItem = getHistoryIndex(videoDetail); fragment.lifecycleScope.launch(Dispatchers.IO) {
val historyItem = getHistoryIndex(videoDetail) ?: return@launch;
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_historicalPosition = StateHistory.instance.updateHistoryPosition(video, historyItem,false, (toResume.toFloat() / 1000.0f).toLong()); _historicalPosition = StateHistory.instance.updateHistoryPosition(video, historyItem,false, (toResume.toFloat() / 1000.0f).toLong());
Logger.i(TAG, "Historical position: $_historicalPosition, last position: $lastPositionMilliseconds"); Logger.i(TAG, "Historical position: $_historicalPosition, last position: $lastPositionMilliseconds");
if (_historicalPosition > 60 && video.duration - _historicalPosition > 5 && Math.abs(_historicalPosition - lastPositionMilliseconds / 1000) > 5.0) { if (_historicalPosition > 60 && video.duration - _historicalPosition > 5 && Math.abs(_historicalPosition - lastPositionMilliseconds / 1000) > 5.0) {
_layoutResume.visibility = View.VISIBLE; _layoutResume.visibility = View.VISIBLE;
_textResume.text = "Resume at ${_historicalPosition.toHumanTime(false)}"; _textResume.text = "Resume at ${_historicalPosition.toHumanTime(false)}";
_jobHideResume = fragment.lifecycleScope.launch(Dispatchers.Main) { _jobHideResume = fragment.lifecycleScope.launch(Dispatchers.Main) {
try { try {
delay(8000); delay(8000);
_layoutResume.visibility = View.GONE; _layoutResume.visibility = View.GONE;
_textResume.text = ""; _textResume.text = "";
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to set resume changes.", e); Logger.e(TAG, "Failed to set resume changes.", e);
}
} }
} else {
_layoutResume.visibility = View.GONE;
_textResume.text = "";
} }
} else {
_layoutResume.visibility = View.GONE;
_textResume.text = "";
} }
} }
} }
@@ -1372,6 +1431,20 @@ class VideoDetailView : ConstraintLayout {
_player.updateNextPrevious(); _player.updateNextPrevious();
updateMoreButtons(); updateMoreButtons();
if (videoDetail is TutorialFragment.TutorialVideo) {
_buttonSubscribe.visibility = View.GONE
_buttonMore.visibility = View.GONE
_buttonPins.visibility = View.GONE
_layoutRating.visibility = View.GONE
_layoutToggleCommentSection.visibility = View.GONE
} else {
_buttonSubscribe.visibility = View.VISIBLE
_buttonMore.visibility = View.VISIBLE
_buttonPins.visibility = View.VISIBLE
_layoutRating.visibility = View.VISIBLE
_layoutToggleCommentSection.visibility = View.VISIBLE
}
} }
fun loadLiveChat(video: IPlatformVideoDetails) { fun loadLiveChat(video: IPlatformVideoDetails) {
_liveChat?.stop(); _liveChat?.stop();
@@ -1434,12 +1507,12 @@ class VideoDetailView : ConstraintLayout {
private fun loadCurrentVideo(resumePositionMs: Long = 0) { private fun loadCurrentVideo(resumePositionMs: Long = 0) {
_didStop = false; _didStop = false;
val video = video ?: return; val video = (videoLocal ?: video) ?: return;
try { try {
val videoSource = _lastVideoSource ?: _player.getPreferredVideoSource(video, Settings.instance.playback.getCurrentPreferredQualityPixelCount()); val videoSource = _lastVideoSource ?: _player.getPreferredVideoSource(video, Settings.instance.playback.getCurrentPreferredQualityPixelCount());
val audioSource = _lastAudioSource ?: _player.getPreferredAudioSource(video, Settings.instance.playback.getPrimaryLanguage(context)); val audioSource = _lastAudioSource ?: _player.getPreferredAudioSource(video, Settings.instance.playback.getPrimaryLanguage(context));
val subtitleSource = _lastSubtitleSource; val subtitleSource = _lastSubtitleSource ?: (if(video is VideoLocal) video.subtitlesSources.firstOrNull() else null);
Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)") Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)")
if(videoSource == null && audioSource == null) { if(videoSource == null && audioSource == null) {
@@ -1467,6 +1540,8 @@ class VideoDetailView : ConstraintLayout {
_player.setArtwork(null); _player.setArtwork(null);
_player.setSource(videoSource, audioSource, _playWhenReady, false); _player.setSource(videoSource, audioSource, _playWhenReady, false);
if(subtitleSource != null)
_player.swapSubtitles(fragment.lifecycleScope, subtitleSource);
_player.seekTo(resumePositionMs); _player.seekTo(resumePositionMs);
} }
else else
@@ -1474,6 +1549,7 @@ class VideoDetailView : ConstraintLayout {
_lastVideoSource = videoSource; _lastVideoSource = videoSource;
_lastAudioSource = audioSource; _lastAudioSource = audioSource;
_lastSubtitleSource = subtitleSource;
} }
catch(ex: UnsupportedCastException) { catch(ex: UnsupportedCastException) {
Logger.e(TAG, "Failed to load cast media", ex); Logger.e(TAG, "Failed to load cast media", ex);
@@ -1591,7 +1667,7 @@ class VideoDetailView : ConstraintLayout {
Logger.i(TAG, "prevVideo") Logger.i(TAG, "prevVideo")
val next = StatePlayer.instance.prevQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9); val next = StatePlayer.instance.prevQueueItem(withoutRemoval || _player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9);
if(next != null) { if(next != null) {
setVideoOverview(next); setVideoOverview(next, true, 0, true);
} }
} }
@@ -1601,7 +1677,7 @@ class VideoDetailView : ConstraintLayout {
if(next == null && forceLoop) if(next == null && forceLoop)
next = StatePlayer.instance.restartQueue(); next = StatePlayer.instance.restartQueue();
if(next != null) { if(next != null) {
setVideoOverview(next); setVideoOverview(next, true, 0, true);
return true; return true;
} }
else else
@@ -1908,13 +1984,15 @@ class VideoDetailView : ConstraintLayout {
Logger.i(TAG, "fetchPolycentricComments") Logger.i(TAG, "fetchPolycentricComments")
val video = video; val video = video;
val idValue = video?.id?.value val idValue = video?.id?.value
if (idValue == null) { if (video?.url?.isEmpty() != false) {
Logger.w(TAG, "Failed to fetch polycentric comments because id was null") Logger.w(TAG, "Failed to fetch polycentric comments because url was null")
_commentsList.clear() _commentsList.clear()
return return
} }
_commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, Models.referenceFromBuffer(idValue.toByteArray())); }; val ref = Models.referenceFromBuffer(video.url.toByteArray())
val extraBytesRef = idValue?.let { if (it.isNotEmpty()) it.toByteArray() else null }
_commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, ref, listOfNotNull(extraBytesRef)); };
} }
private fun fetchVideo() { private fun fetchVideo() {
Logger.i(TAG, "fetchVideo") Logger.i(TAG, "fetchVideo")
@@ -2176,9 +2254,11 @@ class VideoDetailView : ConstraintLayout {
val v = video ?: return; val v = video ?: return;
val currentTime = System.currentTimeMillis(); val currentTime = System.currentTimeMillis();
if (updateHistory && (_lastPositionSaveTime == -1L || currentTime - _lastPositionSaveTime > 5000)) { if (updateHistory && (_lastPositionSaveTime == -1L || currentTime - _lastPositionSaveTime > 5000)) {
fragment.lifecycleScope.launch(Dispatchers.IO) { if (v !is TutorialFragment.TutorialVideo) {
val history = getHistoryIndex(v); fragment.lifecycleScope.launch(Dispatchers.IO) {
StateHistory.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong()); val history = getHistoryIndex(v) ?: return@launch;
StateHistory.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong());
}
} }
_lastPositionSaveTime = currentTime; _lastPositionSaveTime = currentTime;
} }
@@ -2261,7 +2341,7 @@ class VideoDetailView : ConstraintLayout {
_creatorThumbnail.setThumbnail(avatar, animate); _creatorThumbnail.setThumbnail(avatar, animate);
} else { } else {
_creatorThumbnail.setThumbnail(video?.author?.thumbnail, animate); _creatorThumbnail.setThumbnail(video?.author?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate); _creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto());
} }
val username = cachedPolycentricProfile?.profile?.systemState?.username val username = cachedPolycentricProfile?.profile?.systemState?.username
@@ -2285,7 +2365,7 @@ class VideoDetailView : ConstraintLayout {
} }
else if(isOverlayed) { else if(isOverlayed) {
_playerProgress.layoutParams = _playerProgress.layoutParams.apply { _playerProgress.layoutParams = _playerProgress.layoutParams.apply {
(this as MarginLayoutParams).bottomMargin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, -6f, resources.displayMetrics).toInt(); (this as MarginLayoutParams).bottomMargin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, -2f, resources.displayMetrics).toInt();
}; };
_playerProgress.elevation = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics); _playerProgress.elevation = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
} }
@@ -2486,7 +2566,7 @@ class VideoDetailView : ConstraintLayout {
} }
else else
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
setVideoDetails(videoDetail); setVideoDetails(videoDetail, false);
_liveTryJob = null; _liveTryJob = null;
} }
} }
@@ -13,17 +13,17 @@ import android.view.inputmethod.InputMethodManager
import android.widget.EditText import android.widget.EditText
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.TextView import android.widget.TextView
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.stores.SearchHistoryStorage
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.fragment.mainactivity.main.* import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragmentData
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SearchType import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.SearchHistoryStorage
class SearchTopBarFragment : TopFragment() { class SearchTopBarFragment : TopFragment() {
private val TAG = "SearchTopBarFragment" private val TAG = "SearchTopBarFragment"
@@ -54,11 +54,12 @@ class SearchTopBarFragment : TopFragment() {
private val _searchDoneListener = object : TextView.OnEditorActionListener { private val _searchDoneListener = object : TextView.OnEditorActionListener {
override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean { override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean {
if (actionId != EditorInfo.IME_ACTION_DONE) val isEnterPress = event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN
if (actionId != EditorInfo.IME_ACTION_DONE && !isEnterPress)
return false return false
onDone(); onDone()
return true; return true
} }
}; };
@@ -0,0 +1,23 @@
package com.futo.platformplayer.functional
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.structures.ReusablePager
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
//TODO: Integrate this better?
class CentralizedFeed {
var lock = Object();
var feed: ReusablePager<IPlatformContent>? = null;
var isGlobalUpdating: Boolean = false;
var exceptions: List<Throwable> = listOf();
var lastProgress: Int = 0;
var lastTotal: Int = 0;
val onUpdateProgress = Event2<Int, Int>();
val onUpdated = Event0();
val onUpdatedOnce = Event1<Throwable?>();
val onException = Event1<List<Throwable>>();
}
@@ -48,11 +48,16 @@ class Subscription {
var playbackSeconds: Int = 0; var playbackSeconds: Int = 0;
var playbackViews: Int = 0; var playbackViews: Int = 0;
var isOther = false;
constructor(channel : SerializedChannel) { constructor(channel : SerializedChannel) {
this.channel = channel; this.channel = channel;
} }
fun isChannel(url: String): Boolean {
return channel.url == url || channel.urlAlternatives.contains(url);
}
fun shouldFetchVideos() = doFetchVideos && fun shouldFetchVideos() = doFetchVideos &&
(lastVideo.getNowDiffDays() < 30 || lastVideoUpdate.getNowDiffDays() >= 1) && (lastVideo.getNowDiffDays() < 30 || lastVideoUpdate.getNowDiffDays() >= 1) &&
(lastVideo.getNowDiffDays() < 180 || lastVideoUpdate.getNowDiffDays() >= 3); (lastVideo.getNowDiffDays() < 180 || lastVideoUpdate.getNowDiffDays() >= 3);
@@ -63,10 +68,16 @@ class Subscription {
fun getClient() = StatePlatform.instance.getChannelClientOrNull(channel.url); fun getClient() = StatePlatform.instance.getChannelClientOrNull(channel.url);
fun save() { fun save() {
StateSubscriptions.instance.saveSubscription(this); if(isOther)
StateSubscriptions.instance.saveSubscriptionOther(this);
else
StateSubscriptions.instance.saveSubscription(this);
} }
fun saveAsync() { fun saveAsync() {
StateSubscriptions.instance.saveSubscription(this); if(isOther)
StateSubscriptions.instance.saveSubscriptionOtherAsync(this);
else
StateSubscriptions.instance.saveSubscriptionAsync(this);
} }
fun updateChannel(channel: IPlatformChannel) { fun updateChannel(channel: IPlatformChannel) {
@@ -12,6 +12,7 @@ import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.receivers.MediaControlReceiver import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.timestampRegex import com.futo.platformplayer.timestampRegex
import kotlinx.coroutines.runBlocking
class PlatformLinkMovementMethod : LinkMovementMethod { class PlatformLinkMovementMethod : LinkMovementMethod {
private val _context: Context; private val _context: Context;
@@ -32,33 +33,36 @@ class PlatformLinkMovementMethod : LinkMovementMethod {
val links = buffer.getSpans(off, off, URLSpan::class.java); val links = buffer.getSpans(off, off, URLSpan::class.java);
if (links.isNotEmpty()) { if (links.isNotEmpty()) {
for (link in links) { runBlocking {
Logger.i(TAG) { "Link clicked '${link.url}'." }; for (link in links) {
Logger.i(TAG) { "Link clicked '${link.url}'." };
if (_context is MainActivity) { if (_context is MainActivity) {
if (_context.handleUrl(link.url)) { if (_context.handleUrl(link.url)) {
continue;
}
if (timestampRegex.matches(link.url)) {
val tokens = link.url.split(':');
var time_s = -1L;
if (tokens.size == 2) {
time_s = tokens[0].toLong() * 60 + tokens[1].toLong();
} else if (tokens.size == 3) {
time_s = tokens[0].toLong() * 60 * 60 + tokens[1].toLong() * 60 + tokens[2].toLong();
}
if (time_s != -1L) {
MediaControlReceiver.onSeekToReceived.emit(time_s * 1000);
continue; continue;
} }
if (timestampRegex.matches(link.url)) {
val tokens = link.url.split(':');
var time_s = -1L;
if (tokens.size == 2) {
time_s = tokens[0].toLong() * 60 + tokens[1].toLong();
} else if (tokens.size == 3) {
time_s =
tokens[0].toLong() * 60 * 60 + tokens[1].toLong() * 60 + tokens[2].toLong();
}
if (time_s != -1L) {
MediaControlReceiver.onSeekToReceived.emit(time_s * 1000);
continue;
}
}
} }
_context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)));
} }
_context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link.url)));
} }
return true; return true;
@@ -56,7 +56,7 @@ class PolycentricCache {
private val _taskGetProfile = BatchedTaskHandler<PublicKey, CachedPolycentricProfile>(_scope, private val _taskGetProfile = BatchedTaskHandler<PublicKey, CachedPolycentricProfile>(_scope,
{ system -> { system ->
val signedProfileEvents = ApiMethods.getQueryLatest( val signedEventsList = ApiMethods.getQueryLatest(
SERVER, SERVER,
system.toProto(), system.toProto(),
listOf( listOf(
@@ -72,8 +72,9 @@ class PolycentricCache {
ContentType.MEMBERSHIP_URLS.value, ContentType.MEMBERSHIP_URLS.value,
ContentType.DONATION_DESTINATIONS.value ContentType.DONATION_DESTINATIONS.value
) )
).eventsList.map { e -> SignedEvent.fromProto(e) } ).eventsList.map { e -> SignedEvent.fromProto(e) };
.groupBy { e -> e.event.contentType }
val signedProfileEvents = signedEventsList.groupBy { e -> e.event.contentType }
.map { (_, events) -> events.maxBy { it.event.unixMilliseconds ?: 0 } }; .map { (_, events) -> events.maxBy { it.event.unixMilliseconds ?: 0 } };
val storageSystemState = StorageTypeSystemState.create() val storageSystemState = StorageTypeSystemState.create()
@@ -151,17 +152,7 @@ class PolycentricCache {
private val _batchTaskGetData = BatchedTaskHandler<String, ByteBuffer>(_scope, private val _batchTaskGetData = BatchedTaskHandler<String, ByteBuffer>(_scope,
{ {
val urlData = if (it.startsWith("polycentric://")) { val dataLink = getDataLinkFromUrl(it) ?: throw Exception("Only URLInfoDataLink is supported");
it.substring("polycentric://".length)
} else it;
val urlBytes = urlData.base64UrlToByteArray();
val urlInfo = Protocol.URLInfo.parseFrom(urlBytes);
if (urlInfo.urlType != 4L) {
throw Exception("Only URLInfoDataLink is supported");
}
val dataLink = Protocol.URLInfoDataLink.parseFrom(urlInfo.body);
return@BatchedTaskHandler ApiMethods.getDataFromServerAndReassemble(dataLink); return@BatchedTaskHandler ApiMethods.getDataFromServerAndReassemble(dataLink);
}, },
{ return@BatchedTaskHandler null }, { return@BatchedTaskHandler null },
@@ -325,9 +316,10 @@ class PolycentricCache {
.build(); .build();
private const val TAG = "PolycentricCache" private const val TAG = "PolycentricCache"
const val SERVER = "https://srv1-stg.polycentric.io" const val STAGING_SERVER = "https://srv1-stg.polycentric.io"
const val SERVER = "https://srv1-prod.polycentric.io"
private var _instance: PolycentricCache? = null; private var _instance: PolycentricCache? = null;
private val CACHE_EXPIRATION_SECONDS = 60 * 60 * 3; private val CACHE_EXPIRATION_SECONDS = 60 * 5;
@JvmStatic @JvmStatic
val instance: PolycentricCache val instance: PolycentricCache
@@ -343,5 +335,20 @@ class PolycentricCache {
it._scope.cancel("PolycentricCache finished"); it._scope.cancel("PolycentricCache finished");
} }
} }
fun getDataLinkFromUrl(it: String): Protocol.URLInfoDataLink? {
val urlData = if (it.startsWith("polycentric://")) {
it.substring("polycentric://".length)
} else it;
val urlBytes = urlData.base64UrlToByteArray();
val urlInfo = Protocol.URLInfo.parseFrom(urlBytes);
if (urlInfo.urlType != 4L) {
return null
}
val dataLink = Protocol.URLInfoDataLink.parseFrom(urlInfo.body);
return dataLink
}
} }
} }
@@ -0,0 +1,42 @@
package com.futo.platformplayer.polycentric
import com.futo.platformplayer.encryption.GEncryptionProviderV1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.polycentric.core.ProcessSecret
import com.futo.polycentric.core.base64ToByteArray
import com.futo.polycentric.core.toBase64
import userpackage.Protocol
class PolycentricStorage {
private val _processSecrets = FragmentedStorage.get<StringArrayStorage>("processSecrets");
fun addProcessSecret(processSecret: ProcessSecret) {
_processSecrets.addDistinct(GEncryptionProviderV1.instance.encrypt(processSecret.toProto().toByteArray()).toBase64())
_processSecrets.saveBlocking()
}
fun getProcessSecrets(): List<ProcessSecret> {
val processSecrets = arrayListOf<ProcessSecret>()
for (p in _processSecrets.getAllValues()) {
try {
processSecrets.add(ProcessSecret.fromProto(Protocol.StorageTypeProcessSecret.parseFrom(GEncryptionProviderV1.instance.decrypt(p.base64ToByteArray()))))
} catch (e: Throwable) {
Logger.i(TAG, "Failed to decrypt process secret", e);
}
}
return processSecrets
}
companion object {
val TAG = "PolycentricStorage";
private var _instance : PolycentricStorage? = null;
val instance : PolycentricStorage
get(){
if(_instance == null)
_instance = PolycentricStorage();
return _instance!!;
};
}
}
@@ -4,13 +4,18 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class AudioNoisyReceiver : BroadcastReceiver() { class AudioNoisyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
Logger.i(TAG, "Audio Noisy received"); StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
MediaControlReceiver.onPauseReceived.emit(); Logger.i(TAG, "Audio Noisy received");
MediaControlReceiver.onPauseReceived.emit();
}
} }
companion object { companion object {
@@ -37,6 +37,7 @@ class DownloadService : Service() {
private val DOWNLOAD_NOTIF_ID = 3; private val DOWNLOAD_NOTIF_ID = 3;
private val DOWNLOAD_NOTIF_TAG = "download"; private val DOWNLOAD_NOTIF_TAG = "download";
private val DOWNLOAD_NOTIF_CHANNEL_ID = "downloadChannel"; private val DOWNLOAD_NOTIF_CHANNEL_ID = "downloadChannel";
private val DOWNLOAD_NOTIF_CHANNEL_NAME = "Downloads";
//Context //Context
private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default); private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default);
@@ -95,7 +96,7 @@ class DownloadService : Service() {
} }
fun setupNotificationRequirements() { fun setupNotificationRequirements() {
_notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager; _notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
_notificationChannel = NotificationChannel(DOWNLOAD_NOTIF_CHANNEL_ID, "Temp", NotificationManager.IMPORTANCE_DEFAULT).apply { _notificationChannel = NotificationChannel(DOWNLOAD_NOTIF_CHANNEL_ID, DOWNLOAD_NOTIF_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
this.enableVibration(false); this.enableVibration(false);
this.setSound(null, null); this.setSound(null, null);
}; };
@@ -36,6 +36,7 @@ class ExportingService : Service() {
private val EXPORT_NOTIF_ID = 4; private val EXPORT_NOTIF_ID = 4;
private val EXPORT_NOTIF_TAG = "export"; private val EXPORT_NOTIF_TAG = "export";
private val EXPORT_NOTIF_CHANNEL_ID = "exportChannel"; private val EXPORT_NOTIF_CHANNEL_ID = "exportChannel";
private val EXPORT_NOTIF_CHANNEL_NAME = "Export";
//Context //Context
private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default); private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Default);
@@ -88,7 +89,7 @@ class ExportingService : Service() {
} }
fun setupNotificationRequirements() { fun setupNotificationRequirements() {
_notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager; _notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
_notificationChannel = NotificationChannel(EXPORT_NOTIF_CHANNEL_ID, "Temp", NotificationManager.IMPORTANCE_DEFAULT).apply { _notificationChannel = NotificationChannel(EXPORT_NOTIF_CHANNEL_ID, EXPORT_NOTIF_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
this.enableVibration(false); this.enableVibration(false);
this.setSound(null, null); this.setSound(null, null);
}; };
@@ -143,7 +143,7 @@ class MediaPlaybackService : Service() {
override fun onDestroy() { override fun onDestroy() {
Logger.v(TAG, "onDestroy"); Logger.v(TAG, "onDestroy");
_instance = null; _instance = null;
MediaControlReceiver.onCloseReceived.emit(); MediaControlReceiver.onPauseReceived.emit();
super.onDestroy(); super.onDestroy();
} }
@@ -153,7 +153,7 @@ class MediaPlaybackService : Service() {
fun closeMediaSession() { fun closeMediaSession() {
Logger.v(TAG, "closeMediaSession"); Logger.v(TAG, "closeMediaSession");
stopForeground(STOP_FOREGROUND_DETACH); stopForeground(STOP_FOREGROUND_REMOVE);
val focusRequest = _focusRequest; val focusRequest = _focusRequest;
if (focusRequest != null) { if (focusRequest != null) {
@@ -162,7 +162,9 @@ class MediaPlaybackService : Service() {
} }
_hasFocus = false; _hasFocus = false;
_notificationManager?.cancel(MEDIA_NOTIF_ID); val notifManager = _notificationManager;
Logger.i(TAG, "Cancelling playback notification (notifManager: ${notifManager != null})");
notifManager?.cancel(MEDIA_NOTIF_ID);
_notif_last_video = null; _notif_last_video = null;
_notif_last_bitmap = null; _notif_last_bitmap = null;
_mediaSession = null; _mediaSession = null;
@@ -12,7 +12,6 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.Random
import java.util.UUID import java.util.UUID
class StateAnnouncement { class StateAnnouncement {
@@ -252,41 +251,6 @@ class StateAnnouncement {
} }
fun registerDidYouKnow() {
val random = Random();
val message: String? = when (random.nextInt(4 * 18 + 1)) {
0 -> "You can login to different platforms and unify your content experience. Check it out in the source settings!"
1 -> "Importing your playlists and subscriptions from other platforms to Grayjay is quick and easy. Check it out in the source settings!"
2 -> "Want to cast to a big screen? Try out FCast (https://fcast.org/)."
3 -> "Explore Grayjay's gesture controls. When in full-screen swipe on the left to change brightness, swipe on the right to change volume."
4 -> "Explore Grayjay's gesture controls. Swipe up in the center of a video to toggle full-screen."
5 -> "Grayjay's multi-platform search lets you find content from various sources."
6 -> "Grayjay's multi-platform search filters will unify filters across platforms. If your expected filters are not there, try toggling some platforms off in the search filters."
7 -> "You can share playlists with friends on the playlist page and make full-backups in the settings page."
8 -> "Discover Grayjay's offline playback feature. Save content for when you're on the go!"
9 -> "Paid content from your favorite creators gets seamlessly integrated into your Grayjay feed. Login to a platform to seamlessly see content you paid for."
10 -> "Explore Grayjay's plugin features! Login, import playlists, and tweak plugin settings for a tailored experience."
11 -> "Directly engage with content by liking, disliking, or leaving comments on the Polycentric network."
12 -> "With Grayjay's rotation lock, you can watch videos in your preferred orientation regardless of device settings. Check it out during playback!"
13 -> "Grayjay supports background play. Listen to your favorite content even while multitasking!"
14 -> "Use Grayjay's quality selection to adjust video resolution. Save data or watch in high definition it's up to you."
15 -> "Customize your Grayjay experience by changing playback speed. Watch content at your own pace."
16 -> "Save time by adding videos to your 'Watch Later' list. Perfect for catching up on content during your free time."
17 -> "On Grayjay, your playlists, subscriptions, and settings are stored offline for privacy and quick access."
18 -> "Explore and engage with live content using Grayjay's live stream feature."
else -> null
};
if (message != null) {
registerAnnouncement(
"did-you-know?",
"Did you know?",
message,
AnnouncementType.SESSION_RECURRING
);
}
}
fun registerDefaultHandlerAnnouncement() { fun registerDefaultHandlerAnnouncement() {
registerAnnouncement( registerAnnouncement(
"default-url-handler", "default-url-handler",
@@ -13,6 +13,7 @@ import android.net.NetworkRequest
import android.net.Uri import android.net.Uri
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.util.DisplayMetrics import android.util.DisplayMetrics
import android.util.Log
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -38,9 +39,9 @@ import com.futo.platformplayer.receivers.AudioNoisyReceiver
import com.futo.platformplayer.services.DownloadService import com.futo.platformplayer.services.DownloadService
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.io.File import java.io.File
import java.time.OffsetDateTime
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
@@ -380,13 +381,15 @@ class StateApp {
Logger.i(TAG, "MainApp Starting: Initializing [Polycentric]"); Logger.i(TAG, "MainApp Starting: Initializing [Polycentric]");
StatePolycentric.instance.load(context); StatePolycentric.instance.load(context);
Logger.i(TAG, "MainApp Starting: Initializing [Saved]");
StateSaved.instance.load();
Logger.i(TAG, "MainApp Starting: Initializing [Connectivity]"); Logger.i(TAG, "MainApp Starting: Initializing [Connectivity]");
displayMetrics = context.resources.displayMetrics; displayMetrics = context.resources.displayMetrics;
ensureConnectivityManager(context); ensureConnectivityManager(context);
Logger.i(TAG, "MainApp Starting: Cleaning up unused downloads");
StateDownloads.instance.cleanupDownloads();
Logger.i(TAG, "MainApp Starting: Initializing [Telemetry]"); Logger.i(TAG, "MainApp Starting: Initializing [Telemetry]");
if (!BuildConfig.DEBUG) { if (!BuildConfig.DEBUG) {
StateTelemetry.instance.initialize(); StateTelemetry.instance.initialize();
@@ -423,8 +426,6 @@ class StateApp {
} }
} }
StateAnnouncement.instance.registerAnnouncement("fa4647d3-36fa-4c8c-832d-85b00fc72dca", "Disclaimer", "This is an early alpha build of the application, expect bugs and unfinished features.", AnnouncementType.DELETABLE, OffsetDateTime.now())
if(SettingsDev.instance.developerMode && SettingsDev.instance.devServerSettings.devServerOnBoot) if(SettingsDev.instance.developerMode && SettingsDev.instance.devServerSettings.devServerOnBoot)
StateDeveloper.instance.runServer(); StateDeveloper.instance.runServer();
@@ -460,7 +461,9 @@ class StateApp {
//Foreground download //Foreground download
autoUpdateEnabled -> { autoUpdateEnabled -> {
StateUpdate.instance.checkForUpdates(context, false); scopeOrNull?.launch(Dispatchers.IO) {
StateUpdate.instance.checkForUpdates(context, false)
}
} }
else -> { else -> {
@@ -471,7 +474,11 @@ class StateApp {
Logger.i(TAG, "MainApp Started: Initialize [Noisy]"); Logger.i(TAG, "MainApp Started: Initialize [Noisy]");
_receiverBecomingNoisy?.let { _receiverBecomingNoisy?.let {
_receiverBecomingNoisy = null; _receiverBecomingNoisy = null;
context.unregisterReceiver(it); try {
context.unregisterReceiver(it);
} catch (e: Throwable) {
Log.e(TAG, "Failed to unregister receiver.", e)
}
} }
_receiverBecomingNoisy = AudioNoisyReceiver(); _receiverBecomingNoisy = AudioNoisyReceiver();
context.registerReceiver(_receiverBecomingNoisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); context.registerReceiver(_receiverBecomingNoisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
@@ -551,13 +558,31 @@ class StateApp {
} }
StateAnnouncement.instance.registerDefaultHandlerAnnouncement(); StateAnnouncement.instance.registerDefaultHandlerAnnouncement();
StateAnnouncement.instance.registerDidYouKnow();
Logger.i(TAG, "MainApp Started: Finished"); Logger.i(TAG, "MainApp Started: Finished");
StatePlaylists.instance.toMigrateCheck(); StatePlaylists.instance.toMigrateCheck();
if(StateHistory.instance.shouldMigrateLegacyHistory()) if(StateHistory.instance.shouldMigrateLegacyHistory())
StateHistory.instance.migrateLegacyHistory(); StateHistory.instance.migrateLegacyHistory();
StateAnnouncement.instance.deleteAnnouncement("plugin-update")
scopeOrNull?.launch(Dispatchers.IO) {
val updateAvailable = StatePlatform.instance.checkForUpdates()
withContext(Dispatchers.Main) {
if (updateAvailable.isNotEmpty()) {
UIDialogs.appToast(
ToastView.Toast(updateAvailable
.map { " - " + it.name }
.joinToString("\n"),
true,
null,
"Plugin updates available"
));
}
}
}
} }
fun mainAppStartedWithExternalFiles(context: Context) { fun mainAppStartedWithExternalFiles(context: Context) {
@@ -619,7 +644,11 @@ class StateApp {
Logger.i(TAG, "App ended"); Logger.i(TAG, "App ended");
_receiverBecomingNoisy?.let { _receiverBecomingNoisy?.let {
_receiverBecomingNoisy = null; _receiverBecomingNoisy = null;
context.unregisterReceiver(it); try {
context.unregisterReceiver(it);
} catch (e: Throwable) {
Log.e(TAG, "Failed to unregister receiver.", e)
}
} }
Logger.i(TAG, "Unregistered network callback on connectivityManager.") Logger.i(TAG, "Unregistered network callback on connectivityManager.")
@@ -49,13 +49,17 @@ class StateCache {
Logger.i(TAG, "Subscriptions CachePager get subscriptions"); Logger.i(TAG, "Subscriptions CachePager get subscriptions");
val subs = StateSubscriptions.instance.getSubscriptions(); val subs = StateSubscriptions.instance.getSubscriptions();
Logger.i(TAG, "Subscriptions CachePager polycentric urls"); Logger.i(TAG, "Subscriptions CachePager polycentric urls");
val allUrls = subs.map { val allUrls = subs
.map {
val otherUrls = PolycentricCache.instance.getCachedProfile(it.channel.url)?.profile?.ownedClaims?.mapNotNull { c -> c.claim.resolveChannelUrl() } ?: listOf(); val otherUrls = PolycentricCache.instance.getCachedProfile(it.channel.url)?.profile?.ownedClaims?.mapNotNull { c -> c.claim.resolveChannelUrl() } ?: listOf();
if(!otherUrls.contains(it.channel.url)) if(!otherUrls.contains(it.channel.url))
return@map listOf(listOf(it.channel.url), otherUrls).flatten(); return@map listOf(listOf(it.channel.url), otherUrls).flatten();
else else
return@map otherUrls; return@map otherUrls;
}.flatten().distinct(); }
.flatten()
.distinct()
.filter { StatePlatform.instance.hasEnabledChannelClient(it) };
Logger.i(TAG, "Subscriptions CachePager get pagers"); Logger.i(TAG, "Subscriptions CachePager get pagers");
val pagers: List<IPager<IPlatformContent>>; val pagers: List<IPager<IPlatformContent>>;
@@ -352,7 +352,10 @@ class StateDownloads {
fun cleanupDownloads(): Pair<Int, Long> { fun cleanupDownloads(): Pair<Int, Long> {
val expected = getDownloadedVideos(); val expected = getDownloadedVideos();
val validFiles = HashSet(expected.flatMap { e -> e.videoSource.map { it.filePath } + e.audioSource.map { it.filePath } }); val validFiles = HashSet(expected.flatMap { e ->
e.videoSource.map { it.filePath } +
e.audioSource.map { it.filePath } +
e.subtitlesSources.map { it.filePath }});
var totalDeleted: Long = 0; var totalDeleted: Long = 0;
var totalDeletedCount = 0; var totalDeletedCount = 0;
@@ -1,5 +1,6 @@
package com.futo.platformplayer.states package com.futo.platformplayer.states
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
@@ -49,6 +50,9 @@ class StateHistory {
fun getHistoryPosition(url: String): Long { fun getHistoryPosition(url: String): Long {
return historyIndex[url]?.position ?: 0; return historyIndex[url]?.position ?: 0;
} }
fun isHistoryWatched(url: String, duration: Long): Boolean {
return getHistoryPosition(url) > duration * 0.7;
}
fun updateHistoryPosition(liveObj: IPlatformVideo, index: DBHistory.Index, updateExisting: Boolean, position: Long = -1L): Long { fun updateHistoryPosition(liveObj: IPlatformVideo, index: DBHistory.Index, updateExisting: Boolean, position: Long = -1L): Long {
@@ -92,14 +96,20 @@ class StateHistory {
} }
fun getHistoryByVideo(video: IPlatformVideo, create: Boolean = false, watchDate: OffsetDateTime? = null): DBHistory.Index? { fun getHistoryByVideo(video: IPlatformVideo, create: Boolean = false, watchDate: OffsetDateTime? = null): DBHistory.Index? {
val existing = historyIndex[video.url]; val existing = historyIndex[video.url];
if(existing != null) var result: DBHistory.Index? = null;
return _historyDBStore.get(existing.id!!); if(existing != null) {
result = _historyDBStore.getOrNull(existing.id!!);
if(result == null)
UIDialogs.toast("History item null?\nNo history tracking..");
}
else if(create) { else if(create) {
val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), 0, watchDate ?: OffsetDateTime.now()); val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), 0, watchDate ?: OffsetDateTime.now());
val id = _historyDBStore.insert(newHistItem); val id = _historyDBStore.insert(newHistItem);
return _historyDBStore.get(id); result = _historyDBStore.getOrNull(id);
if(result == null)
UIDialogs.toast("History creation failed?\nNo history tracking..");
} }
return null; return result;
} }
fun removeHistory(url: String) { fun removeHistory(url: String) {
@@ -5,6 +5,7 @@ import androidx.collection.LruCache
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.PlatformMultiClientPool import com.futo.platformplayer.api.media.PlatformMultiClientPool
@@ -45,6 +46,7 @@ import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.views.ToastView
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -78,6 +80,7 @@ class StatePlatform {
private val _clientsLock = Object(); private val _clientsLock = Object();
private val _availableClients : ArrayList<IPlatformClient> = ArrayList(); private val _availableClients : ArrayList<IPlatformClient> = ArrayList();
private val _enabledClients : ArrayList<IPlatformClient> = ArrayList(); private val _enabledClients : ArrayList<IPlatformClient> = ArrayList();
private var _updatesAvailableMap: HashSet<String> = hashSetOf();
//ClientPools are used to isolate plugin usage of certain components from others //ClientPools are used to isolate plugin usage of certain components from others
//This prevents for example a background task like subscriptions from blocking a user from opening a video //This prevents for example a background task like subscriptions from blocking a user from opening a video
@@ -92,11 +95,6 @@ class StatePlatform {
private val _liveEventClientPool = PlatformMultiClientPool("LiveEvents", 1); //Used exclusively for live events private val _liveEventClientPool = PlatformMultiClientPool("LiveEvents", 1); //Used exclusively for live events
private val _primaryClientPersistent = FragmentedStorage.get<StringStorage>("primaryClient");
private var _primaryClientObj : IPlatformClient? = null;
val primaryClient : IPlatformClient get() = _primaryClientObj ?: throw IllegalStateException("PlatformState not yet initialized");
private val _icons : HashMap<String, ImageVariable> = HashMap(); private val _icons : HashMap<String, ImageVariable> = HashMap();
val hasClients: Boolean get() = _availableClients.size > 0; val hasClients: Boolean get() = _availableClients.size > 0;
@@ -169,8 +167,13 @@ class StatePlatform {
var enabled: Array<String>; var enabled: Array<String>;
synchronized(_clientsLock) { synchronized(_clientsLock) {
for(e in _enabledClients) { for(e in _enabledClients) {
e.disable(); try {
onSourceDisabled.emit(e); e.disable();
onSourceDisabled.emit(e);
}
catch(ex: Throwable) {
UIDialogs.appToast(ToastView.Toast("If this happens often, please inform the developers on Github", false, null, "Plugin [${e.name}] failed to disable"));
}
} }
_enabledClients.clear(); _enabledClients.clear();
@@ -205,20 +208,6 @@ class StatePlatform {
.filter { id -> _availableClients.any { it.id == id } } .filter { id -> _availableClients.any { it.id == id } }
.toTypedArray(); .toTypedArray();
} }
val primary = _primaryClientPersistent.value;
if(primary.isEmpty() || primary == StateDeveloper.DEV_ID) {
selectPrimaryClient(enabled.firstOrNull() ?: _availableClients.first().id);
} else if(!_availableClients.any { it.id == primary }) {
selectPrimaryClient(_availableClients.firstOrNull()?.id!!);
} else {
selectPrimaryClient(primary);
}
if(!enabled.any { it == primaryClient.id }) {
enabled = enabled.concat(primaryClient.id);
}
} }
selectClients(*enabled); selectClients(*enabled);
}; };
@@ -321,8 +310,6 @@ class StatePlatform {
newClient.initialize(); newClient.initialize();
_enabledClients.add(newClient); _enabledClients.add(newClient);
} }
if (_primaryClientObj == client)
_primaryClientObj = newClient;
_availableClients.removeIf { it.id == id }; _availableClients.removeIf { it.id == id };
_availableClients.add(newClient); _availableClients.add(newClient);
@@ -331,6 +318,11 @@ class StatePlatform {
}; };
} }
suspend fun enableClient(ids: List<String>) {
val currentClients = getEnabledClients().map { it.id };
selectClients(*(currentClients + ids).distinct().toTypedArray());
}
/** /**
* Selects the enabled clients, meaning all clients that data is actively requested from. * Selects the enabled clients, meaning all clients that data is actively requested from.
* If a client is disabled, NO requests are made to said client * If a client is disabled, NO requests are made to said client
@@ -363,17 +355,6 @@ class StatePlatform {
}; };
} }
/**
* Selects the primary client, meaning the first target for requests.
* At the moment, since multi-client requests are not yet implemented, this is the goto client.
*/
fun selectPrimaryClient(id: String) {
synchronized(_clientsLock) {
_primaryClientObj = getClient(id);
_primaryClientPersistent.setAndSave(id);
}
}
fun getHome(): IPager<IPlatformContent> { fun getHome(): IPager<IPlatformContent> {
Logger.i(TAG, "Platform - getHome"); Logger.i(TAG, "Platform - getHome");
var clientIdsOngoing = mutableListOf<String>(); var clientIdsOngoing = mutableListOf<String>();
@@ -446,14 +427,12 @@ class StatePlatform {
toAwait.map { PlaceholderPager(5, { PlatformContentPlaceholder(it.first.id) }) }); toAwait.map { PlaceholderPager(5, { PlatformContentPlaceholder(it.first.id) }) });
} }
fun getHomePrimary(): IPager<IPlatformContent> {
return primaryClient.getHome();
}
//Search //Search
fun searchSuggestions(query: String): Array<String> { fun searchSuggestions(query: String): Array<String> {
Logger.i(TAG, "Platform - searchSuggestions"); Logger.i(TAG, "Platform - searchSuggestions");
return primaryClient.searchSuggestions(query); //TODO: hasSearchSuggestions
return getEnabledClients().firstOrNull()?.searchSuggestions(query) ?: arrayOf();
} }
fun search(query: String, type: String? = null, sort: String? = null, filters: Map<String, List<String>> = mapOf(), clientIds: List<String>? = null): IPager<IPlatformContent> { fun search(query: String, type: String? = null, sort: String? = null, filters: Map<String, List<String>> = mapOf(), clientIds: List<String>? = null): IPager<IPlatformContent> {
@@ -839,6 +818,7 @@ class StatePlatform {
return urls; return urls;
} }
fun hasEnabledPlaylistClient(url: String) : Boolean = getEnabledClients().any { it.isPlaylistUrl(url) };
fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { it.isPlaylistUrl(url) } fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { it.isPlaylistUrl(url) }
fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { it.isPlaylistUrl(url) } fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { it.isPlaylistUrl(url) }
?: throw NoPlatformClientException("No client enabled that supports this playlist url (${url})"); ?: throw NoPlatformClientException("No client enabled that supports this playlist url (${url})");
@@ -884,7 +864,6 @@ class StatePlatform {
synchronized(_clientsLock) { synchronized(_clientsLock) {
val enabledExisting = _enabledClients.filter { it is DevJSClient }; val enabledExisting = _enabledClients.filter { it is DevJSClient };
val isEnabled = !enabledExisting.isEmpty() val isEnabled = !enabledExisting.isEmpty()
val isPrimary = _primaryClientObj is DevJSClient;
for (enabled in enabledExisting) { for (enabled in enabledExisting) {
enabled.disable(); enabled.disable();
@@ -899,11 +878,7 @@ class StatePlatform {
devId = newClient.devID; devId = newClient.devID;
try { try {
StateDeveloper.instance.initializeDev(devId!!); StateDeveloper.instance.initializeDev(devId!!);
if (isPrimary) { if (isEnabled) {
_primaryClientObj = newClient;
_enabledClients.add(0, newClient);
newClient.initialize();
} else if (isEnabled) {
_enabledClients.add(newClient); _enabledClients.add(newClient);
newClient.initialize(); newClient.initialize();
} }
@@ -932,6 +907,67 @@ class StatePlatform {
} }
} }
fun hasUpdateAvailable(c: SourcePluginConfig): Boolean {
val updatesAvailableMap = _updatesAvailableMap
synchronized(updatesAvailableMap) {
return updatesAvailableMap.contains(c.id)
}
}
suspend fun checkForUpdates(): List<SourcePluginConfig> = withContext(Dispatchers.IO) {
var configs = mutableListOf<SourcePluginConfig>()
val updatesAvailableFor = hashSetOf<String>()
for (availableClient in getAvailableClients().filter { it is JSClient && it.descriptor.appSettings.checkForUpdates }) {
if (availableClient !is JSClient) {
continue
}
if (checkForUpdates(availableClient.config)) {
configs.add(availableClient.config);
updatesAvailableFor.add(availableClient.config.id)
}
}
_updatesAvailableMap = updatesAvailableFor
return@withContext configs;
}
fun clearUpdateAvailable(c: SourcePluginConfig) {
val updatesAvailableMap = _updatesAvailableMap
synchronized(updatesAvailableMap) {
updatesAvailableMap.remove(c.id)
}
}
private suspend fun checkForUpdates(c: SourcePluginConfig): Boolean = withContext(Dispatchers.IO) {
val sourceUrl = c.sourceUrl ?: return@withContext false;
Logger.i(TAG, "Check for source updates '${c.name}'.");
try {
val client = ManagedHttpClient();
val response = client.get(sourceUrl);
Logger.i(TAG, "Downloading source config '$sourceUrl'.");
if (!response.isOk || response.body == null) {
return@withContext false;
}
val configJson = response.body.string();
Logger.i(TAG, "Downloaded source config ($sourceUrl):\n${configJson}");
val config = SourcePluginConfig.fromJson(configJson);
if (config.version <= c.version) {
return@withContext false;
}
Logger.i(TAG, "Update is available (config.version=${config.version}, source.config.version=${c.version}).");
return@withContext true;
} catch (e: Throwable) {
Logger.e(TAG, "Failed to check for updates.", e);
return@withContext false;
}
}
companion object { companion object {
private var _instance : StatePlatform? = null; private var _instance : StatePlatform? = null;
val instance : StatePlatform val instance : StatePlatform
@@ -361,6 +361,12 @@ class StatePlayer {
if (queueShuffle) { if (queueShuffle) {
removeFromShuffledQueue(video); removeFromShuffledQueue(video);
} }
if(currentVideo != null) {
val newPos = _queue.indexOfFirst { it.url == currentVideo?.url };
if(newPos >= 0)
_queuePosition = newPos;
}
} }
onQueueChanged.emit(shouldSwapCurrentItem); onQueueChanged.emit(shouldSwapCurrentItem);
@@ -407,6 +413,12 @@ class StatePlayer {
if(_queue.size == 1) { if(_queue.size == 1) {
return null; return null;
} }
if(_queue.size <= _queuePosition && currentVideo != null) {
//Out of sync position
val newPos = _queue.indexOfFirst { it.url == currentVideo?.url }
if(newPos != -1)
_queuePosition = newPos;
}
val shuffledQueue = _queueShuffled; val shuffledQueue = _queueShuffled;
val queue = if (queueShuffle && shuffledQueue != null) { val queue = if (queueShuffle && shuffledQueue != null) {
@@ -421,6 +433,8 @@ class StatePlayer {
} }
//Standard Behavior //Standard Behavior
if(_queuePosition - 1 >= 0) { if(_queuePosition - 1 >= 0) {
if(queue.size <= _queuePosition)
return null;
return queue[_queuePosition - 1]; return queue[_queuePosition - 1];
} }
//Repeat Behavior (End of queue) //Repeat Behavior (End of queue)
@@ -16,6 +16,7 @@ import com.futo.platformplayer.exceptions.ReconstructionException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.stores.v2.ReconstructStore import com.futo.platformplayer.stores.v2.ReconstructStore
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@@ -35,6 +36,8 @@ class StatePlaylists {
= SerializedPlatformVideo.fromVideo(StatePlatform.instance.getContentDetails(backup).await() as IPlatformVideoDetails); = SerializedPlatformVideo.fromVideo(StatePlatform.instance.getContentDetails(backup).await() as IPlatformVideoDetails);
}) })
.load(); .load();
private val _watchlistOrderStore = FragmentedStorage.get<StringArrayStorage>("watchListOrder"); //Temporary workaround to add order..
val playlistStore = FragmentedStorage.storeJson<Playlist>("playlists") val playlistStore = FragmentedStorage.storeJson<Playlist>("playlists")
.withRestore(PlaylistBackup()) .withRestore(PlaylistBackup())
.load(); .load();
@@ -48,26 +51,32 @@ class StatePlaylists {
} }
fun getWatchLater() : List<SerializedPlatformVideo> { fun getWatchLater() : List<SerializedPlatformVideo> {
synchronized(_watchlistStore) { synchronized(_watchlistStore) {
return _watchlistStore.getItems(); val order = _watchlistOrderStore.getAllValues();
return _watchlistStore.getItems().sortedBy { order.indexOf(it.url) };
} }
} }
fun updateWatchLater(updated: List<SerializedPlatformVideo>) { fun updateWatchLater(updated: List<SerializedPlatformVideo>) {
synchronized(_watchlistStore) { synchronized(_watchlistStore) {
_watchlistStore.deleteAll(); _watchlistStore.deleteAll();
_watchlistStore.saveAllAsync(updated); _watchlistStore.saveAllAsync(updated);
_watchlistOrderStore.set(*updated.map { it.url }.toTypedArray());
_watchlistOrderStore.save();
} }
onWatchLaterChanged.emit(); onWatchLaterChanged.emit();
} }
fun removeFromWatchLater(video: SerializedPlatformVideo) { fun removeFromWatchLater(video: SerializedPlatformVideo) {
synchronized(_watchlistStore) { synchronized(_watchlistStore) {
_watchlistStore.delete(video); _watchlistStore.delete(video);
_watchlistOrderStore.set(*_watchlistOrderStore.values.filter { it != video.url }.toTypedArray());
_watchlistOrderStore.save();
} }
onWatchLaterChanged.emit(); onWatchLaterChanged.emit();
} }
fun addToWatchLater(video: SerializedPlatformVideo) { fun addToWatchLater(video: SerializedPlatformVideo) {
synchronized(_watchlistStore) { synchronized(_watchlistStore) {
_watchlistStore.saveAsync(video); _watchlistStore.saveAsync(video);
_watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values) .toTypedArray());
_watchlistOrderStore.save();
} }
onWatchLaterChanged.emit(); onWatchLaterChanged.emit();
} }
@@ -10,7 +10,6 @@ import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.developer.DeveloperEndpoints
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
@@ -161,6 +160,13 @@ class StatePlugins {
val configJson = StateAssets.readAsset(context, assetConfigPath) ?: return null; val configJson = StateAssets.readAsset(context, assetConfigPath) ?: return null;
return SourcePluginConfig.fromJson(configJson, ""); return SourcePluginConfig.fromJson(configJson, "");
} }
fun getEmbeddedPluginConfigFromID(context: Context, pluginId: String): SourcePluginConfig? {
val embedded = getEmbeddedSources(context);
if(!embedded.containsKey(pluginId))
return null;
return getEmbeddedPluginConfig(context, embedded[pluginId]!!);
}
fun installEmbeddedPlugin(context: Context, assetConfigPath: String, id: String? = null): Boolean { fun installEmbeddedPlugin(context: Context, assetConfigPath: String, id: String? = null): Boolean {
try { try {
val configJson = StateAssets.readAsset(context, assetConfigPath) ?: val configJson = StateAssets.readAsset(context, assetConfigPath) ?:
@@ -467,7 +473,6 @@ class StatePlugins {
_plugins.save(descriptor); _plugins.save(descriptor);
} }
@Serializable @Serializable
private data class PluginConfig( private data class PluginConfig(
val SOURCES_EMBEDDED: Map<String, String>, val SOURCES_EMBEDDED: Map<String, String>,
@@ -23,11 +23,25 @@ import com.futo.platformplayer.dp
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.polycentric.PolycentricStorage
import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.stores.StringStorage
import com.futo.polycentric.core.* import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ClaimType
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Opinion
import com.futo.polycentric.core.ProcessHandle
import com.futo.polycentric.core.PublicKey
import com.futo.polycentric.core.SignedEvent
import com.futo.polycentric.core.SqlLiteDbHelper
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.base64ToByteArray
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
import com.futo.polycentric.core.toBase64
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.protobuf.ByteString import com.google.protobuf.ByteString
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
@@ -35,10 +49,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import userpackage.Protocol import userpackage.Protocol
import userpackage.Protocol.Reference
import java.time.Instant import java.time.Instant
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
import kotlin.Exception
class StatePolycentric { class StatePolycentric {
private data class LikeDislikeEntry(val unixMilliseconds: Long, val hasLiked: Boolean, val hasDisliked: Boolean); private data class LikeDislikeEntry(val unixMilliseconds: Long, val hasLiked: Boolean, val hasDisliked: Boolean);
@@ -54,28 +68,40 @@ class StatePolycentric {
return return
} }
try { for (i in 0 .. 1) {
val db = SqlLiteDbHelper(context); try {
Store.initializeSqlLiteStore(db); val db = SqlLiteDbHelper(context);
Store.initializeSqlLiteStore(db);
val activeProcessHandleString = _activeProcessHandle.value; val activeProcessHandleString = _activeProcessHandle.value;
if (activeProcessHandleString.isNotEmpty()) { if (activeProcessHandleString.isNotEmpty()) {
try { try {
val system = PublicKey.fromProto(Protocol.PublicKey.parseFrom(activeProcessHandleString.base64ToByteArray())); val system = PublicKey.fromProto(Protocol.PublicKey.parseFrom(activeProcessHandleString.base64ToByteArray()));
setProcessHandle(Store.instance.getProcessSecret(system)?.toProcessHandle()); setProcessHandle(Store.instance.getProcessSecret(system)?.toProcessHandle());
} catch (e: Throwable) { } catch (e: Throwable) {
db.upgradeOldSecrets(db.writableDatabase); db.upgradeOldSecrets(db.writableDatabase);
val system = PublicKey.fromProto(Protocol.PublicKey.parseFrom(activeProcessHandleString.base64ToByteArray())); val system = PublicKey.fromProto(Protocol.PublicKey.parseFrom(activeProcessHandleString.base64ToByteArray()));
setProcessHandle(Store.instance.getProcessSecret(system)?.toProcessHandle()); setProcessHandle(Store.instance.getProcessSecret(system)?.toProcessHandle());
Log.i(TAG, "Failed to initialize Polycentric.", e)
}
}
getProcessHandles()
break;
} catch (e: Throwable) {
if (i == 0) {
Logger.i(TAG, "Clearing Polycentric database due to corruption");
val db = SqlLiteDbHelper(context);
db.recreate()
} else {
_transientEnabled = false
UIDialogs.showGeneralErrorDialog(context, "Failed to initialize Polycentric.", e);
Log.i(TAG, "Failed to initialize Polycentric.", e) Log.i(TAG, "Failed to initialize Polycentric.", e)
} }
} }
} catch (e: Throwable) {
_transientEnabled = false
UIDialogs.showGeneralErrorDialog(context, "Failed to initialize Polycentric.", e);
Log.i(TAG, "Failed to initialize Polycentric.", e)
} }
} }
@@ -90,7 +116,32 @@ class StatePolycentric {
return listOf() return listOf()
} }
return Store.instance.getProcessSecrets().map { it.toProcessHandle(); }; val storeProcessSecrets = Store.instance.getProcessSecrets().toMutableList()
val processSecrets = PolycentricStorage.instance.getProcessSecrets()
for (processSecret in processSecrets)
{
if (!storeProcessSecrets.contains(processSecret)) {
try {
Store.instance.addProcessSecret(processSecret)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill process secret.")
}
}
}
for (processSecret in storeProcessSecrets)
{
if (!processSecrets.contains(processSecret)) {
try {
PolycentricStorage.instance.addProcessSecret(processSecret)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill process secret.")
}
}
}
return (storeProcessSecrets + processSecrets).distinct().map { it.toProcessHandle() }
} }
fun setProcessHandle(processHandle: ProcessHandle?) { fun setProcessHandle(processHandle: ProcessHandle?) {
@@ -128,21 +179,21 @@ class StatePolycentric {
_likeDislikeMap[ref.toByteArray().toBase64()] = LikeDislikeEntry(System.currentTimeMillis(), hasLiked, hasDisliked); _likeDislikeMap[ref.toByteArray().toBase64()] = LikeDislikeEntry(System.currentTimeMillis(), hasLiked, hasDisliked);
} }
fun hasDisliked(ref: Protocol.Reference): Boolean { fun hasDisliked(data: ByteArray): Boolean {
if (!enabled) { if (!enabled) {
return false return false
} }
val entry = _likeDislikeMap[ref.toByteArray().toBase64()] ?: return false; val entry = _likeDislikeMap[data.toBase64()] ?: return false;
return entry.hasDisliked; return entry.hasDisliked;
} }
fun hasLiked(ref: Protocol.Reference): Boolean { fun hasLiked(data: ByteArray): Boolean {
if (!enabled) { if (!enabled) {
return false return false
} }
val entry = _likeDislikeMap[ref.toByteArray().toBase64()] ?: return false; val entry = _likeDislikeMap[data.toBase64()] ?: return false;
return entry.hasLiked; return entry.hasLiked;
} }
@@ -275,7 +326,8 @@ class StatePolycentric {
rating = RatingLikeDislikes(0, 0), rating = RatingLikeDislikes(0, 0),
date = if (ev.unixMilliseconds != null) Instant.ofEpochMilli(ev.unixMilliseconds!!).atOffset(ZoneOffset.UTC) else OffsetDateTime.MIN, date = if (ev.unixMilliseconds != null) Instant.ofEpochMilli(ev.unixMilliseconds!!).atOffset(ZoneOffset.UTC) else OffsetDateTime.MIN,
replyCount = 0, replyCount = 0,
eventPointer = se.toPointer() eventPointer = se.toPointer(),
parentReference = se.event.references.getOrNull(0)
)) ))
} }
@@ -316,7 +368,78 @@ class StatePolycentric {
return LikesDislikesReplies(likes, dislikes, replyCount) return LikesDislikesReplies(likes, dislikes, replyCount)
} }
suspend fun getCommentPager(contextUrl: String, reference: Protocol.Reference): IPager<IPlatformComment> { suspend fun getComment(contextUrl: String, reference: Reference): PolycentricPlatformComment {
ensureEnabled()
if (reference.referenceType != 2L) {
throw Exception("Not a pointer")
}
val pointer = Protocol.Pointer.parseFrom(reference.reference)
val events = ApiMethods.getEvents(PolycentricCache.SERVER, pointer.system, Protocol.RangesForSystem.newBuilder()
.addRangesForProcesses(Protocol.RangesForProcess.newBuilder()
.setProcess(pointer.process)
.addRanges(Protocol.Range.newBuilder()
.setLow(pointer.logicalClock)
.setHigh(pointer.logicalClock)
.build())
.build())
.build())
val sev = SignedEvent.fromProto(events.getEvents(0))
val ev = sev.event
if (ev.contentType != ContentType.POST.value) {
throw Exception("This is not a comment")
}
val post = Protocol.Post.parseFrom(ev.content);
val systemLinkUrl = ev.system.systemToURLInfoSystemLinkUrl(listOf(PolycentricCache.SERVER));
val dp_25 = 25.dp(StateApp.instance.context.resources)
val profileEvents = ApiMethods.getQueryLatest(
PolycentricCache.SERVER,
ev.system.toProto(),
listOf(
ContentType.AVATAR.value,
ContentType.USERNAME.value
)
).eventsList.map { e -> SignedEvent.fromProto(e) }.groupBy { e -> e.event.contentType }
.map { (_, events) -> events.maxBy { x -> x.event.unixMilliseconds ?: 0 } };
val nameEvent = profileEvents.firstOrNull { e -> e.event.contentType == ContentType.USERNAME.value };
val avatarEvent = profileEvents.firstOrNull { e -> e.event.contentType == ContentType.AVATAR.value };
val imageBundle = if (avatarEvent != null) {
val lwwElementValue = avatarEvent.event.lwwElement?.value;
if (lwwElementValue != null) {
Protocol.ImageBundle.parseFrom(lwwElementValue)
} else {
null
}
} else {
null
}
val ldr = getLikesDislikesReplies(reference)
return PolycentricPlatformComment(
contextUrl = contextUrl,
author = PlatformAuthorLink(
id = PlatformID("polycentric", systemLinkUrl, null, ClaimType.POLYCENTRIC.value.toInt()),
name = nameEvent?.event?.lwwElement?.value?.decodeToString() ?: "Unknown",
url = systemLinkUrl,
thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(ev.system.toProto(), img.process, listOf(PolycentricCache.SERVER)) },
subscribers = null
),
msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content,
rating = RatingLikeDislikes(ldr.likes, ldr.dislikes),
date = if (ev.unixMilliseconds != null) Instant.ofEpochMilli(ev.unixMilliseconds!!).atOffset(ZoneOffset.UTC) else OffsetDateTime.MIN,
replyCount = ldr.replyCount.toInt(),
eventPointer = sev.toPointer(),
parentReference = sev.event.references.getOrNull(0)
)
}
suspend fun getCommentPager(contextUrl: String, reference: Protocol.Reference, extraByteReferences: List<ByteArray>? = null): IPager<IPlatformComment> {
if (!enabled) { if (!enabled) {
return EmptyPager() return EmptyPager()
} }
@@ -338,7 +461,8 @@ class StatePolycentric {
Protocol.QueryReferencesRequestCountReferences.newBuilder() Protocol.QueryReferencesRequestCountReferences.newBuilder()
.setFromType(ContentType.POST.value) .setFromType(ContentType.POST.value)
.build()) .build())
.build() .build(),
extraByteReferences = extraByteReferences
); );
val results = mapQueryReferences(contextUrl, response); val results = mapQueryReferences(contextUrl, response);
@@ -407,7 +531,8 @@ class StatePolycentric {
ContentType.AVATAR.value, ContentType.AVATAR.value,
ContentType.USERNAME.value ContentType.USERNAME.value
) )
).eventsList.map { e -> SignedEvent.fromProto(e) }; ).eventsList.map { e -> SignedEvent.fromProto(e) }.groupBy { e -> e.event.contentType }
.map { (_, events) -> events.maxBy { x -> x.event.unixMilliseconds ?: 0 } };
val nameEvent = profileEvents.firstOrNull { e -> e.event.contentType == ContentType.USERNAME.value }; val nameEvent = profileEvents.firstOrNull { e -> e.event.contentType == ContentType.USERNAME.value };
val avatarEvent = profileEvents.firstOrNull { e -> e.event.contentType == ContentType.AVATAR.value }; val avatarEvent = profileEvents.firstOrNull { e -> e.event.contentType == ContentType.AVATAR.value };
@@ -439,7 +564,8 @@ class StatePolycentric {
rating = RatingLikeDislikes(likes, dislikes), rating = RatingLikeDislikes(likes, dislikes),
date = if (unixMilliseconds != null) Instant.ofEpochMilli(unixMilliseconds).atOffset(ZoneOffset.UTC) else OffsetDateTime.MIN, date = if (unixMilliseconds != null) Instant.ofEpochMilli(unixMilliseconds).atOffset(ZoneOffset.UTC) else OffsetDateTime.MIN,
replyCount = replies.toInt(), replyCount = replies.toInt(),
eventPointer = sev.toPointer() eventPointer = sev.toPointer(),
parentReference = sev.event.references.getOrNull(0)
); );
} catch (e: Throwable) { } catch (e: Throwable) {
return@mapNotNull null; return@mapNotNull null;
@@ -1,52 +0,0 @@
package com.futo.platformplayer.states
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@kotlinx.serialization.Serializable
data class VideoToOpen(val url: String, val timeSeconds: Long);
class StateSaved {
var videoToOpen: VideoToOpen? = null;
private val _videoToOpen = FragmentedStorage.get<StringStorage>("videoToOpen")
fun load() {
val videoToOpenString = _videoToOpen.value;
if (videoToOpenString.isNotEmpty()) {
try {
val v = Serializer.json.decodeFromString<VideoToOpen>(videoToOpenString);
videoToOpen = v;
} catch (e: Throwable) {
Logger.w(TAG, "Failed to load video to open", e)
}
}
Logger.i(TAG, "loaded videoToOpen=$videoToOpen");
}
fun setVideoToOpenNonBlocking(v: VideoToOpen? = null) {
Logger.i(TAG, "set videoToOpen=$v");
videoToOpen = v;
_videoToOpen.setAndSave(if (v != null) Serializer.json.encodeToString(v) else "");
}
fun setVideoToOpenBlocking(v: VideoToOpen? = null) {
Logger.i(TAG, "set videoToOpen=$v");
videoToOpen = v;
_videoToOpen.setAndSaveBlocking(if (v != null) Serializer.json.encodeToString(v) else "");
}
companion object {
const val TAG = "StateSaved"
val instance: StateSaved = StateSaved()
}
}
@@ -1,27 +1,19 @@
package com.futo.platformplayer.states package com.futo.platformplayer.states
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.channels.SerializedChannel import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.* import com.futo.platformplayer.api.media.structures.*
import com.futo.platformplayer.api.media.structures.ReusablePager.Companion.asReusable import com.futo.platformplayer.api.media.structures.ReusablePager.Companion.asReusable
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.functional.CentralizedFeed
import com.futo.platformplayer.engine.exceptions.ScriptCriticalException
import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.findNonRuntimeException
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.getNowDiffDays
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.resolveChannelUrl
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
@@ -32,15 +24,10 @@ import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithms import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithms
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.concurrent.ExecutionException
import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask
import kotlin.collections.ArrayList
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import kotlin.streams.asSequence import kotlin.streams.asSequence
import kotlin.streams.toList
import kotlin.system.measureTimeMillis
/*** /***
* Used to maintain subscriptions * Used to maintain subscriptions
@@ -54,28 +41,26 @@ class StateSubscriptions {
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): Subscription = override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): Subscription =
Subscription(SerializedChannel.fromChannel(StatePlatform.instance.getChannelLive(backup, false))); Subscription(SerializedChannel.fromChannel(StatePlatform.instance.getChannelLive(backup, false)));
}).load(); }).load();
private val _subscriptionOthers = FragmentedStorage.storeJson<Subscription>("subscriptions_others")
.withUnique { it.channel.url }
.load();
private val _subscriptionsPool = ForkJoinPool(Settings.instance.subscriptions.getSubscriptionsConcurrency()); private val _subscriptionsPool = ForkJoinPool(Settings.instance.subscriptions.getSubscriptionsConcurrency());
private val _legacySubscriptions = FragmentedStorage.get<SubscriptionStorage>(); private val _legacySubscriptions = FragmentedStorage.get<SubscriptionStorage>();
private var _globalSubscriptionsLock = Object();
private var _globalSubscriptionFeed: ReusablePager<IPlatformContent>? = null;
var isGlobalUpdating: Boolean = false
private set;
var globalSubscriptionExceptions: List<Throwable> = listOf()
private set;
private val _algorithmSubscriptions = SubscriptionFetchAlgorithms.SMART; private val _algorithmSubscriptions = SubscriptionFetchAlgorithms.SMART;
private var _lastGlobalSubscriptionProgress: Int = 0; val global: CentralizedFeed = CentralizedFeed();
private var _lastGlobalSubscriptionTotal: Int = 0; val feeds: HashMap<String, CentralizedFeed> = hashMapOf();
val onGlobalSubscriptionsUpdateProgress = Event2<Int, Int>(); val onFeedProgress = Event3<String?, Int, Int>();
val onGlobalSubscriptionsUpdated = Event0();
val onGlobalSubscriptionsUpdatedOnce = Event1<Throwable?>();
val onGlobalSubscriptionsException = Event1<List<Throwable>>();
val onSubscriptionsChanged = Event2<List<Subscription>, Boolean>(); val onSubscriptionsChanged = Event2<List<Subscription>, Boolean>();
init {
global.onUpdateProgress.subscribe { progress, total ->
onFeedProgress.emit(null, progress, total);
}
}
fun getOldestUpdateTime(): OffsetDateTime { fun getOldestUpdateTime(): OffsetDateTime {
val subs = getSubscriptions(); val subs = getSubscriptions();
if(subs.size == 0) if(subs.size == 0)
@@ -83,75 +68,98 @@ class StateSubscriptions {
else else
return subs.minOf { it.lastVideoUpdate }; return subs.minOf { it.lastVideoUpdate };
} }
fun getGlobalSubscriptionProgress(): Pair<Int, Int> {
return Pair(_lastGlobalSubscriptionProgress, _lastGlobalSubscriptionTotal); fun getFeed(id: String? = null, createIfNew: Boolean = false): CentralizedFeed? {
if(id == null)
return global;
else {
return synchronized(feeds) {
var f = feeds[id];
if(f == null && createIfNew) {
f = CentralizedFeed();
f.onUpdateProgress.subscribe { progress, total ->
onFeedProgress.emit(id, progress, total)
};
feeds[id] = f;
}
return@synchronized f;
}
}
} }
fun updateSubscriptionFeed(scope: CoroutineScope, onlyIfNull: Boolean = false, onProgress: ((Int, Int)->Unit)? = null) {
fun getGlobalSubscriptionProgress(id: String? = null): Pair<Int, Int> {
val feed = getFeed(id, false) ?: return Pair(0, 0);
return Pair(feed.lastProgress, feed.lastTotal);
}
fun updateSubscriptionFeed(scope: CoroutineScope, onlyIfNull: Boolean = false, onProgress: ((Int, Int)->Unit)? = null, group: SubscriptionGroup? = null) {
val feed = getFeed(group?.id, true) ?: return;
Logger.v(TAG, "updateSubscriptionFeed"); Logger.v(TAG, "updateSubscriptionFeed");
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
synchronized(_globalSubscriptionsLock) { synchronized(feed.lock) {
if (isGlobalUpdating || (onlyIfNull && _globalSubscriptionFeed != null)) { if (feed.isGlobalUpdating || (onlyIfNull && feed.feed != null)) {
Logger.i(TAG, "Already updating subscriptions or not required") Logger.i(TAG, "Already updating subscriptions or not required")
return@launch; return@launch;
} }
isGlobalUpdating = true; feed.isGlobalUpdating = true;
} }
try { try {
val subsResult = getSubscriptionsFeedWithExceptions(true, true, scope, { progress, total -> val subsResult = getSubscriptionsFeedWithExceptions(true, true, scope, { progress, total ->
_lastGlobalSubscriptionProgress = progress; feed.lastProgress = progress;
_lastGlobalSubscriptionTotal = total; feed.lastTotal = total;
onGlobalSubscriptionsUpdateProgress.emit(progress, total); feed.onUpdateProgress.emit(progress, total);
onProgress?.invoke(progress, total); onProgress?.invoke(progress, total);
}); }, null, group);
if (subsResult.second.any()) { if (subsResult.second.any()) {
globalSubscriptionExceptions = subsResult.second; feed.exceptions = subsResult.second;
onGlobalSubscriptionsException.emit(subsResult.second); feed.onException.emit(subsResult.second);
} }
_globalSubscriptionFeed = subsResult.first.asReusable(); feed.feed = subsResult.first.asReusable();
synchronized(_globalSubscriptionsLock) { synchronized(feed.lock) {
onGlobalSubscriptionsUpdated.emit(); feed.onUpdated.emit();
onGlobalSubscriptionsUpdatedOnce.emit(null); feed.onUpdatedOnce.emit(null);
onGlobalSubscriptionsUpdatedOnce.clear(); feed.onUpdatedOnce.clear();
} }
} }
catch (e: Throwable) { catch (e: Throwable) {
synchronized(_globalSubscriptionsLock) { synchronized(feed.lock) {
onGlobalSubscriptionsUpdatedOnce.emit(e); feed.onUpdatedOnce.emit(e);
onGlobalSubscriptionsUpdatedOnce.clear(); feed.onUpdatedOnce.clear();
} }
Logger.e(TAG, "Failed to update subscription feed.", e); Logger.e(TAG, "Failed to update subscription feed.", e);
} }
finally { finally {
isGlobalUpdating = false; feed.isGlobalUpdating = false;
} }
}; };
} }
fun clearSubscriptionFeed() { fun clearSubscriptionFeed(id: String? = null) {
synchronized(_globalSubscriptionsLock) { val feed = getFeed(id) ?: return;
_globalSubscriptionFeed = null; synchronized(feed.lock) {
feed.feed = null;
} }
} }
private var loadIndex = 0; private var loadIndex = 0;
suspend fun getGlobalSubscriptionFeed(scope: CoroutineScope, updated: Boolean): IPager<IPlatformContent> { suspend fun getGlobalSubscriptionFeed(scope: CoroutineScope, updated: Boolean, group: SubscriptionGroup? = null): IPager<IPlatformContent> {
val feed = getFeed(group?.id, true) ?: return EmptyPager();
//Get Subscriptions only if null //Get Subscriptions only if null
updateSubscriptionFeed(scope, !updated); updateSubscriptionFeed(scope, !updated, null, group);
val evRef = Object(); val evRef = Object();
val result = suspendCoroutine { val result = suspendCoroutine {
synchronized(_globalSubscriptionsLock) { synchronized(feed.lock) {
if (_globalSubscriptionFeed != null && !updated) { if (feed.feed != null && !updated) {
Logger.i(TAG, "Subscriptions got feed preloaded"); Logger.i(TAG, "Subscriptions got feed preloaded");
it.resumeWith(Result.success(_globalSubscriptionFeed!!.getWindow())); it.resumeWith(Result.success(feed.feed!!.getWindow()));
} else { } else {
val loadIndex = loadIndex++; val loadIndex = loadIndex++;
Logger.i(TAG, "[${loadIndex}] Starting await update"); Logger.i(TAG, "[${loadIndex}] Starting await update");
onGlobalSubscriptionsUpdatedOnce.subscribe(evRef) {ex -> feed.onUpdatedOnce.subscribe(evRef) { ex ->
Logger.i(TAG, "[${loadIndex}] Subscriptions got feed after update"); Logger.i(TAG, "[${loadIndex}] Subscriptions got feed after update");
if(ex != null) if(ex != null)
it.resumeWithException(ex); it.resumeWithException(ex);
else if (_globalSubscriptionFeed != null) else if (feed.feed != null)
it.resumeWith(Result.success(_globalSubscriptionFeed!!.getWindow())); it.resumeWith(Result.success(feed.feed!!.getWindow()));
else else
it.resumeWithException(IllegalStateException("No subscription pager after change? Illegal null set on global subscriptions")) it.resumeWithException(IllegalStateException("No subscription pager after change? Illegal null set on global subscriptions"))
} }
@@ -176,12 +184,35 @@ class StateSubscriptions {
return _subscriptions.findItem { it.channel.url == url || it.channel.urlAlternatives.contains(url) }; return _subscriptions.findItem { it.channel.url == url || it.channel.urlAlternatives.contains(url) };
} }
} }
fun getSubscriptionOther(url: String) : Subscription? {
synchronized(_subscriptionOthers) {
return _subscriptionOthers.findItem { it.isChannel(url)};
}
}
fun getSubscriptionOtherOrCreate(url: String) : Subscription {
synchronized(_subscriptionOthers) {
val sub = getSubscriptionOther(url);
if(sub == null) {
val newSub = Subscription(SerializedChannel(PlatformID.NONE, url, null, null, 0, null, url, mapOf()));
newSub.isOther = true;
_subscriptions.save(newSub);
return newSub;
}
else return sub;
}
}
fun saveSubscription(sub: Subscription) { fun saveSubscription(sub: Subscription) {
_subscriptions.save(sub, false, true); _subscriptions.save(sub, false, true);
} }
fun saveSubscriptionAsync(sub: Subscription) { fun saveSubscriptionAsync(sub: Subscription) {
_subscriptions.saveAsync(sub, false, true); _subscriptions.saveAsync(sub, false, true);
} }
fun saveSubscriptionOther(sub: Subscription) {
_subscriptionOthers.save(sub, false, true);
}
fun saveSubscriptionOtherAsync(sub: Subscription) {
_subscriptionOthers.saveAsync(sub, false, true);
}
fun getSubscriptionCount(): Int { fun getSubscriptionCount(): Int {
synchronized(_subscriptions) { synchronized(_subscriptions) {
return _subscriptions.getItems().size; return _subscriptions.getItems().size;
@@ -239,12 +270,19 @@ class StateSubscriptions {
} }
} }
fun getSubscriptionRequestCount(): Map<JSClient, Int> { fun getSubscriptionRequestCount(subGroup: SubscriptionGroup? = null): Map<JSClient, Int> {
val subs = getSubscriptions();
val emulatedSubs = subGroup?.let {
it.urls.map {url ->
subs.find { it.channel.url == url }
?: getSubscriptionOtherOrCreate(url);
};
} ?: subs;
return SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, StateApp.instance.scope) return SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, StateApp.instance.scope)
.countRequests(getSubscriptions().associateWith { StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id, true) }); .countRequests(emulatedSubs.associateWith { StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id, true) });
} }
fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null): Pair<IPager<IPlatformContent>, List<Throwable>> { fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null, subGroup: SubscriptionGroup? = null): Pair<IPager<IPlatformContent>, List<Throwable>> {
val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool); val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool);
if(onNewCacheHit != null) if(onNewCacheHit != null)
algo.onNewCacheHit.subscribe(onNewCacheHit) algo.onNewCacheHit.subscribe(onNewCacheHit)
@@ -253,10 +291,19 @@ class StateSubscriptions {
onProgress?.invoke(progress, total); onProgress?.invoke(progress, total);
} }
val subs = getSubscriptions();
val emulatedSubs = subGroup?.let {
it.urls.map {url ->
subs.find { it.channel.url == url }
?: getSubscriptionOtherOrCreate(url);
};
} ?: subs;
val usePolycentric = true; val usePolycentric = true;
val lock = Object(); val lock = Object();
var polycentricBudget: Int = 10; var polycentricBudget: Int = 10;
val subUrls = getSubscriptions().parallelStream().map { val subUrls = emulatedSubs.parallelStream().map {
if(usePolycentric) { if(usePolycentric) {
val result = StatePolycentric.instance.getChannelUrlsWithUpdateResult(it.channel.url, it.channel.id, polycentricBudget <= 0, true); val result = StatePolycentric.instance.getChannelUrlsWithUpdateResult(it.channel.url, it.channel.id, polycentricBudget <= 0, true);
if(result.first) { if(result.first) {
@@ -2,15 +2,15 @@ package com.futo.platformplayer.states
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.os.Environment import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.* import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.copyToOutputStream
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.io.FileNotFoundException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
@@ -155,47 +155,45 @@ class StateUpdate {
} }
} }
fun checkForUpdates(context: Context, showUpToDateToast: Boolean) { suspend fun checkForUpdates(context: Context, showUpToDateToast: Boolean, hideExceptionButtons: Boolean = false) = withContext(Dispatchers.IO) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { try {
try { val client = ManagedHttpClient();
val client = ManagedHttpClient(); val latestVersion = downloadVersionCode(client);
val latestVersion = downloadVersionCode(client);
if (latestVersion != null) { if (latestVersion != null) {
val currentVersion = BuildConfig.VERSION_CODE; val currentVersion = BuildConfig.VERSION_CODE;
Logger.i(TAG, "Current version ${currentVersion} latest version ${latestVersion}."); Logger.i(TAG, "Current version ${currentVersion} latest version ${latestVersion}.");
if (latestVersion > currentVersion) { if (latestVersion > currentVersion) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
try { try {
UIDialogs.showUpdateAvailableDialog(context, latestVersion); UIDialogs.showUpdateAvailableDialog(context, latestVersion, hideExceptionButtons);
} catch (e: Throwable) { } catch (e: Throwable) {
UIDialogs.toast(context, "Failed to show update dialog"); UIDialogs.toast(context, "Failed to show update dialog");
Logger.w(TAG, "Error occurred in update dialog."); Logger.w(TAG, "Error occurred in update dialog.");
}
}
} else {
if (showUpToDateToast) {
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Already on latest version");
}
} }
} }
} else { } else {
Logger.w(TAG, "Failed to retrieve version from version URL."); if (showUpToDateToast) {
withContext(Dispatchers.Main) {
withContext(Dispatchers.Main) { UIDialogs.toast(context, "Already on latest version");
UIDialogs.toast(context, "Failed to retrieve version"); }
} }
} }
} catch (e: Throwable) { } else {
Logger.w(TAG, "Failed to check for updates.", e); Logger.w(TAG, "Failed to retrieve version from version URL.");
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to check for updates"); UIDialogs.toast(context, "Failed to retrieve version");
} }
} }
}; } catch (e: Throwable) {
Logger.w(TAG, "Failed to check for updates.", e);
withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to check for updates");
}
}
} }
private fun downloadApkToFile(client: ManagedHttpClient, destinationFile: File, isCancelled: (() -> Boolean)? = null) { private fun downloadApkToFile(client: ManagedHttpClient, destinationFile: File, isCancelled: (() -> Boolean)? = null) {
@@ -12,6 +12,7 @@ import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.stores.v2.JsonStoreSerializer import com.futo.platformplayer.stores.v2.JsonStoreSerializer
import com.futo.platformplayer.stores.v2.StoreSerializer import com.futo.platformplayer.stores.v2.StoreSerializer
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import java.lang.IllegalArgumentException
import java.lang.reflect.Field import java.lang.reflect.Field
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentMap import java.util.concurrent.ConcurrentMap
@@ -209,7 +210,9 @@ class ManagedDBStore<I: ManagedDBIndex<T>, T, D: ManagedDBDatabase<T, I, DA>, DA
fun getObject(id: Long) = get(id).obj!!; fun getObject(id: Long) = get(id).obj!!;
fun get(id: Long): I { fun get(id: Long): I {
return deserializeIndex(dbDaoBase.get(_sqlGet(id))); val result = dbDaoBase.getNullable(_sqlGet(id))
?: throw IllegalArgumentException("DB [${name}] has no entry with id ${id}");
return deserializeIndex(result);
} }
fun getOrNull(id: Long): I? { fun getOrNull(id: Long): I? {
val result = dbDaoBase.getNullable(_sqlGet(id)); val result = dbDaoBase.getNullable(_sqlGet(id));
@@ -13,6 +13,7 @@ import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptCriticalException import com.futo.platformplayer.engine.exceptions.ScriptCriticalException
import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.exceptions.ChannelException
import com.futo.platformplayer.findNonRuntimeException import com.futo.platformplayer.findNonRuntimeException
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment
@@ -55,7 +56,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
val clientCacheCount = clientTasks.value.size - clientTaskCount; val clientCacheCount = clientTasks.value.size - clientTaskCount;
val limit = clientTasks.key.getSubscriptionRateLimit(); val limit = clientTasks.key.getSubscriptionRateLimit();
if(clientCacheCount > 0 && clientTaskCount > 0 && limit != null && clientTaskCount >= limit && StateApp.instance.contextOrNull?.let { it is MainActivity && it.isFragmentActive<SubscriptionsFeedFragment>() } == true) { if(clientCacheCount > 0 && clientTaskCount > 0 && limit != null && clientTaskCount >= limit && StateApp.instance.contextOrNull?.let { it is MainActivity && it.isFragmentActive<SubscriptionsFeedFragment>() } == true) {
UIDialogs.toast("[${clientTasks.key.name}] only updating ${clientTaskCount} most urgent channels (rqs). (${clientCacheCount} cached)"); UIDialogs.appToast("[${clientTasks.key.name}] only updating ${clientTaskCount} most urgent channels (rqs). (${clientCacheCount} cached)");
} }
} }
@@ -69,7 +70,6 @@ abstract class SubscriptionsTaskFetchAlgorithm(
val cachedChannels = mutableListOf<String>() val cachedChannels = mutableListOf<String>()
val forkTasks = executeSubscriptionTasks(tasks, failedPlugins, cachedChannels); val forkTasks = executeSubscriptionTasks(tasks, failedPlugins, cachedChannels);
val taskResults = arrayListOf<SubscriptionTaskResult>(); val taskResults = arrayListOf<SubscriptionTaskResult>();
val timeTotal = measureTimeMillis { val timeTotal = measureTimeMillis {
for(task in forkTasks) { for(task in forkTasks) {
@@ -126,7 +126,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
val pager = MultiChronoContentPager(groupedPagers, allowFailure, 15); val pager = MultiChronoContentPager(groupedPagers, allowFailure, 15);
pager.initialize(); pager.initialize();
return Result(DedupContentPager(pager), exs); return Result(DedupContentPager(pager, StatePlatform.instance.getEnabledClients().map { it.id }), exs);
} }
fun executeSubscriptionTasks(tasks: List<SubscriptionTask>, failedPlugins: MutableList<String>, cachedChannels: MutableList<String>): List<ForkJoinTask<SubscriptionTaskResult>> { fun executeSubscriptionTasks(tasks: List<SubscriptionTask>, failedPlugins: MutableList<String>, cachedChannels: MutableList<String>): List<ForkJoinTask<SubscriptionTaskResult>> {
@@ -200,7 +200,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
else { else {
Logger.i(StateSubscriptions.TAG, "Channel ${task.sub.channel.name} failed, substituting with cache"); Logger.i(StateSubscriptions.TAG, "Channel ${task.sub.channel.name} failed, substituting with cache");
pager = StateCache.instance.getChannelCachePager(task.sub.channel.url); pager = StateCache.instance.getChannelCachePager(task.sub.channel.url);
taskEx = ex; taskEx = channelEx;
return@submit SubscriptionTaskResult(task, pager, taskEx); return@submit SubscriptionTaskResult(task, pager, taskEx);
} }
} }
@@ -0,0 +1,456 @@
package com.futo.platformplayer.views
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PointF
import android.util.AttributeSet
import android.view.View
import java.security.MessageDigest
import kotlin.math.max
import kotlin.math.min
class IdenticonView(context: Context, attrs: AttributeSet) : View(context, attrs) {
var hashString: String = "default"
set(value) {
field = value
hash = md5(value)
iconGenerator = null
invalidate()
}
private var hash = ByteArray(16)
private var iconGenerator: IconGenerator? = null
private val path = Path()
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val radius = (width.coerceAtMost(height) / 2).toFloat()
val clipPath = path.apply {
reset()
addCircle(width / 2f, height / 2f, radius, Path.Direction.CW)
}
canvas.clipPath(clipPath)
if (iconGenerator == null) {
iconGenerator = IconGenerator(min(height, width).toFloat(), hash)
}
iconGenerator?.render(canvas)
}
private fun md5(input: String): ByteArray {
val md = MessageDigest.getInstance("MD5")
return md.digest(input.toByteArray(Charsets.UTF_8))
}
interface Shape {
fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint)
}
class CutCorner : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val k = size * 0.42f
val path = Path().apply {
moveTo(0f, 0f)
lineTo(size, 0f)
lineTo(size, size - k * 2)
lineTo(size - k, size)
lineTo(0f, size)
close()
}
canvas.drawPath(path, paint)
}
}
class SideTriangle : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val w = size / 2
val h = size * 0.8f
val path = Path().apply {
moveTo(size - w, 0f)
lineTo(size, h)
lineTo(size - w, h)
close()
}
canvas.drawPath(path, paint)
}
}
class MiddleSquare : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val s = size / 3
canvas.drawRect(s, s, size - s, size - s, paint)
}
}
class CornerSquare : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val inner = size * 0.1f
val outer = max(1f, size * 0.25f)
canvas.drawRect(outer, outer, size - inner - outer, size - inner - outer, paint)
}
}
class OffCenterCircle : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val m = size * 0.15f
val s = size * 0.5f
canvas.drawCircle(size - s - m, size - s - m, s / 2, paint)
}
}
class NegativeTriangle : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val inner = size * 0.1f
val outer = inner * 4
val path = Path().apply {
addRect(0f, 0f, size, size, Path.Direction.CW)
moveTo(outer, outer)
lineTo(size - inner, outer)
lineTo(outer + (size - outer - inner) / 2, size - inner)
close()
}
canvas.drawPath(path, paint)
}
}
class CutSquare : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val path = Path().apply {
moveTo(0f, 0f)
lineTo(size, 0f)
lineTo(size, size * 0.7f)
lineTo(size * 0.4f, size * 0.4f)
lineTo(size * 0.7f, size)
lineTo(0f, size)
close()
}
canvas.drawPath(path, paint)
}
}
class CornerPlusTriangle : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val halfSize = size / 2
canvas.drawRect(0f, 0f, size, halfSize, paint)
canvas.drawRect(0f, halfSize, halfSize, size, paint)
val path = Path().apply {
moveTo(halfSize, halfSize)
lineTo(size, halfSize)
lineTo(halfSize, size)
close()
}
canvas.drawPath(path, paint)
}
}
class NegativeSquare : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val inner = size * 0.14f
val outer = size * 0.35f
val path = Path().apply {
addRect(0f, 0f, size, size, Path.Direction.CW)
addRect(outer, outer, size - outer - inner, size - outer - inner, Path.Direction.CCW)
}
canvas.drawPath(path, paint)
}
}
class NegativeCircle : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val inner = size * 0.12f
val outer = inner * 3
val path = Path().apply {
addRect(0f, 0f, size, size, Path.Direction.CW)
addCircle(outer, outer, (size - inner - outer) / 2, Path.Direction.CCW)
}
canvas.drawPath(path, paint)
}
}
class NegativeRhombus : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val m = size * 0.25f
val path = Path().apply {
addRect(0f, 0f, size, size, Path.Direction.CW)
moveTo(m, size / 2)
lineTo(size / 2, m)
lineTo(size - m, size / 2)
lineTo(size / 2, size - m)
close()
}
canvas.drawPath(path, paint)
}
}
class ConditionalCircle : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
if (index == 0) {
val m = size * 0.4f
val s = size * 1.2f
canvas.drawCircle(m, m, s / 2, paint)
}
}
}
class HalfTriangle : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val path = Path().apply {
moveTo(size / 2, size / 2)
lineTo(size, size / 2)
lineTo(size / 2, size)
close()
}
canvas.drawPath(path, paint)
}
}
class Triangle(val corner: Int = 0) : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val path = Path().apply {
when (corner) {
0 -> {
moveTo(0f, 0f)
lineTo(size, 0f)
lineTo(0f, size)
}
1 -> {
moveTo(size, 0f)
lineTo(size, size)
lineTo(0f, size)
}
2 -> {
moveTo(0f, 0f)
lineTo(size, 0f)
lineTo(size, size)
}
3 -> {
moveTo(0f, 0f)
lineTo(0f, size)
lineTo(size, size)
}
}
close()
}
canvas.drawPath(path, paint)
}
}
class BottomHalfTriangle : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val path = Path().apply {
moveTo(0f, size / 2)
lineTo(size, size / 2)
lineTo(size / 2, size)
close()
}
canvas.drawPath(path, paint)
}
}
class Rhombus : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val path = Path().apply {
moveTo(size / 2, 0f)
lineTo(size, size / 2)
lineTo(size / 2, size)
lineTo(0f, size / 2)
close()
}
canvas.drawPath(path, paint)
}
}
class Circle : Shape {
override fun draw(canvas: Canvas, size: Float, index: Int, paint: Paint) {
val m = size / 6
canvas.drawCircle(m, m, size / 2 - m, paint)
}
}
class IconGenerator(private val size: Float, private val hash: ByteArray) {
private val digits: ByteArray
private var selectedColors = arrayOf<Paint>()
init {
digits = ByteArray(max(12, hash.size * 2))
var index = 0
for (byte in hash) {
if (index >= digits.size) {
break
}
digits[index] = ((byte.toInt() shr 4) and 0x0f).toByte()
digits[index + 1] = (byte.toInt() and 0x0f).toByte()
index += 2
}
selectColors()
}
private fun selectColors() {
val value = hash.copyOfRange(hash.size - 4, hash.size).fold(0) { acc, byte ->
(acc shl 8) or (byte.toInt() and 0xFF)
} and 0x0FFFFFFF
val colorTheme = ColorTheme(hue = value.toFloat() / 0x0FFFFFFF)
val selectedColorIndices = mutableListOf<Int>()
for (i in 0 until 3) {
val index = (digits[8 + i].toInt() % colorTheme.colors.size)
selectedColorIndices.add(colorTheme.validateIndex(index, selectedColorIndices))
}
selectedColors = selectedColorIndices.map { index ->
Paint().apply {
color = colorTheme.colors[index]
style = Paint.Style.FILL
}
}.toTypedArray()
}
fun renderBitmap(): Bitmap {
val bitmap = Bitmap.createBitmap(size.toInt(), size.toInt(), Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
render(canvas)
return bitmap
}
fun render(canvas: Canvas) {
canvas.drawColor(Color.WHITE)
renderShape(canvas, 0, outerShapes, 2, 3, arrayOf(
PointF(1f, 0f),
PointF(2f, 0f),
PointF(2f, 3f),
PointF(1f, 3f),
PointF(0f, 1f),
PointF(3f, 1f),
PointF(3f, 2f),
PointF(0f, 2f),
))
renderShape(canvas, 1, outerShapes, 4, 5, arrayOf(
PointF(0f, 0f),
PointF(3f, 0f),
PointF(3f, 3f),
PointF(0f, 3f),
))
renderShape(canvas, 2, centerShapes, 1, null, arrayOf(
PointF(1f, 1f),
PointF(2f, 1f),
PointF(2f, 2f),
PointF(1f, 2f),
))
}
private fun renderShape(
canvas: Canvas,
colorIndex: Int,
shapes: Array<Shape>,
index: Int,
rotationIndex: Int?,
positions: Array<PointF>
) {
val cellSize = size / 4
var r = rotationIndex?.let { digits[it].toInt() } ?: 0
val shape = shapes[digits[index].toInt() % shapes.size]
val paint = Paint().apply {
color = selectedColors[colorIndex % selectedColors.size].color
style = Paint.Style.FILL
}
for ((idx, position) in positions.withIndex()) {
canvas.save()
canvas.translate(position.x * cellSize, position.y * cellSize)
canvas.translate(cellSize / 2, cellSize / 2)
canvas.rotate((r % 4) * 90f)
canvas.translate(-cellSize / 2, -cellSize / 2)
shape.draw(canvas, cellSize, idx, paint)
canvas.restore()
r++
}
}
class ColorTheme(val hue: Float, val saturation: Float = 0.5f) {
val colors: List<Int>
init {
colors = listOf(
// Dark gray
grayscaleColor(0f),
// Mid color
hslColor(hue, saturation, colorLightness(0.5f)),
// Light gray
grayscaleColor(1f),
// Light color
hslColor(hue, saturation, colorLightness(1f)),
// Dark color
hslColor(hue, saturation, colorLightness(0f))
)
}
fun validateIndex(index: Int, selected: List<Int>): Int {
return if (isDuplicate(index, listOf(0, 4), selected) || isDuplicate(index, listOf(2, 3), selected)) {
1
} else {
index
}
}
private fun isDuplicate(index: Int, values: List<Int>, selected: List<Int>): Boolean {
if (!values.contains(index)) return false
return values.any { selected.contains(it) }
}
private fun colorLightness(value: Float): Float = lightness(value, 0.4f, 0.8f)
private fun grayscaleLightness(value: Float): Float = lightness(value, 0.3f, 0.9f)
private fun lightness(value: Float, min: Float, max: Float): Float {
val lightness = min + value * (max - min)
return minOf(1f, maxOf(0f, lightness))
}
private fun grayscaleColor(lightness: Float): Int {
return Color.HSVToColor(floatArrayOf(0f, 0f, lightness))
}
private fun hslColor(hue: Float, saturation: Float, lightness: Float): Int {
return Color.HSVToColor(floatArrayOf(hue, saturation, lightness))
}
}
}
companion object {
val centerShapes = arrayOf(
CutCorner(),
SideTriangle(),
MiddleSquare(),
CornerSquare(),
OffCenterCircle(),
NegativeTriangle(),
CutSquare(),
HalfTriangle(),
CornerPlusTriangle(),
CutSquare(),
NegativeCircle(),
HalfTriangle(),
NegativeRhombus(),
ConditionalCircle()
)
val outerShapes = arrayOf(
Triangle(),
BottomHalfTriangle(),
Rhombus(),
Circle(),
)
private const val TAG = "IdenticonView"
}
}
@@ -8,11 +8,13 @@ import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.HorizontalSpaceItemDecoration import com.futo.platformplayer.HorizontalSpaceItemDecoration
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricCache
@@ -61,6 +63,7 @@ class MonetizationView : LinearLayout {
val onSupportTap = Event0(); val onSupportTap = Event0();
val onStoreTap = Event0(); val onStoreTap = Event0();
val onUrlTap = Event1<String>();
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.view_monetization, this); inflate(context, R.layout.view_monetization, this);
@@ -70,10 +73,12 @@ class MonetizationView : LinearLayout {
_membershipPlatform = findViewById(R.id.membership_platform); _membershipPlatform = findViewById(R.id.membership_platform);
_buttonMembership.setOnClickListener { _buttonMembership.setOnClickListener {
_membershipUrl?.let { _membershipUrl?.let {
/*
val uri = Uri.parse(it); val uri = Uri.parse(it);
val intent = Intent(Intent.ACTION_VIEW); val intent = Intent(Intent.ACTION_VIEW);
intent.data = uri; intent.data = uri;
context.startActivity(intent); context.startActivity(intent);*/
onUrlTap.emit(it);
} }
} }
@@ -129,9 +134,18 @@ class MonetizationView : LinearLayout {
_buttonStore.visibility = View.GONE; _buttonStore.visibility = View.GONE;
} }
if(profile.systemState.donationDestinations.isNotEmpty() ||
profile.systemState.membershipUrls.isNotEmpty() ||
profile.systemState.store.isNotEmpty() ||
profile.systemState.promotion.isNotEmpty())
_buttonSupport.isVisible = true;
else
_buttonSupport.isVisible = false;
_root.visibility = View.VISIBLE; _root.visibility = View.VISIBLE;
} else { } else {
_root.visibility = View.GONE; _root.visibility = View.GONE;
_buttonSupport.isVisible = false;
} }
setMerchandise(null); setMerchandise(null);
@@ -10,6 +10,8 @@ import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.core.view.isVisible
import androidx.core.view.size
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
@@ -33,6 +35,13 @@ class SupportView : LinearLayout {
private var _textNoSupportOptionsSet: TextView private var _textNoSupportOptionsSet: TextView
private var _polycentricProfile: PolycentricProfile? = null private var _polycentricProfile: PolycentricProfile? = null
val hasSupportItems: Boolean get() {
return (_layoutPromotions.isVisible && _buttonPromotion.isVisible) ||
(_layoutMemberships.isVisible && _layoutMembershipEntries.isVisible && _layoutMembershipEntries.size > 0) ||
(_layoutDonation.isVisible && _layoutDonationEntries.isVisible && _layoutDonationEntries.size > 0) ||
_buttonStore.isVisible;
};
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.view_support, this); inflate(context, R.layout.view_support, this);
@@ -0,0 +1,91 @@
package com.futo.platformplayer.views
import android.content.Context
import android.graphics.Color
import android.util.AttributeSet
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import com.futo.platformplayer.R
import com.futo.platformplayer.dp
import com.futo.platformplayer.logging.Logger
class ToastView : LinearLayout {
private val root: LinearLayout;
private val title: TextView;
private val text: TextView;
init {
inflate(context, R.layout.toast, this);
root = findViewById(R.id.root);
title = findViewById(R.id.title);
text = findViewById(R.id.text);
}
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
setToast(ToastView.Toast("", false))
root.visibility = GONE;
}
fun hide(animate: Boolean, onFinished: (()->Unit)? = null) {
Logger.i("MainActivity", "Hiding toast");
if(!animate) {
root.visibility = GONE;
alpha = 0f;
onFinished?.invoke();
}
else {
animate()
.alpha(0f)
.setDuration(700)
.translationY(20.dp(context.resources).toFloat())
.withEndAction { root.visibility = GONE; onFinished?.invoke(); }
.start();
}
}
fun show(animate: Boolean) {
Logger.i("MainActivity", "Showing toast");
if(!animate) {
root.visibility = VISIBLE;
alpha = 1f;
}
else {
alpha = 0f;
root.visibility = VISIBLE;
translationY = 20.dp(context.resources).toFloat();
animate()
.alpha(1f)
.setDuration(700)
.translationY(0f)
.start();
}
}
fun setToast(toast: Toast) {
if(toast.title.isNullOrEmpty())
title.isVisible = false;
else {
title.text = toast.title;
title.isVisible = true;
}
text.text = toast.msg;
if(toast.color != null)
text.setTextColor(toast.color);
else
text.setTextColor(Color.WHITE);
}
fun setToastAnimated(toast: Toast) {
hide(true) {
setToast(toast);
show(true);
};
}
class Toast(
val msg: String,
val long: Boolean,
val color: Int? = null,
val title: String? = null
);
}
@@ -9,15 +9,21 @@ import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.* import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.api.media.models.ratings.RatingLikes import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.fixHtmlLinks
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.others.CreatorThumbnail import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.pills.PillButton import com.futo.platformplayer.views.pills.PillButton
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
@@ -104,7 +110,8 @@ class CommentViewHolder : ViewHolder {
fun bind(comment: IPlatformComment, readonly: Boolean) { fun bind(comment: IPlatformComment, readonly: Boolean) {
_creatorThumbnail.setThumbnail(comment.author.thumbnail, false); _creatorThumbnail.setThumbnail(comment.author.thumbnail, false);
_creatorThumbnail.setHarborAvailable(comment is PolycentricPlatformComment,false); val polycentricComment = if (comment is PolycentricPlatformComment) comment else null
_creatorThumbnail.setHarborAvailable(polycentricComment != null,false, polycentricComment?.eventPointer?.system?.toProto());
_textAuthor.text = comment.author.name; _textAuthor.text = comment.author.name;
val date = comment.date; val date = comment.date;
@@ -161,8 +168,8 @@ class CommentViewHolder : ViewHolder {
_pillRatingLikesDislikes.visibility = View.VISIBLE; _pillRatingLikesDislikes.visibility = View.VISIBLE;
if (comment is PolycentricPlatformComment) { if (comment is PolycentricPlatformComment) {
val hasLiked = StatePolycentric.instance.hasLiked(comment.reference); val hasLiked = StatePolycentric.instance.hasLiked(comment.reference.toByteArray());
val hasDisliked = StatePolycentric.instance.hasDisliked(comment.reference); val hasDisliked = StatePolycentric.instance.hasDisliked(comment.reference.toByteArray());
_pillRatingLikesDislikes.setRating(comment.rating, hasLiked, hasDisliked); _pillRatingLikesDislikes.setRating(comment.rating, hasLiked, hasDisliked);
} else { } else {
_pillRatingLikesDislikes.setRating(comment.rating); _pillRatingLikesDislikes.setRating(comment.rating);
@@ -55,6 +55,7 @@ class CommentWithReferenceViewHolder : ViewHolder {
var onRepliesClick = Event1<IPlatformComment>(); var onRepliesClick = Event1<IPlatformComment>();
var onDelete = Event1<IPlatformComment>(); var onDelete = Event1<IPlatformComment>();
var onClick = Event1<IPlatformComment>();
var comment: IPlatformComment? = null var comment: IPlatformComment? = null
private set; private set;
@@ -108,6 +109,11 @@ class CommentWithReferenceViewHolder : ViewHolder {
onDelete.emit(c); onDelete.emit(c);
} }
_layoutComment.setOnClickListener {
val c = comment ?: return@setOnClickListener;
onClick.emit(c);
}
_textBody.setPlatformPlayerLinkMovementMethod(viewGroup.context); _textBody.setPlatformPlayerLinkMovementMethod(viewGroup.context);
} }
@@ -126,7 +132,8 @@ class CommentWithReferenceViewHolder : ViewHolder {
_taskGetLiveComment.cancel() _taskGetLiveComment.cancel()
_creatorThumbnail.setThumbnail(comment.author.thumbnail, false); _creatorThumbnail.setThumbnail(comment.author.thumbnail, false);
_creatorThumbnail.setHarborAvailable(comment is PolycentricPlatformComment,false); val polycentricComment = if (comment is PolycentricPlatformComment) comment else null
_creatorThumbnail.setHarborAvailable(polycentricComment != null,false, polycentricComment?.eventPointer?.system?.toProto());
_textAuthor.text = comment.author.name; _textAuthor.text = comment.author.name;
val date = comment.date; val date = comment.date;
@@ -168,8 +175,8 @@ class CommentWithReferenceViewHolder : ViewHolder {
if (likesDislikesReplies != null) { if (likesDislikesReplies != null) {
Log.i(TAG, "updateLikesDislikesReplies set") Log.i(TAG, "updateLikesDislikesReplies set")
val hasLiked = StatePolycentric.instance.hasLiked(c.reference); val hasLiked = StatePolycentric.instance.hasLiked(c.reference.toByteArray());
val hasDisliked = StatePolycentric.instance.hasDisliked(c.reference); val hasDisliked = StatePolycentric.instance.hasDisliked(c.reference.toByteArray());
_pillRatingLikesDislikes.setRating(RatingLikeDislikes(likesDislikesReplies.likes, likesDislikesReplies.dislikes), hasLiked, hasDisliked); _pillRatingLikesDislikes.setRating(RatingLikeDislikes(likesDislikesReplies.likes, likesDislikesReplies.dislikes), hasLiked, hasDisliked);
_buttonReplies.setLoading(false) _buttonReplies.setLoading(false)
@@ -5,7 +5,6 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.CastingDevice
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> { class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
@@ -13,6 +12,7 @@ class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
private val _isRememberedDevice: Boolean; private val _isRememberedDevice: Boolean;
var onRemove = Event1<CastingDevice>(); var onRemove = Event1<CastingDevice>();
var onConnect = Event1<CastingDevice>();
constructor(devices: ArrayList<CastingDevice>, isRememberedDevice: Boolean) : super() { constructor(devices: ArrayList<CastingDevice>, isRememberedDevice: Boolean) : super() {
_devices = devices; _devices = devices;
@@ -26,6 +26,7 @@ class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
val holder = DeviceViewHolder(view); val holder = DeviceViewHolder(view);
holder.setIsRememberedDevice(_isRememberedDevice); holder.setIsRememberedDevice(_isRememberedDevice);
holder.onRemove.subscribe { d -> onRemove.emit(d); }; holder.onRemove.subscribe { d -> onRemove.emit(d); };
holder.onConnect.subscribe { d -> onConnect.emit(d); }
return holder; return holder;
} }
@@ -31,6 +31,7 @@ class DeviceViewHolder : ViewHolder {
private set private set
var onRemove = Event1<CastingDevice>(); var onRemove = Event1<CastingDevice>();
val onConnect = Event1<CastingDevice>();
constructor(view: View) : super(view) { constructor(view: View) : super(view) {
_imageDevice = view.findViewById(R.id.image_device); _imageDevice = view.findViewById(R.id.image_device);
@@ -56,7 +57,7 @@ class DeviceViewHolder : ViewHolder {
val dev = device ?: return@setOnClickListener; val dev = device ?: return@setOnClickListener;
StateCasting.instance.activeDevice?.stopCasting(); StateCasting.instance.activeDevice?.stopCasting();
StateCasting.instance.connectDevice(dev); StateCasting.instance.connectDevice(dev);
updateButton(); onConnect.emit(dev);
}; };
_buttonRemove.setOnClickListener { _buttonRemove.setOnClickListener {
@@ -64,6 +65,10 @@ class DeviceViewHolder : ViewHolder {
onRemove.emit(dev); onRemove.emit(dev);
}; };
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ ->
updateButton();
}
setIsRememberedDevice(false); setIsRememberedDevice(false);
} }
@@ -1,42 +0,0 @@
package com.futo.platformplayer.views.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.constructs.Event1
class DisabledSourceAdapter : RecyclerView.Adapter<DisabledSourceViewHolder> {
private val _sources: MutableList<IPlatformClient>;
var onClick = Event1<IPlatformClient>();
var onAdd = Event1<IPlatformClient>();
constructor(sources: MutableList<IPlatformClient>) : super() {
_sources = sources;
}
override fun getItemCount() = _sources.size
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): DisabledSourceViewHolder {
val holder = DisabledSourceViewHolder(viewGroup);
holder.onAdd.subscribe {
val source = holder.source;
if (source != null) {
onAdd.emit(source);
}
}
holder.onClick.subscribe {
val source = holder.source;
if (source != null) {
onClick.emit(source);
}
};
return holder;
}
override fun onBindViewHolder(viewHolder: DisabledSourceViewHolder, position: Int) {
viewHolder.bind(_sources[position])
}
}
@@ -1,17 +1,15 @@
package com.futo.platformplayer.views.adapters package com.futo.platformplayer.views.adapters
import android.content.Context import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.states.StatePlatform
class DisabledSourceView : LinearLayout { class DisabledSourceView : LinearLayout {
private val _root: LinearLayout; private val _root: LinearLayout;
@@ -38,7 +36,16 @@ class DisabledSourceView : LinearLayout {
client.icon?.setImageView(_imageSource); client.icon?.setImageView(_imageSource);
_textSource.text = client.name; _textSource.text = client.name;
_textSourceSubtitle.text = context.getString(R.string.tap_to_open);
if (client is JSClient && StatePlatform.instance.hasUpdateAvailable(client.config)) {
_textSourceSubtitle.text = context.getString(R.string.update_available_exclamation)
_textSourceSubtitle.setTextColor(context.getColor(R.color.light_blue_400))
_textSourceSubtitle.typeface = resources.getFont(R.font.inter_regular)
} else {
_textSourceSubtitle.text = context.getString(R.string.tap_to_open)
_textSourceSubtitle.setTextColor(context.getColor(R.color.gray_ac))
_textSourceSubtitle.typeface = resources.getFont(R.font.inter_extra_light)
}
_buttonAdd.setOnClickListener { onAdd.emit(source) } _buttonAdd.setOnClickListener { onAdd.emit(source) }
_root.setOnClickListener { onClick.emit(); }; _root.setOnClickListener { onClick.emit(); };
@@ -1,44 +0,0 @@
package com.futo.platformplayer.views.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.constructs.Event0
class DisabledSourceViewHolder : ViewHolder {
private val _imageSource: ImageView;
private val _textSource: TextView;
private val _textSourceSubtitle: TextView;
private val _buttonAdd: LinearLayout;
var onClick = Event0();
var onAdd = Event0();
var source: IPlatformClient? = null
private set
constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_source_disabled, viewGroup, false)) {
_imageSource = itemView.findViewById(R.id.image_source);
_textSource = itemView.findViewById(R.id.text_source);
_textSourceSubtitle = itemView.findViewById(R.id.text_source_subtitle);
_buttonAdd = itemView.findViewById(R.id.button_add);
val root = itemView.findViewById<LinearLayout>(R.id.root);
_buttonAdd.setOnClickListener { onAdd.emit() }
root.setOnClickListener { onClick.emit(); };
}
fun bind(client: IPlatformClient) {
client.icon?.setImageView(_imageSource);
_textSource.text = client.name;
_textSourceSubtitle.text = itemView.context.getString(R.string.tap_to_open);
source = client;
}
}
@@ -10,7 +10,9 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.states.StatePlatform
class EnabledSourceViewHolder : ViewHolder { class EnabledSourceViewHolder : ViewHolder {
private val _imageSource: ImageView; private val _imageSource: ImageView;
@@ -57,8 +59,18 @@ class EnabledSourceViewHolder : ViewHolder {
fun bind(client: IPlatformClient) { fun bind(client: IPlatformClient) {
client.icon?.setImageView(_imageSource); client.icon?.setImageView(_imageSource);
_textSource.text = client.name; _textSource.text = client.name
_textSourceSubtitle.text = itemView.context.getString(R.string.tap_to_open);
if (client is JSClient && StatePlatform.instance.hasUpdateAvailable(client.config)) {
_textSourceSubtitle.text = itemView.context.getString(R.string.update_available_exclamation)
_textSourceSubtitle.setTextColor(itemView.context.getColor(R.color.light_blue_400))
_textSourceSubtitle.typeface = itemView.resources.getFont(R.font.inter_regular)
} else {
_textSourceSubtitle.text = itemView.context.getString(R.string.tap_to_open)
_textSourceSubtitle.setTextColor(itemView.context.getColor(R.color.gray_ac))
_textSourceSubtitle.typeface = itemView.resources.getFont(R.font.inter_extra_light)
}
source = client source = client
} }
} }
@@ -7,7 +7,7 @@ import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.futo.platformplayer.* import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
@@ -18,8 +18,8 @@ import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator import com.futo.platformplayer.views.platform.PlatformIndicator
@@ -149,7 +149,8 @@ open class PlaylistView : LinearLayout {
_neopassAnimator?.cancel(); _neopassAnimator?.cancel();
_neopassAnimator = null; _neopassAnimator = null;
val harborAvailable = claims != null && !claims.ownedClaims.isNullOrEmpty(); val firstClaim = claims?.ownedClaims?.firstOrNull();
val harborAvailable = firstClaim != null
if (harborAvailable) { if (harborAvailable) {
_imageNeopassChannel?.visibility = View.VISIBLE _imageNeopassChannel?.visibility = View.VISIBLE
if (animate) { if (animate) {
@@ -160,7 +161,7 @@ open class PlaylistView : LinearLayout {
_imageNeopassChannel?.visibility = View.GONE _imageNeopassChannel?.visibility = View.GONE
} }
_creatorThumbnail?.setHarborAvailable(harborAvailable, animate) _creatorThumbnail?.setHarborAvailable(harborAvailable, animate, firstClaim?.system?.toProto())
} }
companion object { companion object {

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