Compare commits

...

134 Commits

Author SHA1 Message Date
Kelvin f3fa208680 Kick subs fix, dedup fix 2023-11-22 18:04:29 +01:00
Kelvin 502602e27a Reordering progress bar settings 2023-11-22 16:50:54 +01:00
Kelvin 5054b093a4 Stable refs 2023-11-22 16:15:05 +01:00
Kelvin 0ffaec6bc2 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-22 16:05:39 +01:00
Kelvin ef8ea9eecf Fix whitelist checking for dev-portal 2023-11-22 16:05:27 +01:00
Koen b09d22e479 Added historical time bars to videos. 2023-11-22 14:49:34 +01:00
Koen 01787b6229 Added backfill exception printing to announcements. 2023-11-22 12:46:39 +01:00
Koen 4c022698d3 Quality selection overlay now properly closes when pressing the back button. 2023-11-22 11:32:51 +01:00
Koen bfdcab0e84 Properly handle V1 encrypted secrets in the upgrade process from V0 to V1. 2023-11-22 11:21:18 +01:00
Koen aaea5cc963 Only close the app on closeSegment if there is no video playing. 2023-11-22 10:38:04 +01:00
Koen 23d9c33406 Added support for v6 Odysee URLs. 2023-11-22 10:27:35 +01:00
Koen fad1b216df Further extended HLS spec that is implemented. 2023-11-22 09:32:52 +01:00
Kelvin e221b508d3 Improved notifications, experimental scheduled notifications 2023-11-21 23:31:26 +01:00
Koen dfafac7d99 Merge branch 'hls-live-stream-proxy' into 'master'
Finished implementation of HLS proxying.

See merge request videostreaming/grayjay!5
2023-11-21 15:12:09 +00:00
Koen 2246f8cee2 Finished implementation of HLS proxying. 2023-11-21 15:12:09 +00:00
Koen 8661ff88c0 Another iteration on the HttpContext fix. 2023-11-20 12:14:03 +01:00
Koen 0bba7fa373 Keep screen on fixes. 2023-11-17 16:44:16 +01:00
Kelvin 0c1822b118 Locked content support 2023-11-17 00:34:21 +01:00
Kelvin 6df8f84421 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-16 17:13:50 +01:00
Kelvin 7fa80ec048 Fix communication issues devportal 2023-11-16 17:13:23 +01:00
Koen b3f9b81984 Do not allow empty polycentric comments. 2023-11-16 15:51:34 +01:00
Kelvin 1393c489c1 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-15 20:02:36 +01:00
Kelvin 640c2cbed0 Chapter accuracy now sub-second 2023-11-15 20:02:28 +01:00
Koen e55509f549 Merge branch 'casting-fixes' into 'master'
Content length is now set correctly for HttpConstantHandler.

See merge request videostreaming/grayjay!4
2023-11-15 16:18:38 +00:00
Koen 27c7fb0c12 Content length is now set correctly for HttpConstantHandler. 2023-11-15 16:18:38 +00:00
Koen 88f3815585 When clicking on a video it is added to queue instead of replacing queue. 2023-11-15 11:49:22 +01:00
Koen 2e9405cfdb Exit full screen swipe is now a down gesture. 2023-11-15 11:40:09 +01:00
Koen 9c1b543ed6 Added 'Add to new playlist' button in options menu. 2023-11-14 14:58:24 +01:00
Koen d34cb0f9c1 Added support for long-press gesture to open options menu. 2023-11-14 14:42:41 +01:00
Koen 116dc90d21 Changed the way the changelog is written. 2023-11-14 14:18:17 +01:00
Kelvin 17b9853bb6 Better subscription behavior reporting 2023-11-14 00:27:13 +01:00
Kelvin 8bfb8abd20 Fix notifications 2023-11-13 23:35:25 +01:00
Kelvin 9ee3f1f26e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-13 22:10:43 +01:00
Kelvin 5dcff29d8d Language on all activities, Fix plugin initial state, Allow rotation prevention bypass, Settings warning support 2023-11-13 22:10:31 +01:00
Koen 6cfbd0c8bf Added + Tax indicator 2023-11-13 15:58:01 +01:00
Koen 01d96cce16 Made export request folder to export to. 2023-11-13 15:49:55 +01:00
Koen 58c376f011 Fixed Rumble subscription import. 2023-11-13 12:06:46 +01:00
Kelvin 439d339330 Fix channel membership reset 2023-11-11 16:47:14 +01:00
Kelvin 44eacc2a47 Stable refs 2023-11-10 20:38:46 +01:00
Kelvin 8135d61398 Fix language issue 2023-11-10 20:34:43 +01:00
Kelvin 66208f8265 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-10 19:49:20 +01:00
Kelvin f52251e23a Hide creators, Fix hide video, 2023-11-10 19:49:09 +01:00
Koen dbea93efe5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-10 12:10:30 +01:00
Koen 3bf0740bd1 Fixed control cast on non-fullscreen. 2023-11-10 12:10:12 +01:00
Kelvin fa7f1b11f3 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-09 19:49:51 +01:00
Kelvin ff914bbdf4 Temporary subscription workarounds 2023-11-09 19:49:45 +01:00
Koen b822078d4b Fixed support tab falsely showing when a creator does not have a polycentric profile. 2023-11-09 19:12:54 +01:00
Kelvin 290d2ceb50 Polycentrif ref 2023-11-09 16:57:32 +01:00
Kelvin 8ec9025990 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-09 16:55:32 +01:00
Kelvin c4cf856dcd Stable submods 2023-11-09 16:55:26 +01:00
Koen 38bb4e25d3 Merge branch 'encryption-changes' into 'master'
Encryption changes, load more on import subscriptions.

See merge request videostreaming/grayjay!3
2023-11-09 15:04:55 +00:00
Koen 0de996d91c Encryption changes, load more on import subscriptions. 2023-11-09 15:04:54 +00:00
Kelvin 1f38c9b27d Logs 2023-11-09 15:46:22 +01:00
Kelvin 234f31b02d Logs and submods 2023-11-09 15:42:33 +01:00
Koen 00e40e8cd6 Merge branch 'encryption-provider-split' into 'master'
Encryption provider split.

See merge request videostreaming/grayjay!2
2023-11-08 15:06:29 +00:00
Koen 0bc6a43dc1 Encryption provider split. 2023-11-08 15:06:29 +00:00
Kelvin e7e0157fbc Stable refs 2023-11-08 16:06:07 +01:00
Kelvin 4cae1a41a5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-08 16:03:01 +01:00
Kelvin 4fa61e7f52 Additional plugin settings capabilities 2023-11-08 16:02:52 +01:00
Koen f02ac796f5 Updated YT stable. 2023-11-08 13:23:49 +01:00
Kelvin 22146a6bdc Playlists ui tweaks 2023-11-08 12:15:53 +01:00
Kelvin 5285eae01d Channel membership support and live chat donation support fix 2023-11-07 20:34:23 +01:00
Koen c47ca369e4 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-07 17:16:19 +01:00
Koen f0b1f62bb1 Casting button no longer visible when disabled (may require restart). 2023-11-07 17:16:06 +01:00
Kelvin f7aa6d006e Add header to login activity with current url 2023-11-07 16:46:55 +01:00
Kelvin 6b67cd549f Fix chapters not getting cleared 2023-11-07 16:24:22 +01:00
Kelvin fc6bf85822 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-07 16:04:26 +01:00
Kelvin fbd9345cf8 Fix fallback to cache results 2023-11-07 16:04:19 +01:00
Koen 63137b4c4d Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-07 16:03:05 +01:00
Koen e28dc7a3a6 Crash fix for bottom menu bar for specific screen dimensions. 2023-11-07 16:02:55 +01:00
Kelvin 6e14acc685 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-07 15:43:21 +01:00
Kelvin ba64153f1d Language setting, Preview setting, Background audio switch setting, No error on comments failed 2023-11-07 15:43:13 +01:00
Koen 72c04e7556 Added casting button in full screen. 2023-11-07 15:08:33 +01:00
Koen 54f37ee5b2 Fixed crash. 2023-11-07 15:01:22 +01:00
Koen 4fbb325313 Added fix for video player not filling height properly for audio only in a playlist. 2023-11-07 14:20:57 +01:00
Koen e1d3b95f73 Fixed crash on Android 9 when playing a video. 2023-11-07 13:35:13 +01:00
Koen 8f7b4b8257 Merge branch 'monetization' into 'master'
Monetization

See merge request videostreaming/grayjay!1
2023-11-07 12:10:40 +00:00
Koen 9d906025ea Monetization 2023-11-07 12:10:40 +00:00
Kelvin d7f4dd65e8 Stable refs 2023-11-06 14:58:11 +01:00
Kelvin 599b119e62 Remove plugin interaction on main thread for channels 2023-11-06 14:53:24 +01:00
Kelvin 41176464db Fix missing swipe to refresh on tab switch 2023-11-06 14:43:24 +01:00
Kelvin dd0ad19fb9 NewLine subs import, fix no-recent video subscriptions 2023-11-06 14:25:09 +01:00
Kelvin 430625d2fb Fix icon colors 2023-11-06 13:37:18 +01:00
Kelvin 796cd1a776 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-06 13:20:42 +01:00
Kelvin baa26af0c0 Only show sub toasts when on subs page, WIP import ui 2023-11-06 13:20:33 +01:00
Koen ea0c27936e Fixed videos not automatically going to next video in playlist when casting. 2023-11-05 15:13:57 +01:00
Kelvin 4aade35d19 Grayjay schema channel support 2023-11-04 18:42:04 +01:00
Kelvin 251a5701af Custom grayjay open video url handling 2023-11-04 18:31:01 +01:00
Kelvin 2da3116111 Fix initial selection of subscription settings 2023-11-03 20:07:08 +01:00
Kelvin 4c82fa1a4a Stable refs 2023-11-03 18:25:40 +01:00
Kelvin 7eef6eece2 Primary claim support, fix sub for clients without type 2023-11-03 18:17:04 +01:00
Kelvin 570f32e980 PlatformUrl support 2023-11-03 15:39:27 +01:00
Kelvin 16a0351125 Per-plugin ratelimit setting 2023-11-03 15:15:18 +01:00
Kelvin 2fa9005806 Keep plugin settings on update 2023-11-03 14:46:43 +01:00
Kelvin 25527997fa Fix channels updating while they shouldnt 2023-11-03 14:37:36 +01:00
Kelvin 4655d8369d Reduce subscription calls, Improve subs sorting, Improve view sorting 2023-11-03 13:34:23 +01:00
Kelvin aeaaace3a4 Subscription settings from creators tab 2023-11-02 23:42:51 +01:00
Kelvin e6997004ff Fix new user crash, show/hide subscription settings button on change, raise import limit to 90 2023-11-02 23:22:42 +01:00
Kelvin 5e1896b7f2 Stable ref 2023-11-02 22:52:29 +01:00
Kelvin 88ca90c13a Notification improvements, Polycentric subscription parallelization, Cache load parallelization 2023-11-02 22:23:24 +01:00
Kelvin f8ee340499 Creator sort options views and watchtime, subscription header ordered by views, view/watchtime tracking for subscriptions, optional view/watchtime metrics in creator tab, cache channel results if subscribed, update subs only if older than 5 min 2023-11-02 20:21:26 +01:00
Kelvin 93f5260e20 Working smart subscriptions, Direct url through search, channel content cache trimming, skippable and skip chapter support, reinstall button for embedded plugins 2023-11-01 20:32:51 +01:00
Kelvin 34ba44ffa4 WIP Subscription notifs 2023-11-01 00:36:01 +01:00
Kelvin b3a3e459a4 WIP Smart subscriptions 2023-11-01 00:09:05 +01:00
Kelvin f234564952 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-10-30 19:22:22 +01:00
Kelvin ffa5795cc9 WIP new subscription system and ui 2023-10-30 19:22:17 +01:00
Koen 4f50c51356 Fixed context crash. 2023-10-30 14:37:49 +01:00
Koen 9e9c8a0bec Fixed Polycentric not backfilling issue 2023-10-30 11:44:56 +01:00
Koen 1349358d7c Added QRCaptureActivity and AudioNoisyReceiver to manifest. 2023-10-30 11:21:51 +01:00
Koen 9c50f15be7 Processed community feedback on German translations. Thank you McIrco95. 2023-10-30 11:08:29 +01:00
Koen 31e771daca Processed community feedback on german translations. Thank you Allstreamer. 2023-10-30 10:59:24 +01:00
Koen 66ce156dea Processed community feedback on translations. Thank you jorpilo. 2023-10-30 10:47:40 +01:00
Koen db6756bc78 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-10-30 09:22:34 +01:00
Koen cab2581476 Added more logging related to backfill and made backfill properly throw. 2023-10-30 09:22:25 +01:00
Kelvin 4c0be35020 Fix ContentTypes docs 2023-10-29 18:28:52 +00:00
Koen 7114201c08 Translations 2023-10-27 20:01:22 +02:00
Kelvin d8aecd325b Basic chapter system working 2023-10-25 20:38:57 +02:00
Koen 1d18c13817 Updated submodule. 2023-10-25 20:05:03 +02:00
Koen f65eb0cd53 Finished moving view strings to strings.xml 2023-10-25 19:54:48 +02:00
Koen 206c3884e9 Finished moving strings to strings.xml for activities and fragments. 2023-10-25 14:53:50 +02:00
Koen 35f9173980 Started moving all strings to strings.xml 2023-10-25 12:16:58 +02:00
Kelvin 48ab77eadc Patreon refs 2023-10-24 23:58:39 +02:00
Kelvin f486513105 Casting HLS fixed 2023-10-24 23:10:15 +02:00
Kelvin f338adf033 Fix polycentric profile content ordering and deduplication 2023-10-24 22:16:10 +02:00
Kelvin 74be667114 Retain login and captcha on embedded update, Play entire feed option 2023-10-24 14:47:34 +02:00
Kelvin b5a1fc92dc Add misisng synchronization, unsub all dev action, Dedup capital insensitive and more scaling max video date difference 2023-10-23 22:38:13 +02:00
Kelvin 9cec1a8c49 Stable ref updates 2023-10-23 21:03:23 +02:00
Kelvin d4afba929b Fix captcha, FAQ, issues page, icons on settings buttons 2023-10-23 20:36:26 +02:00
Koen 70939cbac6 Fixed log submission and added telemetry OS version. 2023-10-23 16:31:50 +02:00
Koen a3aa61df6d Fixed Odysee get channel contents. 2023-10-23 15:24:55 +02:00
Koen e13ab5cb40 Deduplicated map. 2023-10-23 15:23:46 +02:00
Koen d059947925 Odysee now works with more different types of channel URLs. 2023-10-23 14:24:28 +02:00
Koen d6c4b730de Fixes to Polycentric data display. 2023-10-23 14:21:10 +02:00
Koen 8241863170 Fixed comment alpha. 2023-10-21 16:06:05 +02:00
360 changed files with 15574 additions and 2749 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ Thank you for your interest in contributing! This document outlines how you can
### License ### License
The official plugins for this project are licensed under GPLv3. Any contributions you make will also fall under the GPLv3 license. The official plugins for this project are licensed under AGPL. Any contributions you make will also fall under the AGPL license.
### How to Contribute ### How to Contribute
@@ -1,13 +1,14 @@
package com.futo.platformplayer package com.futo.platformplayer
import com.futo.platformplayer.encryption.EncryptionProvider import com.futo.platformplayer.encryption.GEncryptionProviderV0
import com.futo.platformplayer.encryption.GEncryptionProviderV1
import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertEquals
import org.junit.Test import org.junit.Test
class EncryptionProviderTests { class GEncryptionProviderTests {
@Test @Test
fun testEncryptDecrypt() { fun testEncryptDecryptV1() {
val encryptionProvider = EncryptionProvider.instance val encryptionProvider = GEncryptionProviderV1.instance
val plaintext = "This is a test string." val plaintext = "This is a test string."
// Encrypt the plaintext // Encrypt the plaintext
@@ -22,8 +23,8 @@ class EncryptionProviderTests {
@Test @Test
fun testEncryptDecryptBytes() { fun testEncryptDecryptBytesV1() {
val encryptionProvider = EncryptionProvider.instance val encryptionProvider = GEncryptionProviderV1.instance
val bytes = "This is a test string.".toByteArray(); val bytes = "This is a test string.".toByteArray();
// Encrypt the plaintext // Encrypt the plaintext
@@ -36,21 +37,36 @@ class EncryptionProviderTests {
assertArrayEquals(bytes, decrypted); assertArrayEquals(bytes, decrypted);
} }
@Test @Test
fun testEncryptDecryptBytesPassword() { fun testEncryptDecryptV0() {
val encryptionProvider = EncryptionProvider.instance val encryptionProvider = GEncryptionProviderV0.instance
val bytes = "This is a test string.".toByteArray(); val plaintext = "This is a test string."
val password = "1234".padStart(32, '9');
// Encrypt the plaintext // Encrypt the plaintext
val ciphertext = encryptionProvider.encrypt(bytes, password) val ciphertext = encryptionProvider.encrypt(plaintext)
// Decrypt the ciphertext // Decrypt the ciphertext
val decrypted = encryptionProvider.decrypt(ciphertext, password) val decrypted = encryptionProvider.decrypt(ciphertext)
// The decrypted string should be equal to the original plaintext
assertEquals(plaintext, decrypted)
}
@Test
fun testEncryptDecryptBytesV0() {
val encryptionProvider = GEncryptionProviderV0.instance
val bytes = "This is a test string.".toByteArray();
// Encrypt the plaintext
val ciphertext = encryptionProvider.encrypt(bytes)
// Decrypt the ciphertext
val decrypted = encryptionProvider.decrypt(ciphertext)
// The decrypted string should be equal to the original plaintext // The decrypted string should be equal to the original plaintext
assertArrayEquals(bytes, decrypted); assertArrayEquals(bytes, decrypted);
} }
private fun assertArrayEquals(a: ByteArray, b: ByteArray) { private fun assertArrayEquals(a: ByteArray, b: ByteArray) {
@@ -0,0 +1,45 @@
package com.futo.platformplayer
import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV0
import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV1
import junit.framework.TestCase.assertEquals
import org.junit.Test
class GPasswordEncryptionProviderTests {
@Test
fun testEncryptDecryptBytesPasswordV1() {
val encryptionProvider = GPasswordEncryptionProviderV1();
val bytes = "This is a test string.".toByteArray();
// Encrypt the plaintext
val ciphertext = encryptionProvider.encrypt(bytes, "1234")
// Decrypt the ciphertext
val decrypted = encryptionProvider.decrypt(ciphertext, "1234")
// The decrypted string should be equal to the original plaintext
assertArrayEquals(bytes, decrypted);
}
@Test
fun testEncryptDecryptBytesPasswordV0() {
val encryptionProvider = GPasswordEncryptionProviderV0("1234".padStart(32, '9'));
val bytes = "This is a test string.".toByteArray();
// Encrypt the plaintext
val ciphertext = encryptionProvider.encrypt(bytes)
// Decrypt the ciphertext
val decrypted = encryptionProvider.decrypt(ciphertext)
// The decrypted string should be equal to the original plaintext
assertArrayEquals(bytes, decrypted);
}
private fun assertArrayEquals(a: ByteArray, b: ByteArray) {
assertEquals(a.size, b.size);
for(i in 0 until a.size) {
assertEquals(a[i], b[i]);
}
}
}
+25 -2
View File
@@ -9,6 +9,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="com.android.alarm.permission.SET_ALARM"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -38,6 +40,8 @@
android:enabled="true" /> android:enabled="true" />
<receiver android:name=".receivers.MediaControlReceiver" /> <receiver android:name=".receivers.MediaControlReceiver" />
<receiver android:name=".receivers.AudioNoisyReceiver" />
<receiver android:name=".receivers.PlannedNotificationReceiver" />
<activity <activity
android:name=".activities.MainActivity" android:name=".activities.MainActivity"
@@ -91,6 +95,26 @@
<data android:host="*" /> <data android:host="*" />
<data android:scheme="file" /> <data android:scheme="file" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:autoVerify="true">
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<data android:host="*" />
<data android:scheme="content" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:autoVerify="true">
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<data android:host="*" />
<data android:scheme="file" />
<data android:mimeType="application/zip" /> <data android:mimeType="application/zip" />
</intent-filter> </intent-filter>
<intent-filter android:autoVerify="true"> <intent-filter android:autoVerify="true">
@@ -182,9 +206,8 @@
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity <activity
android:name=".activities.AddSourceOptionsActivity$QRCaptureActivity" android:name=".activities.QRCaptureActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" /> android:theme="@style/Theme.FutoVideo.NoActionBar" />
</application> </application>
+5 -2
View File
@@ -540,6 +540,8 @@
<script> <script>
IS_TESTING = true; IS_TESTING = true;
let lastScriptTag = null; let lastScriptTag = null;
let shouldDevLog = true;
let shouldLoginCheck = true;
new Vue({ new Vue({
el: '#app', el: '#app',
data: { data: {
@@ -603,7 +605,7 @@
}; };
setInterval(()=>{ setInterval(()=>{
try{ try{
if(!this.Plugin.currentPlugin) if(!this.Plugin.currentPlugin || !shouldDevLog)
return; return;
getDevLogs(this.Integration.lastLogIndex, (newLogs)=> { getDevLogs(this.Integration.lastLogIndex, (newLogs)=> {
@@ -638,7 +640,8 @@
}, 1000); }, 1000);
setInterval(()=>{ setInterval(()=>{
try{ try{
this.isTestLoggedIn(); if(shouldLoginCheck)
this.isTestLoggedIn();
}catch(ex){} }catch(ex){}
}, 2500); }, 2500);
}, },
+33 -2
View File
@@ -10,7 +10,8 @@ let Type = {
Videos: "VIDEOS", Videos: "VIDEOS",
Streams: "STREAMS", Streams: "STREAMS",
Mixed: "MIXED", Mixed: "MIXED",
Live: "LIVE" Live: "LIVE",
Subscriptions: "SUBSCRIPTIONS"
}, },
Order: { Order: {
Chronological: "CHRONOLOGICAL" Chronological: "CHRONOLOGICAL"
@@ -31,6 +32,12 @@ let Type = {
RAW: 0, RAW: 0,
HTML: 1, HTML: 1,
MARKUP: 2 MARKUP: 2
},
Chapter: {
NORMAL: 0,
SKIPPABLE: 5,
SKIP: 6
} }
}; };
@@ -153,13 +160,27 @@ class FilterCapability {
class PlatformAuthorLink { class PlatformAuthorLink {
constructor(id, name, url, thumbnail, subscribers) { constructor(id, name, url, thumbnail, subscribers, membershipUrl) {
this.id = id ?? PlatformID(); //PlatformID this.id = id ?? PlatformID(); //PlatformID
this.name = name ?? ""; //string this.name = name ?? ""; //string
this.url = url ?? ""; //string this.url = url ?? ""; //string
this.thumbnail = thumbnail; //string this.thumbnail = thumbnail; //string
if(subscribers) if(subscribers)
this.subscribers = subscribers; this.subscribers = subscribers;
if(membershipUrl)
this.membershipUrl = membershipUrl ?? null; //string (for backcompat)
}
}
class PlatformAuthorMembershipLink {
constructor(id, name, url, thumbnail, subscribers, membershipUrl) {
this.id = id ?? PlatformID(); //PlatformID
this.name = name ?? ""; //string
this.url = url ?? ""; //string
this.thumbnail = thumbnail; //string
if(subscribers)
this.subscribers = subscribers;
if(membershipUrl)
this.membershipUrl = membershipUrl ?? null; //string
} }
} }
class PlatformContent { class PlatformContent {
@@ -190,6 +211,16 @@ class PlatformNestedMediaContent extends PlatformContent {
this.contentThumbnails = obj.contentThumbnails ?? new Thumbnails(); this.contentThumbnails = obj.contentThumbnails ?? new Thumbnails();
} }
} }
class PlatformLockedContent extends PlatformContent {
constructor(obj) {
super(obj, 70);
obj = obj ?? {};
this.contentName = obj.contentName;
this.contentThumbnails = obj.contentThumbnails ?? new Thumbnails();
this.unlockUrl = obj.unlockUrl ?? "";
this.lockDescription = obj.lockDescription;
}
}
class PlatformVideo extends PlatformContent { class PlatformVideo extends PlatformContent {
constructor(obj) { constructor(obj) {
super(obj, 1); super(obj, 1);
@@ -185,6 +185,25 @@ fun OffsetDateTime.toHumanNowDiffString(abs: Boolean = false) : String {
return "${value} ${unit}"; return "${value} ${unit}";
}; };
fun Int.toHumanTimeIndicator(abs: Boolean = false) : String {
var value = this;
var unit = "s";
if(abs) value = abs(value);
if(value >= secondsInHour) {
value = (this / secondsInHour).toInt();
if(abs) value = abs(value);
unit = "hr" + (if(value > 1) "s" else "");
}
else if(value >= secondsInMinute) {
value = (this / secondsInMinute).toInt();
if(abs) value = abs(value);
unit = "min";
}
return "${value}${unit}";
}
fun Long.toHumanTime(isMs: Boolean): String { fun Long.toHumanTime(isMs: Boolean): String {
var scaler = 1; var scaler = 1;
@@ -1,11 +1,15 @@
package com.futo.platformplayer package com.futo.platformplayer
import com.google.common.base.CharMatcher import com.google.common.base.CharMatcher
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.net.Inet4Address import java.net.Inet4Address
import java.net.InetAddress import java.net.InetAddress
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket
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;
@@ -273,3 +277,46 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
return connectedSocket; return connectedSocket;
} }
fun InputStream.readHttpHeaderBytes() : ByteArray {
val headerBytes = ByteArrayOutputStream()
var crlfCount = 0
while (crlfCount < 4) {
val b = read()
if (b == -1) {
throw IOException("Unexpected end of stream while reading headers")
}
if (b == 0x0D || b == 0x0A) { // CR or LF
crlfCount++
} else {
crlfCount = 0
}
headerBytes.write(b)
}
return headerBytes.toByteArray()
}
fun InputStream.readLine() : String? {
val line = ByteArrayOutputStream()
var crlfCount = 0
while (crlfCount < 2) {
val b = read()
if (b == -1) {
return null
}
if (b == 0x0D || b == 0x0A) { // CR or LF
crlfCount++
} else {
crlfCount = 0
line.write(b)
}
}
return String(line.toByteArray(), Charsets.UTF_8)
}
@@ -1,6 +1,11 @@
package com.futo.platformplayer package com.futo.platformplayer
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.AnnouncementType
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 userpackage.Protocol import userpackage.Protocol
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.min import kotlin.math.min
@@ -35,4 +40,25 @@ fun Protocol.ImageBundle?.selectHighestResolutionImage(): Protocol.ImageManifest
fun Protocol.Claim.resolveChannelUrl(): String? { fun Protocol.Claim.resolveChannelUrl(): String? {
return StatePlatform.instance.resolveChannelUrlByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) }) return StatePlatform.instance.resolveChannelUrlByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
}
fun Protocol.Claim.resolveChannelUrls(): List<String> {
return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
}
suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() {
val exceptions = fullyBackfillServers()
for (pair in exceptions) {
val server = pair.key
val exception = pair.value
StateAnnouncement.instance.registerAnnouncement(
"backfill-failed",
"Backfill failed",
"Failed to backfill server $server. $exception",
AnnouncementType.SESSION_RECURRING
);
Logger.e("Backfill", "Failed to backfill server $server.", exception)
}
} }
@@ -1,5 +1,10 @@
package com.futo.platformplayer package com.futo.platformplayer
import android.net.Uri
import java.net.URI
import java.net.URISyntaxException
import java.net.URLEncoder
//Syntax sugaring //Syntax sugaring
inline fun <reified T> Any.assume(): T?{ inline fun <reified T> Any.assume(): T?{
if(this is T) if(this is T)
@@ -12,4 +17,29 @@ inline fun <reified T, R> Any.assume(cb: (T) -> R): R? {
if(result != null) if(result != null)
return cb(result); return cb(result);
return null; return null;
}
fun String?.yesNoToBoolean(): Boolean {
return this?.uppercase() == "YES"
}
fun String?.toURIRobust(): URI? {
if (this == null) {
return null
}
try {
return URI(this)
} catch (e: URISyntaxException) {
val parts = this.split("\\?".toRegex(), 2)
if (parts.size < 2) {
return null
}
val beforeQuery = parts[0]
val query = parts[1]
val encodedQuery = URLEncoder.encode(query, "UTF-8")
val rebuiltUrl = "$beforeQuery?$encodedQuery"
return URI(rebuiltUrl)
}
} }
@@ -109,11 +109,29 @@ inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextN
else else
return this.expectOrThrow<V8ValueLong>(config, contextName).value.toLong() as T return this.expectOrThrow<V8ValueLong>(config, contextName).value.toLong() as T
}; };
Float::class -> {
if(this is V8ValueDouble)
return this.value.toFloat() as T;
else if(this is V8ValueInteger)
return this.value.toFloat() as T;
else if(this is V8ValueLong)
return this.value.toFloat() as T;
else
return this.expectOrThrow<V8ValueDouble>(config, contextName).value.toDouble() as T
};
Double::class -> {
if(this is V8ValueDouble)
return this.value.toDouble() as T;
else if(this is V8ValueInteger)
return this.value.toDouble() as T;
else if(this is V8ValueLong)
return this.value.toDouble() as T;
else
return this.expectOrThrow<V8ValueDouble>(config, contextName).value.toDouble() as T
};
V8ValueObject::class -> this.expectOrThrow<V8ValueObject>(config, contextName) as T V8ValueObject::class -> this.expectOrThrow<V8ValueObject>(config, contextName) as T
V8ValueArray::class -> this.expectOrThrow<V8ValueArray>(config, contextName) as T; V8ValueArray::class -> this.expectOrThrow<V8ValueArray>(config, contextName) as T;
Boolean::class -> this.expectOrThrow<V8ValueBoolean>(config, contextName).value as T; Boolean::class -> this.expectOrThrow<V8ValueBoolean>(config, contextName).value as T;
Float::class -> this.expectOrThrow<V8ValueDouble>(config, contextName).value.toFloat() as T;
Double::class -> this.expectOrThrow<V8ValueDouble>(config, contextName).value as T;
HashMap::class -> this.expectOrThrow<V8ValueObject>(config, contextName).let { V8ObjectToHashMap(it) } as T; HashMap::class -> this.expectOrThrow<V8ValueObject>(config, contextName).let { V8ObjectToHashMap(it) } as T;
Map::class -> this.expectOrThrow<V8ValueObject>(config, contextName).let { V8ObjectToHashMap(it) } as T; Map::class -> this.expectOrThrow<V8ValueObject>(config, contextName).let { V8ObjectToHashMap(it) } as T;
List::class -> this.expectOrThrow<V8ValueArray>(config, contextName).let { V8ArrayToStringList(it) } as T; List::class -> this.expectOrThrow<V8ValueArray>(config, contextName).let { V8ArrayToStringList(it) } as T;
@@ -0,0 +1,20 @@
package com.futo.platformplayer
import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
class HorizontalSpaceItemDecoration(private val startSpace: Int, private val betweenSpace: Int, private val endSpace: Int) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
outRect.left = betweenSpace
val position = parent.getChildAdapterPosition(view)
if (position == 0) {
outRect.left = startSpace
}
else if (position == state.itemCount - 1) {
outRect.right = endSpace
}
}
}
@@ -4,12 +4,11 @@ import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.webkit.CookieManager import android.webkit.CookieManager
import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.* import com.futo.platformplayer.activities.*
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -22,6 +21,8 @@ import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.fields.DropdownFieldOptionsId import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
import com.futo.platformplayer.views.fields.FormField import com.futo.platformplayer.views.fields.FormField
import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormFieldButton
import com.futo.platformplayer.views.fields.FormFieldWarning
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -29,6 +30,7 @@ import kotlinx.serialization.*
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import java.io.File import java.io.File
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.Locale
@Serializable @Serializable
data class MenuBottomBarSetting(val id: Int, var enabled: Boolean); data class MenuBottomBarSetting(val id: Int, var enabled: Boolean);
@@ -43,10 +45,8 @@ class Settings : FragmentedStorageFileJson() {
@Transient @Transient
val onTabsChanged = Event0(); val onTabsChanged = Event0();
@FormField( @FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -5)
"Manage Polycentric identity", FieldForm.BUTTON, @FormFieldButton(R.drawable.ic_person)
"Manage your Polycentric identity", -3
)
fun managePolycentricIdentity() { fun managePolycentricIdentity() {
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
if (StatePolycentric.instance.processHandle != null) { if (StatePolycentric.instance.processHandle != null) {
@@ -57,23 +57,33 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
@FormField( @FormField(R.string.show_faq, FieldForm.BUTTON, R.string.get_answers_to_common_questions, -4)
"Open FAQ", FieldForm.BUTTON, @FormFieldButton(R.drawable.ic_quiz)
"Get answers to common questions", -2
)
fun openFAQ() { fun openFAQ() {
try { try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://grayjay.app/faq.html")) val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(Settings.URL_FAQ))
SettingsActivity.getActivity()?.startActivity(browserIntent);
} catch (e: Throwable) {
//Ignored
}
}
@FormField(R.string.show_issues, FieldForm.BUTTON, R.string.a_list_of_user_reported_and_self_reported_issues, -3)
@FormFieldButton(R.drawable.ic_data_alert)
fun openIssues() {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/futo-org/grayjay-android/issues"))
SettingsActivity.getActivity()?.startActivity(browserIntent); SettingsActivity.getActivity()?.startActivity(browserIntent);
} catch (e: Throwable) { } catch (e: Throwable) {
//Ignored //Ignored
} }
} }
/*
@FormField( @FormField(
"Submit feedback", FieldForm.BUTTON, R.string.submit_feedback, FieldForm.BUTTON,
"Give feedback on the application", -1 R.string.give_feedback_on_the_application, -1
) )
@FormFieldButton(R.drawable.ic_bug)
fun submitFeedback() { fun submitFeedback() {
try { try {
val i = Intent(Intent.ACTION_VIEW); val i = Intent(Intent.ACTION_VIEW);
@@ -87,12 +97,10 @@ class Settings : FragmentedStorageFileJson() {
} catch (e: Throwable) { } catch (e: Throwable) {
//Ignored //Ignored
} }
} }*/
@FormField( @FormField(R.string.manage_tabs, FieldForm.BUTTON, R.string.change_tabs_visible_on_the_home_screen, -2)
"Manage Tabs", FieldForm.BUTTON, @FormFieldButton(R.drawable.ic_tabs)
"Change tabs visible on the home screen", -1
)
fun manageTabs() { fun manageTabs() {
try { try {
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
@@ -103,11 +111,39 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
@FormField("Home", "group", "Configure how your Home tab works and feels", 1)
@FormField(R.string.language, "group", -1, 0)
var language = LanguageSettings();
@Serializable
class LanguageSettings {
@FormField(R.string.app_language, FieldForm.DROPDOWN, R.string.may_require_restart, 5, "app_language")
@DropdownFieldOptionsId(R.array.app_languages)
var appLanguage: Int = 0;
fun getAppLanguageLocaleString(): String? {
return when(appLanguage) {
0 -> null
1 -> "en";
2 -> "de";
3 -> "es";
4 -> "pt";
5 -> "fr"
6 -> "ja";
7 -> "ko";
8 -> "zh";
9 -> "ru";
10 -> "ar";
else -> null
}
}
}
@FormField(R.string.home, "group", R.string.configure_how_your_home_tab_works_and_feels, 1)
var home = HomeSettings(); var home = HomeSettings();
@Serializable @Serializable
class HomeSettings { class HomeSettings {
@FormField("Feed Style", FieldForm.DROPDOWN, "", 5) @FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 5)
@DropdownFieldOptionsId(R.array.feed_style) @DropdownFieldOptionsId(R.array.feed_style)
var homeFeedStyle: Int = 1; var homeFeedStyle: Int = 1;
@@ -117,21 +153,45 @@ class Settings : FragmentedStorageFileJson() {
else else
return FeedStyle.THUMBNAIL; return FeedStyle.THUMBNAIL;
} }
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
var previewFeedItems: Boolean = true;
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = false;
@FormField(R.string.clear_hidden, FieldForm.BUTTON, R.string.clear_hidden_description, 8)
@FormFieldButton(R.drawable.ic_visibility_off)
fun clearHidden() {
StateMeta.instance.removeAllHiddenCreators();
StateMeta.instance.removeAllHiddenVideos();
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it, "Creators and videos should show up again");
}
}
} }
@FormField("Search", "group", "", 2) @FormField(R.string.search, "group", -1, 2)
var search = SearchSettings(); var search = SearchSettings();
@Serializable @Serializable
class SearchSettings { class SearchSettings {
@FormField("Search History", FieldForm.TOGGLE, "", 4) @FormField(R.string.search_history, FieldForm.TOGGLE, R.string.may_require_restart, 3)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var searchHistory: Boolean = true; var searchHistory: Boolean = true;
@FormField("Feed Style", FieldForm.DROPDOWN, "", 5) @FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 4)
@DropdownFieldOptionsId(R.array.feed_style) @DropdownFieldOptionsId(R.array.feed_style)
var searchFeedStyle: Int = 1; var searchFeedStyle: Int = 1;
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
var previewFeedItems: Boolean = true;
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = false;
fun getSearchFeedStyle(): FeedStyle { fun getSearchFeedStyle(): FeedStyle {
if(searchFeedStyle == 0) if(searchFeedStyle == 0)
@@ -141,11 +201,21 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
@FormField("Subscriptions", "group", "Configure how your Subscriptions works and feels", 3)
@FormField(R.string.channel, "group", -1, 3)
var channel = ChannelSettings();
@Serializable
class ChannelSettings {
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = false;
}
@FormField(R.string.subscriptions, "group", R.string.configure_how_your_subscriptions_works_and_feels, 4)
var subscriptions = SubscriptionsSettings(); var subscriptions = SubscriptionsSettings();
@Serializable @Serializable
class SubscriptionsSettings { class SubscriptionsSettings {
@FormField("Feed Style", FieldForm.DROPDOWN, "", 5) @FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 4)
@DropdownFieldOptionsId(R.array.feed_style) @DropdownFieldOptionsId(R.array.feed_style)
var subscriptionsFeedStyle: Int = 1; var subscriptionsFeedStyle: Int = 1;
@@ -156,11 +226,20 @@ class Settings : FragmentedStorageFileJson() {
return FeedStyle.THUMBNAIL; return FeedStyle.THUMBNAIL;
} }
@FormField("Fetch on app boot", FieldForm.TOGGLE, "Shortly after opening the app, start fetching subscriptions.", 6) @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
var previewFeedItems: Boolean = true;
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = false;
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 7)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var fetchOnAppBoot: Boolean = true; var fetchOnAppBoot: Boolean = true;
@FormField("Background Update", FieldForm.DROPDOWN, "Experimental background update for subscriptions cache (requires restart)", 7) @FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 8)
var fetchOnTabOpen: Boolean = true;
@FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 9)
@DropdownFieldOptionsId(R.array.background_interval) @DropdownFieldOptionsId(R.array.background_interval)
var subscriptionsBackgroundUpdateInterval: Int = 0; var subscriptionsBackgroundUpdateInterval: Int = 0;
@@ -176,26 +255,43 @@ class Settings : FragmentedStorageFileJson() {
}; };
@FormField("Subscription Concurrency", FieldForm.DROPDOWN, "Specify how many threads are used to fetch channels (requires restart)", 8) @FormField(R.string.subscription_concurrency, FieldForm.DROPDOWN, R.string.specify_how_many_threads_are_used_to_fetch_channels, 10)
@DropdownFieldOptionsId(R.array.thread_count) @DropdownFieldOptionsId(R.array.thread_count)
var subscriptionConcurrency: Int = 3; var subscriptionConcurrency: Int = 3;
fun getSubscriptionsConcurrency() : Int { fun getSubscriptionsConcurrency() : Int {
return threadIndexToCount(subscriptionConcurrency); return threadIndexToCount(subscriptionConcurrency);
} }
@FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 11)
var showWatchMetrics: Boolean = false;
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 12)
var allowPlaytimeTracking: Boolean = true;
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 13)
var alwaysReloadFromCache: Boolean = false;
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 14)
fun clearChannelCache() {
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
ChannelContentCache.instance.clear();
UIDialogs.toast(SettingsActivity.getActivity()!!, "Finished clearing");
}
} }
@FormField("Player", "group", "Change behavior of the player", 4) @FormField(R.string.player, "group", R.string.change_behavior_of_the_player, 5)
var playback = PlaybackSettings(); var playback = PlaybackSettings();
@Serializable @Serializable
class PlaybackSettings { class PlaybackSettings {
@FormField("Primary Language", FieldForm.DROPDOWN, "", 0) @FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, 0)
@DropdownFieldOptionsId(R.array.languages) @DropdownFieldOptionsId(R.array.audio_languages)
var primaryLanguage: Int = 0; var primaryLanguage: Int = 0;
fun getPrimaryLanguage(context: Context) = context.resources.getStringArray(R.array.languages)[primaryLanguage]; fun getPrimaryLanguage(context: Context) = context.resources.getStringArray(R.array.audio_languages)[primaryLanguage];
@FormField("Default Playback Speed", FieldForm.DROPDOWN, "", 1) @FormField(R.string.default_playback_speed, FieldForm.DROPDOWN, -1, 1)
@DropdownFieldOptionsId(R.array.playback_speeds) @DropdownFieldOptionsId(R.array.playback_speeds)
var defaultPlaybackSpeed: Int = 3; var defaultPlaybackSpeed: Int = 3;
fun getDefaultPlaybackSpeed(): Float = when(defaultPlaybackSpeed) { fun getDefaultPlaybackSpeed(): Float = when(defaultPlaybackSpeed) {
@@ -211,29 +307,29 @@ class Settings : FragmentedStorageFileJson() {
else -> 1.0f; else -> 1.0f;
}; };
@FormField("Preferred Quality", FieldForm.DROPDOWN, "", 2) @FormField(R.string.preferred_quality, FieldForm.DROPDOWN, -1, 2)
@DropdownFieldOptionsId(R.array.preferred_quality_array) @DropdownFieldOptionsId(R.array.preferred_quality_array)
var preferredQuality: Int = 0; var preferredQuality: Int = 0;
@FormField("Preferred Metered Quality", FieldForm.DROPDOWN, "", 2) @FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, -1, 2)
@DropdownFieldOptionsId(R.array.preferred_quality_array) @DropdownFieldOptionsId(R.array.preferred_quality_array)
var preferredMeteredQuality: Int = 0; var preferredMeteredQuality: Int = 0;
fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality); fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality);
fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality); fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality);
fun getCurrentPreferredQualityPixelCount(): Int = if(!StateApp.instance.isCurrentMetered()) getPreferredQualityPixelCount() else getPreferredMeteredQualityPixelCount(); fun getCurrentPreferredQualityPixelCount(): Int = if(!StateApp.instance.isCurrentMetered()) getPreferredQualityPixelCount() else getPreferredMeteredQualityPixelCount();
@FormField("Preferred Preview Quality", FieldForm.DROPDOWN, "", 3) @FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, -1, 3)
@DropdownFieldOptionsId(R.array.preferred_quality_array) @DropdownFieldOptionsId(R.array.preferred_quality_array)
var preferredPreviewQuality: Int = 5; var preferredPreviewQuality: Int = 5;
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality); fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
@FormField("Auto-Rotate", FieldForm.DROPDOWN, "", 4) @FormField(R.string.auto_rotate, FieldForm.DROPDOWN, -1, 4)
@DropdownFieldOptionsId(R.array.system_enabled_disabled_array) @DropdownFieldOptionsId(R.array.system_enabled_disabled_array)
var autoRotate: Int = 2; var autoRotate: Int = 2;
fun isAutoRotate() = autoRotate == 1 || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate()); fun isAutoRotate() = autoRotate == 1 || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate());
@FormField("Auto-Rotate Dead Zone", FieldForm.DROPDOWN, "This prevents the device from rotating within the given amount of degrees.", 5) @FormField(R.string.auto_rotate_dead_zone, FieldForm.DROPDOWN, R.string.this_prevents_the_device_from_rotating_within_the_given_amount_of_degrees, 5)
@DropdownFieldOptionsId(R.array.auto_rotate_dead_zone) @DropdownFieldOptionsId(R.array.auto_rotate_dead_zone)
var autoRotateDeadZone: Int = 0; var autoRotateDeadZone: Int = 0;
@@ -241,21 +337,17 @@ class Settings : FragmentedStorageFileJson() {
return autoRotateDeadZone * 5; return autoRotateDeadZone * 5;
} }
@FormField("Background Behavior", FieldForm.DROPDOWN, "", 6) @FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 6)
@DropdownFieldOptionsId(R.array.player_background_behavior) @DropdownFieldOptionsId(R.array.player_background_behavior)
var backgroundPlay: Int = 2; var backgroundPlay: Int = 2;
fun isBackgroundContinue() = backgroundPlay == 1; fun isBackgroundContinue() = backgroundPlay == 1;
fun isBackgroundPictureInPicture() = backgroundPlay == 2; fun isBackgroundPictureInPicture() = backgroundPlay == 2;
@FormField("Resume After Preview", FieldForm.DROPDOWN, "When watching a video in preview mode, resume at the position when opening the video", 7) @FormField(R.string.resume_after_preview, FieldForm.DROPDOWN, R.string.when_watching_a_video_in_preview_mode_resume_at_the_position_when_opening_the_video_code, 7)
@DropdownFieldOptionsId(R.array.resume_after_preview) @DropdownFieldOptionsId(R.array.resume_after_preview)
var resumeAfterPreview: Int = 1; var resumeAfterPreview: Int = 1;
@FormField("Live Chat Webview", FieldForm.TOGGLE, "Use the live chat web window when available over native implementation.", 8)
var useLiveChatWindow: Boolean = true;
fun shouldResumePreview(previewedPosition: Long): Boolean{ fun shouldResumePreview(previewedPosition: Long): Boolean{
if(resumeAfterPreview == 2) if(resumeAfterPreview == 2)
return true; return true;
@@ -263,14 +355,45 @@ class Settings : FragmentedStorageFileJson() {
return true; return true;
return false; return false;
} }
@FormField(R.string.chapter_update_fps_title, FieldForm.DROPDOWN, R.string.chapter_update_fps_description, 8)
@DropdownFieldOptionsId(R.array.chapter_fps)
var chapterUpdateFPS: Int = 0;
fun getChapterUpdateFrames(): Int {
return when(chapterUpdateFPS) {
0 -> 24
1 -> 30
2 -> 60
3 -> 120
else -> 1
};
}
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 9)
var useLiveChatWindow: Boolean = true;
@FormField(R.string.background_switch_audio, FieldForm.TOGGLE, R.string.background_switch_audio_description, 10)
var backgroundSwitchToAudio: Boolean = true;
} }
@FormField("Downloads", "group", "Configure downloading of videos", 5) @FormField(R.string.comments, "group", R.string.comments_description, 6)
var comments = CommentSettings();
@Serializable
class CommentSettings {
@FormField(R.string.default_comment_section, FieldForm.DROPDOWN, -1, 0)
@DropdownFieldOptionsId(R.array.comment_sections)
var defaultCommentSection: Int = 0;
}
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 7)
var downloads = Downloads(); var downloads = Downloads();
@Serializable @Serializable
class Downloads { class Downloads {
@FormField("Download when", FieldForm.DROPDOWN, "Configure when videos should be downloaded", 0) @FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_videos_should_be_downloaded, 0)
@DropdownFieldOptionsId(R.array.when_download) @DropdownFieldOptionsId(R.array.when_download)
var whenDownload: Int = 0; var whenDownload: Int = 0;
@@ -283,21 +406,21 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
@FormField("Default Video Quality", FieldForm.DROPDOWN, "", 2) @FormField(R.string.default_video_quality, FieldForm.DROPDOWN, -1, 2)
@DropdownFieldOptionsId(R.array.preferred_video_download) @DropdownFieldOptionsId(R.array.preferred_video_download)
var preferredVideoQuality: Int = 4; var preferredVideoQuality: Int = 4;
fun getDefaultVideoQualityPixels(): Int = preferedQualityToPixels(preferredVideoQuality); fun getDefaultVideoQualityPixels(): Int = preferedQualityToPixels(preferredVideoQuality);
@FormField("Default Audio Quality", FieldForm.DROPDOWN, "", 3) @FormField(R.string.default_audio_quality, FieldForm.DROPDOWN, -1, 3)
@DropdownFieldOptionsId(R.array.preferred_audio_download) @DropdownFieldOptionsId(R.array.preferred_audio_download)
var preferredAudioQuality: Int = 1; var preferredAudioQuality: Int = 1;
fun isHighBitrateDefault(): Boolean = preferredAudioQuality > 0; fun isHighBitrateDefault(): Boolean = preferredAudioQuality > 0;
@FormField("ByteRange Download", FieldForm.TOGGLE, "Attempt to utilize byte ranges, this can be combined with concurrency to bypass throttling", 4) @FormField(R.string.byte_range_download, FieldForm.TOGGLE, R.string.attempt_to_utilize_byte_ranges, 4)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var byteRangeDownload: Boolean = true; var byteRangeDownload: Boolean = true;
@FormField("ByteRange Concurrency", FieldForm.DROPDOWN, "Number of concurrent threads to multiply download speeds from throttled sources", 5) @FormField(R.string.byte_range_concurrency, FieldForm.DROPDOWN, R.string.number_of_concurrent_threads_to_multiply_download_speeds_from_throttled_sources, 5)
@DropdownFieldOptionsId(R.array.thread_count) @DropdownFieldOptionsId(R.array.thread_count)
var byteRangeConcurrency: Int = 3; var byteRangeConcurrency: Int = 3;
fun getByteRangeThreadCount(): Int { fun getByteRangeThreadCount(): Int {
@@ -305,23 +428,26 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
@FormField("Browsing", "group", "Configure browsing behavior", 6) @FormField(R.string.browsing, "group", R.string.configure_browsing_behavior, 8)
var browsing = Browsing(); var browsing = Browsing();
@Serializable @Serializable
class Browsing { class Browsing {
@FormField("Enable Video Cache", FieldForm.TOGGLE, "A cache to quickly load previously fetched videos", 0) @FormField(R.string.enable_video_cache, FieldForm.TOGGLE, R.string.cache_to_quickly_load_previously_fetched_videos, 0)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var videoCache: Boolean = true; var videoCache: Boolean = true;
} }
@FormField("Casting", "group", "Configure casting", 7) @FormField(R.string.casting, "group", R.string.configure_casting, 9)
var casting = Casting(); var casting = Casting();
@Serializable @Serializable
class Casting { class Casting {
@FormField("Enabled", FieldForm.TOGGLE, "Enable casting", 0) @FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enable_casting, 0)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var enabled: Boolean = true; var enabled: Boolean = true;
@FormField(R.string.keep_screen_on, FieldForm.TOGGLE, R.string.keep_screen_on_while_casting, 1)
@Serializable(with = FlexibleBooleanSerializer::class)
var keepScreenOn: Boolean = true;
/*TODO: Should we have a different casting quality? /*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@@ -339,25 +465,21 @@ class Settings : FragmentedStorageFileJson() {
}*/ }*/
} }
@FormField(R.string.logging, FieldForm.GROUP, -1, 10)
@FormField("Logging", FieldForm.GROUP, "", 8)
var logging = Logging(); var logging = Logging();
@Serializable @Serializable
class Logging { class Logging {
@FormField("Log Level", FieldForm.DROPDOWN, "", 0) @FormField(R.string.log_level, FieldForm.DROPDOWN, -1, 0)
@DropdownFieldOptionsId(R.array.log_levels) @DropdownFieldOptionsId(R.array.log_levels)
var logLevel: Int = 0; var logLevel: Int = 0;
@FormField( @FormField(R.string.submit_logs, FieldForm.BUTTON, R.string.submit_logs_to_help_us_narrow_down_issues, 1)
"Submit logs", FieldForm.BUTTON,
"Submit logs to help us narrow down issues", 1
)
fun submitLogs() { fun submitLogs() {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
if (!Logger.submitLogs()) { if (!Logger.submitLogs()) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, "Please enable logging to submit logs") } SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.please_enable_logging_to_submit_logs)) }
} }
} }
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -367,43 +489,40 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
@FormField(R.string.announcement, FieldForm.GROUP, -1, 11)
@FormField("Announcement", FieldForm.GROUP, "", 10)
var announcementSettings = AnnouncementSettings(); var announcementSettings = AnnouncementSettings();
@Serializable @Serializable
class AnnouncementSettings { class AnnouncementSettings {
@FormField( @FormField(R.string.reset_announcements, FieldForm.BUTTON, R.string.reset_hidden_announcements, 1)
"Reset announcements", FieldForm.BUTTON,
"Reset hidden announcements", 1
)
fun resetAnnouncements() { fun resetAnnouncements() {
StateAnnouncement.instance.resetAnnouncements(); StateAnnouncement.instance.resetAnnouncements();
UIDialogs.toast("Announcements reset."); SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); };
} }
} }
@FormField("Plugins", FieldForm.GROUP, "", 11) @FormField(R.string.notifications, FieldForm.GROUP, -1, 12)
var notifications = NotificationSettings();
@Serializable
class NotificationSettings {
@FormField(R.string.planned_content_notifications, FieldForm.TOGGLE, R.string.planned_content_notifications_description, 1)
var plannedContentNotification: Boolean = true;
}
@FormField(R.string.plugins, FieldForm.GROUP, -1, 13)
@Transient @Transient
var plugins = Plugins(); var plugins = Plugins();
@Serializable @Serializable
class Plugins { class Plugins {
@FormField("Clear Cookies on Logout", FieldForm.TOGGLE, "Clears cookies when you log out, allowing you to change account.", 0) @FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
var clearCookiesOnLogout: Boolean = true; var clearCookiesOnLogout: Boolean = true;
@FormField( @FormField(R.string.clear_cookies, FieldForm.BUTTON, R.string.clears_in_app_browser_cookies, 1)
"Clear Cookies", FieldForm.BUTTON,
"Clears in-app browser cookies, especially useful for fully logging out of plugins.", 1
)
fun clearCookies() { fun clearCookies() {
val cookieManager: CookieManager = CookieManager.getInstance(); val cookieManager: CookieManager = CookieManager.getInstance();
cookieManager.removeAllCookies(null); cookieManager.removeAllCookies(null);
} }
@FormField( @FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
"Reinstall Embedded Plugins", FieldForm.BUTTON,
"Also removes any data related plugin like login or settings (may not clear browser cache)", 1
)
fun reinstallEmbedded() { fun reinstallEmbedded() {
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
try { try {
@@ -411,7 +530,7 @@ class Settings : FragmentedStorageFileJson() {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
StateApp.instance.contextOrNull?.let { StateApp.instance.contextOrNull?.let {
UIDialogs.toast(it, "Embedded plugins reinstalled, a reboot is recommended"); UIDialogs.toast(it, it.getString(R.string.embedded_plugins_reinstalled_a_reboot_is_recommended));
}; };
} }
} catch (ex: Exception) { } catch (ex: Exception) {
@@ -426,7 +545,7 @@ class Settings : FragmentedStorageFileJson() {
} }
@FormField("External Storage", FieldForm.GROUP, "", 12) @FormField(R.string.external_storage, FieldForm.GROUP, -1, 14)
var storage = Storage(); var storage = Storage();
@Serializable @Serializable
class Storage { class Storage {
@@ -438,34 +557,41 @@ class Settings : FragmentedStorageFileJson() {
fun isStorageMainValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageGeneralUri()); fun isStorageMainValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageGeneralUri());
fun isStorageDownloadValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageDownloadUri()); fun isStorageDownloadValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageDownloadUri());
@FormField("Change external General directory", FieldForm.BUTTON, "Change the external directory for general files, used for persistent files like auto-backup", 3) @FormField(R.string.change_external_general_directory, FieldForm.BUTTON, R.string.change_the_external_directory_for_general_files, 3)
fun changeStorageGeneral() { fun changeStorageGeneral() {
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
StateApp.instance.changeExternalGeneralDirectory(it); StateApp.instance.changeExternalGeneralDirectory(it);
} }
} }
@FormField("Change external Downloads directory", FieldForm.BUTTON, "Change the external storage for download files, used for exported download files", 4) @FormField(R.string.change_external_downloads_directory, FieldForm.BUTTON, R.string.change_the_external_storage_for_download_files, 4)
fun changeStorageDownload() { fun changeStorageDownload() {
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
StateApp.instance.changeExternalDownloadDirectory(it); StateApp.instance.changeExternalDownloadDirectory(it);
} }
} }
@FormField(R.string.clear_external_downloads_directory, FieldForm.BUTTON, R.string.clear_the_external_storage_for_download_files, 5)
fun clearStorageDownload() {
Settings.instance.storage.storage_download = null;
Settings.instance.save();
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, "Cleared download storage directory") };
}
} }
@FormField("Auto Update", "group", "Configure the auto updater", 12) @FormField(R.string.auto_update, "group", R.string.configure_the_auto_updater, 15)
var autoUpdate = AutoUpdate(); var autoUpdate = AutoUpdate();
@Serializable @Serializable
class AutoUpdate { class AutoUpdate {
@FormField("Check", FieldForm.DROPDOWN, "", 0) @FormField(R.string.check, FieldForm.DROPDOWN, -1, 0)
@DropdownFieldOptionsId(R.array.auto_update_when_array) @DropdownFieldOptionsId(R.array.auto_update_when_array)
var check: Int = 0; var check: Int = 0;
@FormField("Background download", FieldForm.DROPDOWN, "Configure if background download should be used", 1) @FormField(R.string.background_download, FieldForm.DROPDOWN, R.string.configure_if_background_download_should_be_used, 1)
@DropdownFieldOptionsId(R.array.background_download) @DropdownFieldOptionsId(R.array.background_download)
var backgroundDownload: Int = 0; var backgroundDownload: Int = 0;
@FormField("Download when", FieldForm.DROPDOWN, "Configure when updates should be downloaded", 2) @FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
@DropdownFieldOptionsId(R.array.when_download) @DropdownFieldOptionsId(R.array.when_download)
var whenDownload: Int = 0; var whenDownload: Int = 0;
@@ -482,10 +608,7 @@ class Settings : FragmentedStorageFileJson() {
return check == 0 && !BuildConfig.IS_PLAYSTORE_BUILD; return check == 0 && !BuildConfig.IS_PLAYSTORE_BUILD;
} }
@FormField( @FormField(R.string.manual_check, FieldForm.BUTTON, R.string.manually_check_for_updates, 3)
"Manual check", FieldForm.BUTTON,
"Manually check for updates", 3
)
fun manualCheck() { fun manualCheck() {
if (!BuildConfig.IS_PLAYSTORE_BUILD) { if (!BuildConfig.IS_PLAYSTORE_BUILD) {
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
@@ -496,19 +619,17 @@ class Settings : FragmentedStorageFileJson() {
try { try {
it.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${it.packageName}"))) it.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${it.packageName}")))
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
UIDialogs.toast(it, "Failed to show store."); UIDialogs.toast(it, it.getString(R.string.failed_to_show_store));
} }
} }
} }
} }
@FormField( @FormField(R.string.view_changelog, FieldForm.BUTTON, R.string.review_the_current_and_past_changelogs, 4)
"View changelog", FieldForm.BUTTON,
"Review the current and past changelogs", 4
)
fun viewChangelog() { fun viewChangelog() {
UIDialogs.toast("Retrieving changelog");
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
UIDialogs.toast(it.getString(R.string.retrieving_changelog));
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
val version = StateUpdate.instance.downloadVersionCode(ManagedHttpClient()) ?: return@launch; val version = StateUpdate.instance.downloadVersionCode(ManagedHttpClient()) ?: return@launch;
@@ -524,10 +645,7 @@ class Settings : FragmentedStorageFileJson() {
}; };
} }
@FormField( @FormField(R.string.remove_cached_version, FieldForm.BUTTON, R.string.remove_the_last_downloaded_version, 5)
"Remove Cached Version", FieldForm.BUTTON,
"Remove the last downloaded version", 5
)
fun removeCachedVersion() { fun removeCachedVersion() {
StateApp.withContext { StateApp.withContext {
val outputDirectory = File(it.filesDir, "autoupdate"); val outputDirectory = File(it.filesDir, "autoupdate");
@@ -543,7 +661,7 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
@FormField("Backup", FieldForm.GROUP, "", 13) @FormField(R.string.backup, FieldForm.GROUP, -1, 16)
var backup = Backup(); var backup = Backup();
@Serializable @Serializable
class Backup { class Backup {
@@ -553,58 +671,84 @@ class Settings : FragmentedStorageFileJson() {
var autoBackupPassword: String? = null; var autoBackupPassword: String? = null;
fun shouldAutomaticBackup() = autoBackupPassword != null; fun shouldAutomaticBackup() = autoBackupPassword != null;
@FormField("Automatic Backup", FieldForm.READONLYTEXT, "", 0) @FormField(R.string.automatic_backup, FieldForm.READONLYTEXT, -1, 0)
val automaticBackupText get() = if(!shouldAutomaticBackup()) "None" else "Every Day"; val automaticBackupText get() = if(!shouldAutomaticBackup()) "None" else "Every Day";
@FormField("Set Automatic Backup", FieldForm.BUTTON, "Configure daily backup in case of catastrophic failure. (Written to the external Grayjay directory)", 1) @FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1)
fun configureAutomaticBackup() { fun configureAutomaticBackup() {
UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!, autoBackupPassword != null) { UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!, autoBackupPassword != null) {
SettingsActivity.getActivity()?.reloadSettings(); SettingsActivity.getActivity()?.reloadSettings();
}; };
} }
@FormField("Restore Automatic Backup", FieldForm.BUTTON, "Restore a previous automatic backup", 2) @FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
fun restoreAutomaticBackup() { fun restoreAutomaticBackup() {
val activity = SettingsActivity.getActivity()!! val activity = SettingsActivity.getActivity()!!
if(!StateBackup.hasAutomaticBackup()) if(!StateBackup.hasAutomaticBackup())
UIDialogs.toast(activity, "You don't have any automatic backups", false); UIDialogs.toast(activity, activity.getString(R.string.you_don_t_have_any_automatic_backups), false);
else else
UIDialogs.showAutomaticRestoreDialog(activity, activity.lifecycleScope); UIDialogs.showAutomaticRestoreDialog(activity, activity.lifecycleScope);
} }
@FormField("Export Data", FieldForm.BUTTON, "Creates a zip file with your data which can be imported by opening it with Grayjay", 3) @FormField(R.string.export_data, FieldForm.BUTTON, R.string.creates_a_zip_file_with_your_data_which_can_be_imported_by_opening_it_with_grayjay, 3)
fun export() { fun export() {
StateBackup.startExternalBackup(); StateBackup.startExternalBackup();
} }
/*
@FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, 4)
fun import() {
val act = SettingsActivity.getActivity() ?: return;
StateApp.instance.requestFileReadAccess(act, null) {
if(it != null && it.exists()) {
val name = it.name;
val contents = it.readBytes(act);
if(contents != null) {
if(name != null && name.endsWith(".zip", true))
StateBackup.importZipBytes(act, act.lifecycleScope, contents);
}
}
}
}*/
} }
@FormField("Payment", FieldForm.GROUP, "", 14) @FormField(R.string.payment, FieldForm.GROUP, -1, 17)
var payment = Payment(); var payment = Payment();
@Serializable @Serializable
class Payment { class Payment {
@FormField("Payment Status", FieldForm.READONLYTEXT, "", 1) @FormField(R.string.payment_status, FieldForm.READONLYTEXT, -1, 1)
val paymentStatus: String get() = if (StatePayment.instance.hasPaid) "Paid" else "Not Paid"; val paymentStatus: String get() = SettingsActivity.getActivity()?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown";
@FormField("Clear Payment", FieldForm.BUTTON, "Deletes license keys from app", 2) @FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 2)
fun clearPayment() { fun clearPayment() {
StatePayment.instance.clearLicenses(); StatePayment.instance.clearLicenses();
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
UIDialogs.toast(it, "Licenses cleared, might require app restart"); UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
it.reloadSettings(); it.reloadSettings();
} }
} }
} }
@FormField("Info", FieldForm.GROUP, "", 15) @FormField(R.string.other, FieldForm.GROUP, -1, 18)
var other = Other();
@Serializable
class Other {
@FormField(R.string.bypass_rotation_prevention, FieldForm.TOGGLE, R.string.bypass_rotation_prevention_description, 1)
@FormFieldWarning(R.string.bypass_rotation_prevention_warning)
var bypassRotationPrevention: Boolean = false;
}
@FormField(R.string.info, FieldForm.GROUP, -1, 19)
var info = Info(); var info = Info();
@Serializable @Serializable
class Info { class Info {
@FormField("Version Code", FieldForm.READONLYTEXT, "", 1, "code") @FormField(R.string.version_code, FieldForm.READONLYTEXT, -1, 1, "code")
var versionCode = BuildConfig.VERSION_CODE; var versionCode = BuildConfig.VERSION_CODE;
@FormField("Version Name", FieldForm.READONLYTEXT, "", 2) @FormField(R.string.version_name, FieldForm.READONLYTEXT, -1, 2)
var versionName = BuildConfig.VERSION_NAME; var versionName = BuildConfig.VERSION_NAME;
@FormField("Version Type", FieldForm.READONLYTEXT, "", 3) @FormField(R.string.version_type, FieldForm.READONLYTEXT, -1, 3)
var versionType = BuildConfig.BUILD_TYPE; var versionType = BuildConfig.BUILD_TYPE;
} }
@@ -615,6 +759,7 @@ class Settings : FragmentedStorageFileJson() {
companion object { companion object {
private const val TAG = "Settings"; private const val TAG = "Settings";
const val URL_FAQ = "https://grayjay.app/faq.html";
private var _isFirst = true; private var _isFirst = true;
@@ -2,14 +2,25 @@ package com.futo.platformplayer
import android.content.Context import android.content.Context
import android.webkit.CookieManager import android.webkit.CookieManager
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.caoccao.javet.values.primitive.V8ValueInteger import com.caoccao.javet.values.primitive.V8ValueInteger
import com.caoccao.javet.values.primitive.V8ValueString import com.caoccao.javet.values.primitive.V8ValueString
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.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.SourcePluginDescriptor
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.background.BackgroundWorker
import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
@@ -17,6 +28,7 @@ import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
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.fields.FieldForm import com.futo.platformplayer.views.fields.FieldForm
@@ -27,28 +39,30 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.* import kotlinx.serialization.*
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import java.util.UUID
import java.util.concurrent.TimeUnit
import java.util.stream.IntStream.range import java.util.stream.IntStream.range
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
@Serializable() @Serializable()
class SettingsDev : FragmentedStorageFileJson() { class SettingsDev : FragmentedStorageFileJson() {
@FormField("Developer Mode", FieldForm.TOGGLE, "", 0) @FormField(R.string.developer_mode, FieldForm.TOGGLE, -1, 0)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var developerMode: Boolean = false; var developerMode: Boolean = false;
@FormField("Development Server", FieldForm.GROUP, @FormField(R.string.development_server, FieldForm.GROUP,
"Settings related to development server, be careful as it may open your phone to security vulnerabilities", 1) R.string.settings_related_to_development_server_be_careful_as_it_may_open_your_phone_to_security_vulnerabilities, 1)
val devServerSettings: DeveloperServerFields = DeveloperServerFields(); val devServerSettings: DeveloperServerFields = DeveloperServerFields();
@Serializable @Serializable
class DeveloperServerFields { class DeveloperServerFields {
@FormField("Start Server on boot", FieldForm.TOGGLE, "", 0) @FormField(R.string.start_server_on_boot, FieldForm.TOGGLE, -1, 0)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var devServerOnBoot: Boolean = false; var devServerOnBoot: Boolean = false;
@FormField("Start Server", FieldForm.BUTTON, @FormField(R.string.start_server, FieldForm.BUTTON,
"Starts a DevServer on port 11337, may expose vulnerabilities.", 1) R.string.starts_a_devServer_on_port_11337_may_expose_vulnerabilities, 1)
fun startServer() { fun startServer() {
StateDeveloper.instance.runServer(); StateDeveloper.instance.runServer();
StateApp.instance.contextOrNull?.let { StateApp.instance.contextOrNull?.let {
@@ -57,45 +71,65 @@ class SettingsDev : FragmentedStorageFileJson() {
} }
} }
@FormField("Experimental", FieldForm.GROUP, @FormField(R.string.experimental, FieldForm.GROUP,
"Settings related to development server, be careful as it may open your phone to security vulnerabilities", 2) R.string.settings_related_to_development_server_be_careful_as_it_may_open_your_phone_to_security_vulnerabilities, 2)
val experimentalSettings: ExperimentalFields = ExperimentalFields(); val experimentalSettings: ExperimentalFields = ExperimentalFields();
@Serializable @Serializable
class ExperimentalFields { class ExperimentalFields {
@FormField("Background Subscription Testing", FieldForm.TOGGLE, "", 0) @FormField(R.string.background_subscription_testing, FieldForm.TOGGLE, -1, 0)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var backgroundSubscriptionFetching: Boolean = false; var backgroundSubscriptionFetching: Boolean = false;
} }
@FormField("Crash Me", FieldForm.BUTTON, @FormField(R.string.crash_me, FieldForm.BUTTON,
"Crashes the application on purpose", 2) R.string.crashes_the_application_on_purpose, 2)
fun crashMe() { fun crashMe() {
throw java.lang.IllegalStateException("This is an uncaught exception triggered on purpose!"); throw java.lang.IllegalStateException("This is an uncaught exception triggered on purpose!");
} }
@FormField("Delete Announcements", FieldForm.BUTTON, @FormField(R.string.delete_announcements, FieldForm.BUTTON,
"Delete all announcements", 2) R.string.delete_all_announcements, 2)
fun deleteAnnouncements() { fun deleteAnnouncements() {
StateAnnouncement.instance.deleteAllAnnouncements(); StateAnnouncement.instance.deleteAllAnnouncements();
} }
@FormField("Clear Cookies", FieldForm.BUTTON, @FormField(R.string.clear_cookies, FieldForm.BUTTON,
"Clear all cook from the CookieManager", 2) R.string.clear_all_cookies_from_the_cookieManager, 2)
fun clearCookies() { fun clearCookies() {
val cookieManager: CookieManager = CookieManager.getInstance() val cookieManager: CookieManager = CookieManager.getInstance()
cookieManager.removeAllCookies(null); cookieManager.removeAllCookies(null);
} }
@FormField(R.string.test_background_worker, FieldForm.BUTTON,
R.string.test_background_worker_description, 3)
fun triggerBackgroundUpdate() {
val act = SettingsActivity.getActivity()!!;
UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker");
val wm = WorkManager.getInstance(act);
val req = OneTimeWorkRequestBuilder<BackgroundWorker>()
.setInputData(Data.Builder().putBoolean("bypassMainCheck", true).build())
.build();
wm.enqueue(req);
}
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
R.string.test_background_worker_description, 3)
fun clearChannelContentCache() {
UIDialogs.toast(SettingsActivity.getActivity()!!, "Clearing cache");
ChannelContentCache.instance.clearToday();
UIDialogs.toast(SettingsActivity.getActivity()!!, "Cleared");
}
@Contextual @Contextual
@Transient @Transient
@FormField("V8 Benchmarks", FieldForm.GROUP, @FormField(R.string.v8_benchmarks, FieldForm.GROUP,
"Various benchmarks using the integrated V8 engine", 3) R.string.various_benchmarks_using_the_integrated_v8_engine, 4)
val v8Benchmarks: V8Benchmarks = V8Benchmarks(); val v8Benchmarks: V8Benchmarks = V8Benchmarks();
class V8Benchmarks { class V8Benchmarks {
@FormField( @FormField(
"Test V8 Creation speed", FieldForm.BUTTON, R.string.test_v8_creation_speed, FieldForm.BUTTON,
"Tests V8 creation times and running", 1 R.string.tests_v8_creation_times_and_running, 1
) )
fun testV8Creation() { fun testV8Creation() {
var plugin: V8Plugin? = null; var plugin: V8Plugin? = null;
@@ -137,8 +171,8 @@ class SettingsDev : FragmentedStorageFileJson() {
} }
@FormField( @FormField(
"Test V8 Communication speed", FieldForm.BUTTON, R.string.test_v8_communication_speed, FieldForm.BUTTON,
"Tests V8 communication speeds", 2 R.string.tests_v8_communication_speeds, 4
) )
fun testV8RunSpeeds() { fun testV8RunSpeeds() {
var plugin: V8Plugin? = null; var plugin: V8Plugin? = null;
@@ -182,12 +216,12 @@ class SettingsDev : FragmentedStorageFileJson() {
@Contextual @Contextual
@Transient @Transient
@FormField("V8 Script Testing", FieldForm.GROUP, "Various tests against a custom source", 4) @FormField(R.string.v8_script_testing, FieldForm.GROUP, R.string.various_tests_against_a_custom_source, 4)
val v8ScriptTests: V8ScriptTests = V8ScriptTests(); val v8ScriptTests: V8ScriptTests = V8ScriptTests();
class V8ScriptTests { class V8ScriptTests {
@Contextual @Contextual
private var _currentPlugin : JSClient? = null; private var _currentPlugin : JSClient? = null;
@FormField("Inject", FieldForm.BUTTON, "Injects a test source config (local) into V8", 1) @FormField(R.string.inject, FieldForm.BUTTON, R.string.injects_a_test_source_config_local_into_v8, 1)
fun testV8Init() { fun testV8Init() {
StateApp.instance.scope.launch(Dispatchers.IO) { StateApp.instance.scope.launch(Dispatchers.IO) {
try { try {
@@ -203,7 +237,7 @@ class SettingsDev : FragmentedStorageFileJson() {
} }
} }
} }
@FormField("getHome", FieldForm.BUTTON, "Attempts to fetch 2 pages from getHome", 2) @FormField(R.string.getHome, FieldForm.BUTTON, R.string.attempts_to_fetch_2_pages_from_getHome, 2)
fun testV8Home() { fun testV8Home() {
runTestPlugin(_currentPlugin) { runTestPlugin(_currentPlugin) {
var home: IPager<IPlatformContent>? = null; var home: IPager<IPlatformContent>? = null;
@@ -269,27 +303,36 @@ class SettingsDev : FragmentedStorageFileJson() {
@Contextual @Contextual
@Transient @Transient
@FormField("Other", FieldForm.GROUP, "Others...", 5) @FormField(R.string.other, FieldForm.GROUP, R.string.others_ellipsis, 5)
val otherTests: OtherTests = OtherTests(); val otherTests: OtherTests = OtherTests();
class OtherTests { class OtherTests {
@FormField("Clear Downloads", FieldForm.BUTTON, "Deletes all ongoing downloads", 1) @FormField(R.string.unsubscribe_all, FieldForm.BUTTON, R.string.removes_all_subscriptions, -1)
fun unsubscribeAll() {
val toUnsub = StateSubscriptions.instance.getSubscriptions();
UIDialogs.toast("Started unsubbing.. (${toUnsub.size})")
toUnsub.forEach {
StateSubscriptions.instance.removeSubscription(it.channel.url);
};
UIDialogs.toast("Finished unsubbing.. (${toUnsub.size})")
}
@FormField(R.string.clear_downloads, FieldForm.BUTTON, R.string.deletes_all_ongoing_downloads, 1)
fun clearDownloads() { fun clearDownloads() {
StateDownloads.instance.getDownloading().forEach { StateDownloads.instance.getDownloading().forEach {
StateDownloads.instance.removeDownload(it); StateDownloads.instance.removeDownload(it);
}; };
} }
@FormField("Clear All Downloaded", FieldForm.BUTTON, "Deletes all downloaded videos and related files", 2) @FormField(R.string.clear_all_downloaded, FieldForm.BUTTON, R.string.deletes_all_downloaded_videos_and_related_files, 2)
fun clearDownloaded() { fun clearDownloaded() {
StateDownloads.instance.getDownloadedVideos().forEach { StateDownloads.instance.getDownloadedVideos().forEach {
StateDownloads.instance.deleteCachedVideo(it.id); StateDownloads.instance.deleteCachedVideo(it.id);
}; };
} }
@FormField("Delete Unresolved", FieldForm.BUTTON, "Deletes all unresolved source files", 3) @FormField(R.string.delete_unresolved, FieldForm.BUTTON, R.string.deletes_all_unresolved_source_files, 3)
fun cleanupDownloads() { fun cleanupDownloads() {
StateDownloads.instance.cleanupDownloads(); StateDownloads.instance.cleanupDownloads();
} }
@FormField("Fill storage till error", FieldForm.BUTTON, "Writes to disk till no space is left", 4) @FormField(R.string.fill_storage_till_error, FieldForm.BUTTON, R.string.writes_to_disk_till_no_space_is_left, 4)
fun fillStorage(context: Context, scope: CoroutineScope?) { fun fillStorage(context: Context, scope: CoroutineScope?) {
val gigabuffer = ByteArray(1024 * 1024 * 128); val gigabuffer = ByteArray(1024 * 1024 * 128);
var count: Long = 0; var count: Long = 0;
@@ -100,12 +100,12 @@ class UIDialogs {
dialog.show(); dialog.show();
}; };
if(StateBackup.hasAutomaticBackup() && !skipRestoreCheck) if(StateBackup.hasAutomaticBackup() && !skipRestoreCheck)
UIDialogs.showDialog(context, R.drawable.ic_move_up, "An old backup is available", "Would you like to restore this backup?", null, 0, UIDialogs.showDialog(context, R.drawable.ic_move_up, context.getString(R.string.an_old_backup_is_available), context.getString(R.string.would_you_like_to_restore_this_backup), null, 0,
UIDialogs.Action("Cancel", {}), //To nothing UIDialogs.Action(context.getString(R.string.cancel), {}), //To nothing
UIDialogs.Action("Override", { UIDialogs.Action(context.getString(R.string.override), {
dialogAction(); dialogAction();
}, UIDialogs.ActionStyle.DANGEROUS), }, UIDialogs.ActionStyle.DANGEROUS),
UIDialogs.Action("Restore", { UIDialogs.Action(context.getString(R.string.restore), {
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope); UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
}, UIDialogs.ActionStyle.PRIMARY)); }, UIDialogs.ActionStyle.PRIMARY));
else { else {
@@ -211,10 +211,10 @@ class UIDialogs {
(if(ex != null ) "${ex.message}" else ""), (if(ex != null ) "${ex.message}" else ""),
if(ex is PluginException) ex.code else null, if(ex is PluginException) ex.code else null,
0, 0,
UIDialogs.Action("Retry", { UIDialogs.Action(context.getString(R.string.retry), {
retryAction?.invoke(); retryAction?.invoke();
}, UIDialogs.ActionStyle.PRIMARY), }, UIDialogs.ActionStyle.PRIMARY),
UIDialogs.Action("Close", { UIDialogs.Action(context.getString(R.string.close), {
closeAction?.invoke() closeAction?.invoke()
}, UIDialogs.ActionStyle.NONE) }, UIDialogs.ActionStyle.NONE)
); );
@@ -226,15 +226,15 @@ class UIDialogs {
} }
fun showDataRetryDialog(context: Context, reason: String? = null, retryAction: (() -> Unit)? = null, closeAction: (() -> Unit)? = null) { fun showDataRetryDialog(context: Context, reason: String? = null, retryAction: (() -> Unit)? = null, closeAction: (() -> Unit)? = null) {
val retryButtonAction = Action("Retry", retryAction ?: {}, ActionStyle.PRIMARY) val retryButtonAction = Action(context.getString(R.string.retry), retryAction ?: {}, ActionStyle.PRIMARY)
val closeButtonAction = Action("Close", closeAction ?: {}, ActionStyle.ACCENT) val closeButtonAction = Action(context.getString(R.string.close), closeAction ?: {}, ActionStyle.ACCENT)
showDialog(context, R.drawable.ic_no_internet_86dp, "Data Retry", reason, null, 0, closeButtonAction, retryButtonAction) showDialog(context, R.drawable.ic_no_internet_86dp, context.getString(R.string.data_retry), reason, null, 0, closeButtonAction, retryButtonAction)
} }
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null) { fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null) {
val confirmButtonAction = Action("Confirm", action, ActionStyle.PRIMARY) val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
val cancelButtonAction = Action("Cancel", cancelAction ?: {}, ActionStyle.ACCENT) val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction) showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
} }
@@ -1,9 +1,16 @@
package com.futo.platformplayer package com.futo.platformplayer
import android.content.ContentResolver import android.content.ContentResolver
import android.graphics.Color
import android.util.TypedValue
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.TextView
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.streams.VideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
@@ -16,6 +23,7 @@ import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.helpers.VideoHelper
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.models.Subscription
import com.futo.platformplayer.states.* import com.futo.platformplayer.states.*
import com.futo.platformplayer.views.Loader import com.futo.platformplayer.views.Loader
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
@@ -45,6 +53,80 @@ class UISlideOverlays {
menu.show(); menu.show();
} }
fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup) {
val items = arrayListOf<View>();
val originalNotif = subscription.doNotifications;
val originalLive = subscription.doFetchLive;
val originalStream = subscription.doFetchStreams;
val originalVideo = subscription.doFetchVideos;
val originalPosts = subscription.doFetchPosts;
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
val capabilities = plugin.getChannelCapabilities();
withContext(Dispatchers.Main) {
var menu: SlideUpMenuOverlay? = null;
items.addAll(listOf(
SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", {
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
}, false),
SlideUpMenuGroup(container.context, "Fetch Settings",
"Depending on the platform you might not need to enable a type for it to be available.",
-1, listOf()),
if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(container.context, R.drawable.ic_live_tv, "Livestreams", "Check for livestreams", "fetchLive", {
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
}, false) else null,
if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(container.context, R.drawable.ic_play, "Streams", "Check for streams", "fetchStreams", {
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams;
}, false) else null,
if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
SlideUpMenuItem(container.context, R.drawable.ic_play, "Videos", "Check for videos", "fetchVideos", {
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
}, false) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
SlideUpMenuItem(container.context, R.drawable.ic_play, "Content", "Check for content", "fetchVideos", {
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
}, false) else null,
if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", {
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts;
}, false) else null).filterNotNull());
menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items);
if(subscription.doNotifications)
menu.selectOption(null, "notifications", true, true);
if(subscription.doFetchLive)
menu.selectOption(null, "fetchLive", true, true);
if(subscription.doFetchStreams)
menu.selectOption(null, "fetchStreams", true, true);
if(subscription.doFetchVideos)
menu.selectOption(null, "fetchVideos", true, true);
if(subscription.doFetchPosts)
menu.selectOption(null, "fetchPosts", true, true);
menu.onOK.subscribe {
subscription.save();
menu.hide(true);
};
menu.onCancel.subscribe {
subscription.doNotifications = originalNotif;
subscription.doFetchLive = originalLive;
subscription.doFetchStreams = originalStream;
subscription.doFetchVideos = originalVideo;
subscription.doFetchPosts = originalPosts;
};
menu.setOk("Save");
menu.show();
}
}
}
fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? { fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? {
val items = arrayListOf<View>(); val items = arrayListOf<View>();
var menu: SlideUpMenuOverlay? = null; var menu: SlideUpMenuOverlay? = null;
@@ -64,22 +146,22 @@ class UISlideOverlays {
val subtitleSources = video.subtitles; val subtitleSources = video.subtitles;
if(videoSources.size == 0 && (audioSources?.size ?: 0) == 0) { if(videoSources.size == 0 && (audioSources?.size ?: 0) == 0) {
UIDialogs.toast("No downloads available", false); UIDialogs.toast(container.context.getString(R.string.no_downloads_available), false);
return null; return null;
} }
if(!VideoHelper.isDownloadable(video)) { if(!VideoHelper.isDownloadable(video)) {
Logger.i(TAG, "Attempted to open downloads without valid sources for [${video.name}]: ${video.url}"); Logger.i(TAG, "Attempted to open downloads without valid sources for [${video.name}]: ${video.url}");
UIDialogs.toast( "No downloadable sources (yet)"); UIDialogs.toast( container.context.getString(R.string.no_downloadable_sources_yet));
return null; return null;
} }
items.add(SlideUpMenuGroup(container.context, "Video", videoSources, items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
listOf(listOf(SlideUpMenuItem(container.context, R.drawable.ic_movie, "None", "Audio Only", "none", { listOf(listOf(SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.none), container.context.getString(R.string.audio_only), "none", {
selectedVideo = null; selectedVideo = null;
menu?.selectOption(videoSources, "none"); menu?.selectOption(videoSources, "none");
if(selectedAudio != null || !requiresAudio) if(selectedAudio != null || !requiresAudio)
menu?.setOk("Download"); menu?.setOk(container.context.getString(R.string.download));
}, false)) + }, false)) +
videoSources videoSources
.filter { it.isDownloadable() } .filter { it.isDownloadable() }
@@ -88,7 +170,7 @@ class UISlideOverlays {
selectedVideo = it as IVideoUrlSource; selectedVideo = it as IVideoUrlSource;
menu?.selectOption(videoSources, it); menu?.selectOption(videoSources, it);
if(selectedAudio != null || !requiresAudio) if(selectedAudio != null || !requiresAudio)
menu?.setOk("Download"); menu?.setOk(container.context.getString(R.string.download));
}, false) }, false)
}).flatten().toList() }).flatten().toList()
)); ));
@@ -100,13 +182,13 @@ class UISlideOverlays {
audioSources?.let { audioSources -> audioSources?.let { audioSources ->
items.add(SlideUpMenuGroup(container.context, "Audio", audioSources, audioSources items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.audio), audioSources, audioSources
.filter { VideoHelper.isDownloadable(it) } .filter { VideoHelper.isDownloadable(it) }
.map { .map {
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, { SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
selectedAudio = it as IAudioUrlSource; selectedAudio = it as IAudioUrlSource;
menu?.selectOption(audioSources, it); menu?.selectOption(audioSources, it);
menu?.setOk("Download"); menu?.setOk(container.context.getString(R.string.download));
}, false); }, false);
})); }));
val asources = audioSources; val asources = audioSources;
@@ -125,7 +207,7 @@ class UISlideOverlays {
//ContentResolver is required for subtitles.. //ContentResolver is required for subtitles..
if(contentResolver != null) { if(contentResolver != null) {
items.add(SlideUpMenuGroup(container.context, "Subtitles", subtitleSources, subtitleSources items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleSources, subtitleSources
.map { .map {
SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, { SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {
if (selectedSubtitle == it) { if (selectedSubtitle == it) {
@@ -139,7 +221,7 @@ class UISlideOverlays {
})); }));
} }
menu = SlideUpMenuOverlay(container.context, container, "Download Video", null, true, items); menu = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items);
if(selectedVideo != null) { if(selectedVideo != null) {
menu.selectOption(videoSources, selectedVideo); menu.selectOption(videoSources, selectedVideo);
@@ -148,7 +230,7 @@ class UISlideOverlays {
audioSources?.let { audioSources -> menu.selectOption(audioSources, selectedAudio); }; audioSources?.let { audioSources -> menu.selectOption(audioSources, selectedAudio); };
} }
if(selectedAudio != null || (!requiresAudio && selectedVideo != null)) { if(selectedAudio != null || (!requiresAudio && selectedVideo != null)) {
menu.setOk("Download"); menu.setOk(container.context.getString(R.string.download));
} }
menu.onOK.subscribe { menu.onOK.subscribe {
@@ -185,7 +267,7 @@ class UISlideOverlays {
} }
fun showDownloadVideoOverlay(video: IPlatformVideo, container: ViewGroup, useDetails: Boolean = false) { fun showDownloadVideoOverlay(video: IPlatformVideo, container: ViewGroup, useDetails: Boolean = false) {
val handleUnknownDownload: ()->Unit = { val handleUnknownDownload: ()->Unit = {
showUnknownVideoDownload("Video", container) { px, bitrate -> showUnknownVideoDownload(container.context.getString(R.string.video), container) { px, bitrate ->
StateDownloads.instance.download(video, px, bitrate) StateDownloads.instance.download(video, px, bitrate)
}; };
}; };
@@ -195,7 +277,7 @@ class UISlideOverlays {
val scope = StateApp.instance.scopeOrNull; val scope = StateApp.instance.scopeOrNull;
if(scope != null) { if(scope != null) {
val loader = showLoaderOverlay("Fetching video details", container); val loader = showLoaderOverlay(container.context.getString(R.string.fetching_video_details), container);
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
val videoDetails = StatePlatform.instance.getContentDetails(video.url, false).await(); val videoDetails = StatePlatform.instance.getContentDetails(video.url, false).await();
@@ -209,7 +291,7 @@ class UISlideOverlays {
} }
catch(ex: Throwable) { catch(ex: Throwable) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast("Failed to fetch details for download"); UIDialogs.toast(container.context.getString(R.string.failed_to_fetch_details_for_download));
handleUnknownDownload(); handleUnknownDownload();
loader.hide(true); loader.hide(true);
} }
@@ -220,7 +302,7 @@ class UISlideOverlays {
} }
} }
fun showDownloadPlaylistOverlay(playlist: Playlist, container: ViewGroup) { fun showDownloadPlaylistOverlay(playlist: Playlist, container: ViewGroup) {
showUnknownVideoDownload("Video", container) { px, bitrate -> showUnknownVideoDownload(container.context.getString(R.string.video), container) { px, bitrate ->
StateDownloads.instance.download(playlist, px, bitrate); StateDownloads.instance.download(playlist, px, bitrate);
}; };
} }
@@ -232,7 +314,7 @@ class UISlideOverlays {
var targetBitrate: Long = 0; var targetBitrate: Long = 0;
val resolutions = listOf( val resolutions = listOf(
Triple<String, String, Long>("None", "None", -1), Triple<String, String, Long>(container.context.getString(R.string.none), container.context.getString(R.string.none), -1),
Triple<String, String, Long>("480P", "720x480", 720*480), Triple<String, String, Long>("480P", "720x480", 720*480),
Triple<String, String, Long>("720P", "1280x720", 1280*720), Triple<String, String, Long>("720P", "1280x720", 1280*720),
Triple<String, String, Long>("1080P", "1920x1080", 1920*1080), Triple<String, String, Long>("1080P", "1920x1080", 1920*1080),
@@ -240,23 +322,23 @@ class UISlideOverlays {
Triple<String, String, Long>("2160P", "3840x2160", 3840*2160) Triple<String, String, Long>("2160P", "3840x2160", 3840*2160)
); );
items.add(SlideUpMenuGroup(container.context, "Target Resolution", "Video", resolutions.map { items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.target_resolution), "Video", resolutions.map {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.first, it.second, it.third, { SlideUpMenuItem(container.context, R.drawable.ic_movie, it.first, it.second, it.third, {
targetPxSize = it.third; targetPxSize = it.third;
menu?.selectOption("Video", it.third); menu?.selectOption("Video", it.third);
}, false) }, false)
})); }));
items.add(SlideUpMenuGroup(container.context, "Target Bitrate", "Bitrate", listOf( items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.target_bitrate), "Bitrate", listOf(
SlideUpMenuItem(container.context, R.drawable.ic_movie, "Low Bitrate", "", 1, { SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.low_bitrate), "", 1, {
targetBitrate = 1; targetBitrate = 1;
menu?.selectOption("Bitrate", 1); menu?.selectOption("Bitrate", 1);
menu?.setOk("Download"); menu?.setOk(container.context.getString(R.string.download));
}, false), }, false),
SlideUpMenuItem(container.context, R.drawable.ic_movie, "High Bitrate", "", 9999999, { SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.high_bitrate), "", 9999999, {
targetBitrate = 9999999; targetBitrate = 9999999;
menu?.selectOption("Bitrate", 9999999); menu?.selectOption("Bitrate", 9999999);
menu?.setOk("Download"); menu?.setOk(container.context.getString(R.string.download));
}, false) }, false)
))); )));
@@ -277,12 +359,12 @@ class UISlideOverlays {
if(Settings.instance.downloads.isHighBitrateDefault()) { if(Settings.instance.downloads.isHighBitrateDefault()) {
targetBitrate = 9999999; targetBitrate = 9999999;
menu.selectOption("Bitrate", 9999999); menu.selectOption("Bitrate", 9999999);
menu.setOk("Download"); menu.setOk(container.context.getString(R.string.download));
} }
else { else {
targetBitrate = 1; targetBitrate = 1;
menu.selectOption("Bitrate", 1); menu.selectOption("Bitrate", 1);
menu.setOk("Download"); menu.setOk(container.context.getString(R.string.download));
} }
menu.onOK.subscribe { menu.onOK.subscribe {
@@ -304,14 +386,41 @@ class UISlideOverlays {
return overlay; return overlay;
} }
fun showVideoOptionsOverlay(video: IPlatformVideo, container: ViewGroup, onVideoHidden: (()->Unit)? = null): SlideUpMenuOverlay { fun showCreatePlaylistOverlay(container: ViewGroup, onCreate: (String) -> Unit): SlideUpMenuOverlay {
val nameInput = SlideUpMenuTextInput(container.context, container.context.getString(R.string.name));
val addPlaylistOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.create_new_playlist), container.context.getString(R.string.ok), false, nameInput);
addPlaylistOverlay.onOK.subscribe {
val text = nameInput.text;
if (text.isBlank()) {
return@subscribe;
}
addPlaylistOverlay.hide();
nameInput.deactivate();
nameInput.clear();
onCreate(text)
};
addPlaylistOverlay.onCancel.subscribe {
nameInput.deactivate();
nameInput.clear();
};
addPlaylistOverlay.show();
nameInput.activate();
return addPlaylistOverlay
}
fun showVideoOptionsOverlay(video: IPlatformVideo, container: ViewGroup, vararg actions: SlideUpMenuItem): SlideUpMenuOverlay {
val items = arrayListOf<View>(); val items = arrayListOf<View>();
val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist(); val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist();
if (lastUpdated != null) { if (lastUpdated != null) {
items.add( items.add(
SlideUpMenuGroup(container.context, "Recently Used Playlist", "recentlyusedplaylist", SlideUpMenuGroup(container.context, container.context.getString(R.string.recently_used_playlist), "recentlyusedplaylist",
SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} videos", "", SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} " + container.context.getString(R.string.videos), "",
{ {
StatePlaylists.instance.addToPlaylist(lastUpdated.id, video); StatePlaylists.instance.addToPlaylist(lastUpdated.id, video);
StateDownloads.instance.checkForOutdatedPlaylists(); StateDownloads.instance.checkForOutdatedPlaylists();
@@ -322,23 +431,35 @@ class UISlideOverlays {
val allPlaylists = StatePlaylists.instance.getPlaylists(); val allPlaylists = StatePlaylists.instance.getPlaylists();
val queue = StatePlayer.instance.getQueue(); val queue = StatePlayer.instance.getQueue();
val watchLater = StatePlaylists.instance.getWatchLater(); val watchLater = StatePlaylists.instance.getWatchLater();
items.add(SlideUpMenuGroup(container.context, "Actions", "actions", items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, "Hide", "Hide from Home", "hide", (listOf(
{ StateMeta.instance.addHiddenVideo(video.url); onVideoHidden?.invoke() }), 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, "Download", "Download the video", "download", showDownloadVideoOverlay(video, container, true);
{ showDownloadVideoOverlay(video, container, true); }, false) }, false),
)) 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);
UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
}))
+ actions)
));
items.add( items.add(
SlideUpMenuGroup(container.context, "Add To", "addto", SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto",
SlideUpMenuItem(container.context, R.drawable.ic_queue_add, "Add to Queue", "${queue.size} videos", "queue", SlideUpMenuItem(container.context, R.drawable.ic_queue_add, container.context.getString(R.string.add_to_queue), "${queue.size} " + container.context.getString(R.string.videos), "queue",
{ StatePlayer.instance.addToQueue(video); }), { StatePlayer.instance.addToQueue(video); }),
SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, "Add to " + StatePlayer.TYPE_WATCHLATER + "", "${watchLater.size} videos", "watch later", SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, "${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "", "${watchLater.size} " + container.context.getString(R.string.videos), "watch later",
{ StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }) { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); })
)); ));
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", {
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, "Add to " + playlist.name + "", "${playlist.videos.size} videos", "", playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, "${container.context.getString(R.string.add_to)} " + playlist.name + "", "${playlist.videos.size} " + container.context.getString(R.string.videos), "",
{ {
StatePlaylists.instance.addToPlaylist(playlist.id, video); StatePlaylists.instance.addToPlaylist(playlist.id, video);
StateDownloads.instance.checkForOutdatedPlaylists(); StateDownloads.instance.checkForOutdatedPlaylists();
@@ -346,9 +467,9 @@ class UISlideOverlays {
} }
if(playlistItems.size > 0) if(playlistItems.size > 0)
items.add(SlideUpMenuGroup(container.context, "Playlists", "", playlistItems)); items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.playlists), "", playlistItems));
return SlideUpMenuOverlay(container.context, container, "Video Options", null, true, items).apply { show() }; return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.video_options), null, true, items).apply { show() };
} }
@@ -360,8 +481,8 @@ class UISlideOverlays {
if (lastUpdated != null) { if (lastUpdated != null) {
items.add( items.add(
SlideUpMenuGroup(container.context, "Recently Used Playlist", "recentlyusedplaylist", SlideUpMenuGroup(container.context, container.context.getString(R.string.recently_used_playlist), "recentlyusedplaylist",
SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} videos", "", SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} " + container.context.getString(R.string.videos), "",
{ {
StatePlaylists.instance.addToPlaylist(lastUpdated.id, video); StatePlaylists.instance.addToPlaylist(lastUpdated.id, video);
StateDownloads.instance.checkForOutdatedPlaylists(); StateDownloads.instance.checkForOutdatedPlaylists();
@@ -373,18 +494,18 @@ class UISlideOverlays {
val queue = StatePlayer.instance.getQueue(); val queue = StatePlayer.instance.getQueue();
val watchLater = StatePlaylists.instance.getWatchLater(); val watchLater = StatePlaylists.instance.getWatchLater();
items.add( items.add(
SlideUpMenuGroup(container.context, "Other", "other", SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other",
SlideUpMenuItem(container.context, R.drawable.ic_queue_add, "Queue", "${queue.size} videos", "queue", SlideUpMenuItem(container.context, R.drawable.ic_queue_add, container.context.getString(R.string.queue), "${queue.size} " + container.context.getString(R.string.videos), "queue",
{ StatePlayer.instance.addToQueue(video); }), { StatePlayer.instance.addToQueue(video); }),
SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, StatePlayer.TYPE_WATCHLATER, "${watchLater.size} videos", "watch later", SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, StatePlayer.TYPE_WATCHLATER, "${watchLater.size} " + container.context.getString(R.string.videos), "watch later",
{ StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }), { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download", 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),
{ showDownloadVideoOverlay(video, container, true); }, false)) { showDownloadVideoOverlay(video, container, true); }, false))
); );
val playlistItems = arrayListOf<SlideUpMenuItem>(); val playlistItems = arrayListOf<SlideUpMenuItem>();
for (playlist in allPlaylists) { for (playlist in allPlaylists) {
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, playlist.name, "${playlist.videos.size} videos", "", playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, playlist.name, "${playlist.videos.size} " + container.context.getString(R.string.videos), "",
{ {
StatePlaylists.instance.addToPlaylist(playlist.id, video); StatePlaylists.instance.addToPlaylist(playlist.id, video);
StateDownloads.instance.checkForOutdatedPlaylists(); StateDownloads.instance.checkForOutdatedPlaylists();
@@ -392,9 +513,9 @@ class UISlideOverlays {
} }
if(playlistItems.size > 0) if(playlistItems.size > 0)
items.add(SlideUpMenuGroup(container.context, "Playlists", "", playlistItems)); items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.playlists), "", playlistItems));
return SlideUpMenuOverlay(container.context, container, "Add to", null, true, items).apply { show() }; return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.add_to), null, true, items).apply { show() };
} }
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>): SlideUpMenuFilters { fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>): SlideUpMenuFilters {
@@ -412,8 +533,8 @@ class UISlideOverlays {
.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() ?: arrayOf(), }, true) as View }.toTypedArray() ?: arrayOf(),
arrayOf(SlideUpMenuItem(container.context, R.drawable.ic_pin, "Change Pins", "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, "Select your pins in order", (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) { showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) {
val selected = it val selected = it
.map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } } .map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } }
.filter { it != null } .filter { it != null }
@@ -425,7 +546,7 @@ class UISlideOverlays {
}, false)) }, false))
).flatten().toTypedArray(); ).flatten().toTypedArray();
return SlideUpMenuOverlay(container.context, container, "More Options", null, true, *views).apply { show() }; return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.more_options), null, true, *views).apply { show() };
} }
fun showOrderOverlay(container: ViewGroup, title: String, options: List<Pair<String, Any>>, onOrdered: (List<Any>)->Unit) { fun showOrderOverlay(container: ViewGroup, title: String, options: List<Pair<String, Any>>, onOrdered: (List<Any>)->Unit) {
@@ -433,7 +554,7 @@ class UISlideOverlays {
var overlay: SlideUpMenuOverlay? = null; var overlay: SlideUpMenuOverlay? = null;
overlay = SlideUpMenuOverlay(container.context, container, title, "Save", true, overlay = SlideUpMenuOverlay(container.context, container, title, container.context.getString(R.string.save), true,
options.map { SlideUpMenuItem(container.context, R.drawable.ic_move_up, it.first, "", it.second, { options.map { SlideUpMenuItem(container.context, R.drawable.ic_move_up, it.first, "", it.second, {
if(overlay!!.selectOption(null, it.second, true, true)) { if(overlay!!.selectOption(null, it.second, true, true)) {
if(!selection.contains(it.second)) if(!selection.contains(it.second))
@@ -68,6 +68,12 @@ fun ensureNotMainThread() {
} }
} }
private val _regexUrl = Regex("https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&\\/\\/=]*)");
fun String.isHttpUrl(): Boolean {
return _regexUrl.matchEntire(this) != null;
}
private val _regexHexColor = Regex("(#[a-fA-F0-9]{8})|(#[a-fA-F0-9]{6})|(#[a-fA-F0-9]{3})"); private val _regexHexColor = Regex("(#[a-fA-F0-9]{8})|(#[a-fA-F0-9]{6})|(#[a-fA-F0-9]{3})");
fun String.isHexColor(): Boolean { fun String.isHexColor(): Boolean {
return _regexHexColor.matches(this); return _regexHexColor.matches(this);
@@ -158,9 +164,7 @@ fun Int.sp(resources: Resources): Int {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, this.toFloat(), resources.displayMetrics).toInt() return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, this.toFloat(), resources.displayMetrics).toInt()
} }
fun File.share(context: Context) { fun DocumentFile.share(context: Context) {
val uri = FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), this);
val shareIntent = Intent(); val shareIntent = Intent();
shareIntent.action = Intent.ACTION_SEND; shareIntent.action = Intent.ACTION_SEND;
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.os.Bundle import android.os.Bundle
@@ -45,6 +46,10 @@ class AddSourceActivity : AppCompatActivity() {
private var _config: SourcePluginConfig? = null; private var _config: SourcePluginConfig? = null;
private var _script: String? = null; private var _script: String? = null;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
@@ -96,8 +101,8 @@ class AddSourceActivity : AppCompatActivity() {
var url = intent?.dataString; var url = intent?.dataString;
if(url == null) if(url == null)
UIDialogs.showDialog(this, R.drawable.ic_error, "No valid URL provided..", null, null, UIDialogs.showDialog(this, R.drawable.ic_error, getString(R.string.no_valid_url_provided), null, null,
0, UIDialogs.Action("Ok", { finish() }, UIDialogs.ActionStyle.PRIMARY)); 0, UIDialogs.Action(getString(R.string.ok), { finish() }, UIDialogs.ActionStyle.PRIMARY));
else { else {
if(url.startsWith("vfuto://")) if(url.startsWith("vfuto://"))
url = "https://" + url.substring("vfuto://".length); url = "https://" + url.substring("vfuto://".length);
@@ -129,14 +134,14 @@ class AddSourceActivity : AppCompatActivity() {
Logger.e(TAG, "Failed decode config", ex); Logger.e(TAG, "Failed decode config", ex);
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.showDialog(this@AddSourceActivity, R.drawable.ic_error, UIDialogs.showDialog(this@AddSourceActivity, R.drawable.ic_error,
"Invalid Config Format", null, null, getString(R.string.invalid_config_format), null, null,
0, UIDialogs.Action("Ok", { finish() }, UIDialogs.ActionStyle.PRIMARY)); 0, UIDialogs.Action("Ok", { finish() }, UIDialogs.ActionStyle.PRIMARY));
}; };
return@launch; return@launch;
} catch(ex: Exception) { } catch(ex: Exception) {
Logger.e(TAG, "Failed fetch config", ex); Logger.e(TAG, "Failed fetch config", ex);
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, "Failed to fetch configuration", ex); UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, getString(R.string.failed_to_fetch_configuration), ex);
}; };
return@launch; return@launch;
} }
@@ -152,7 +157,7 @@ class AddSourceActivity : AppCompatActivity() {
} catch (ex: Exception) { } catch (ex: Exception) {
Logger.e(TAG, "Failed fetch script", ex); Logger.e(TAG, "Failed fetch script", ex);
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, "Failed to fetch script", ex); UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, getString(R.string.failed_to_fetch_script), ex);
}; };
return@launch; return@launch;
} }
@@ -175,8 +180,8 @@ class AddSourceActivity : AppCompatActivity() {
_sourcePermissions.addView( _sourcePermissions.addView(
SourceInfoView(this, SourceInfoView(this,
R.drawable.ic_language, R.drawable.ic_language,
"URL Access", getString(R.string.url_access),
"The plugin will have access to the following domains", getString(R.string.the_plugin_will_have_access_to_the_following_domains),
config.allowUrls, true) config.allowUrls, true)
) )
@@ -184,8 +189,8 @@ class AddSourceActivity : AppCompatActivity() {
_sourcePermissions.addView( _sourcePermissions.addView(
SourceInfoView(this, SourceInfoView(this,
R.drawable.ic_code, R.drawable.ic_code,
"Eval Access", getString(R.string.eval_access),
"The plugin will have access to eval capability (remote injection)", getString(R.string.the_plugin_will_have_access_to_eval_capability_remote_injection),
config.allowUrls, true) config.allowUrls, true)
) )
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
@@ -7,6 +8,7 @@ import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
import com.google.zxing.integration.android.IntentIntegrator import com.google.zxing.integration.android.IntentIntegrator
import com.journeyapps.barcodescanner.CaptureActivity import com.journeyapps.barcodescanner.CaptureActivity
@@ -16,13 +18,14 @@ class AddSourceOptionsActivity : AppCompatActivity() {
lateinit var _buttonQR: BigButton; lateinit var _buttonQR: BigButton;
lateinit var _buttonURL: BigButton; lateinit var _buttonURL: BigButton;
lateinit var _buttonPlugins: BigButton;
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)
scanResult?.let { scanResult?.let {
val content = it.contents val content = it.contents
if (content == null) { if (content == null) {
UIDialogs.toast(this, "Failed to scan QR code") UIDialogs.toast(this, getString(R.string.failed_to_scan_qr_code))
return@let return@let
} }
@@ -31,7 +34,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
} else if (content.startsWith("grayjay://plugin/")) { } else if (content.startsWith("grayjay://plugin/")) {
content.substring("grayjay://plugin/".length) content.substring("grayjay://plugin/".length)
} else { } else {
UIDialogs.toast(this, "Not a plugin URL") UIDialogs.toast(this, getString(R.string.not_a_plugin_url))
return@let; return@let;
} }
@@ -42,6 +45,10 @@ class AddSourceOptionsActivity : AppCompatActivity() {
} }
} }
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_add_source_options); setContentView(R.layout.activity_add_source_options);
@@ -51,6 +58,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
_buttonQR = findViewById(R.id.option_qr); _buttonQR = findViewById(R.id.option_qr);
_buttonURL = findViewById(R.id.option_url); _buttonURL = findViewById(R.id.option_url);
_buttonPlugins = findViewById(R.id.option_plugins);
_buttonBack.setOnClickListener { _buttonBack.setOnClickListener {
finish(); finish();
@@ -59,7 +67,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
_buttonQR.onClick.subscribe { _buttonQR.onClick.subscribe {
val integrator = IntentIntegrator(this); val integrator = IntentIntegrator(this);
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt("Scan a QR Code") integrator.setPrompt(getString(R.string.scan_a_qr_code))
integrator.setOrientationLocked(true); integrator.setOrientationLocked(true);
integrator.setCameraId(0) integrator.setCameraId(0)
integrator.setBeepEnabled(false) integrator.setBeepEnabled(false)
@@ -69,12 +77,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
} }
_buttonURL.onClick.subscribe { _buttonURL.onClick.subscribe {
UIDialogs.toast(this, "Not implemented yet.."); UIDialogs.toast(this, getString(R.string.not_implemented_yet));
} }
} }
class QRCaptureActivity: CaptureActivity() {
}
} }
@@ -18,6 +18,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.CaptchaWebViewClient import com.futo.platformplayer.others.CaptchaWebViewClient
import com.futo.platformplayer.others.LoginWebViewClient import com.futo.platformplayer.others.LoginWebViewClient
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
@@ -31,6 +32,10 @@ class CaptchaActivity : AppCompatActivity() {
private lateinit var _webView: WebView; private lateinit var _webView: WebView;
private lateinit var _buttonClose: Button; private lateinit var _buttonClose: Button;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_captcha); setContentView(R.layout.activity_captcha);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
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
@@ -11,6 +12,7 @@ import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.* import com.futo.platformplayer.*
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.states.StateApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -27,6 +29,10 @@ class ExceptionActivity : AppCompatActivity() {
private var _file: File? = null; private var _file: File? = null;
private var _submitted = false; private var _submitted = false;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_exception); setContentView(R.layout.activity_exception);
@@ -38,8 +44,8 @@ class ExceptionActivity : AppCompatActivity() {
_buttonRestart = findViewById(R.id.button_restart); _buttonRestart = findViewById(R.id.button_restart);
_buttonClose = findViewById(R.id.button_close); _buttonClose = findViewById(R.id.button_close);
val context = intent.getStringExtra(EXTRA_CONTEXT) ?: "Unknown Context"; val context = intent.getStringExtra(EXTRA_CONTEXT) ?: getString(R.string.unknown_context);
val stack = intent.getStringExtra(EXTRA_STACK) ?: "Something went wrong... missing stack trace?"; val stack = intent.getStringExtra(EXTRA_STACK) ?: getString(R.string.something_went_wrong_missing_stack_trace);
val exceptionString = "Version information (version_name = ${BuildConfig.VERSION_NAME}, version_code = ${BuildConfig.VERSION_CODE}, flavor = ${BuildConfig.FLAVOR}, build_type = ${BuildConfig.BUILD_TYPE})\n" + val exceptionString = "Version information (version_name = ${BuildConfig.VERSION_NAME}, version_code = ${BuildConfig.VERSION_CODE}, flavor = ${BuildConfig.FLAVOR}, build_type = ${BuildConfig.BUILD_TYPE})\n" +
"Device information (brand= ${Build.BRAND}, manufacturer = ${Build.MANUFACTURER}, device = ${Build.DEVICE}, version-sdk = ${Build.VERSION.SDK_INT}, version-os = ${Build.VERSION.BASE_OS})\n\n" + "Device information (brand= ${Build.BRAND}, manufacturer = ${Build.MANUFACTURER}, device = ${Build.DEVICE}, version-sdk = ${Build.VERSION.SDK_INT}, version-os = ${Build.VERSION.BASE_OS})\n\n" +
@@ -79,13 +85,13 @@ class ExceptionActivity : AppCompatActivity() {
private fun submitFile() { private fun submitFile() {
if (_submitted) { if (_submitted) {
Toast.makeText(this, "Logs already submitted.", Toast.LENGTH_LONG).show(); Toast.makeText(this, getString(R.string.logs_already_submitted), Toast.LENGTH_LONG).show();
return; return;
} }
val file = _file; val file = _file;
if (file == null) { if (file == null) {
Toast.makeText(this, "No logs found.", Toast.LENGTH_LONG).show(); Toast.makeText(this, getString(R.string.no_logs_found), Toast.LENGTH_LONG).show();
return; return;
} }
@@ -101,14 +107,14 @@ class ExceptionActivity : AppCompatActivity() {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (id == null) { if (id == null) {
try { try {
Toast.makeText(this@ExceptionActivity, "Failed automated share, share manually?", Toast.LENGTH_LONG).show(); Toast.makeText(this@ExceptionActivity, getString(R.string.failed_automated_share_share_manually), Toast.LENGTH_LONG).show();
} catch (e: Throwable) { } catch (e: Throwable) {
//Ignored //Ignored
} }
} else { } else {
_submitted = true; _submitted = true;
file.delete(); file.delete();
Toast.makeText(this@ExceptionActivity, "Shared $id", Toast.LENGTH_LONG).show(); Toast.makeText(this@ExceptionActivity, getString(R.string.shared_id).replace("{id}", id), Toast.LENGTH_LONG).show();
} }
} }
} }
@@ -119,10 +125,10 @@ class ExceptionActivity : AppCompatActivity() {
val i = Intent(Intent.ACTION_SEND); val i = Intent(Intent.ACTION_SEND);
i.type = "text/plain"; i.type = "text/plain";
i.putExtra(Intent.EXTRA_EMAIL, arrayOf("grayjay@futo.org")); i.putExtra(Intent.EXTRA_EMAIL, arrayOf("grayjay@futo.org"));
i.putExtra(Intent.EXTRA_SUBJECT, "Unhandled exception in VS"); i.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.unhandled_exception_in_vs));
i.putExtra(Intent.EXTRA_TEXT, exceptionString); i.putExtra(Intent.EXTRA_TEXT, exceptionString);
startActivity(Intent.createChooser(i, "Send exception to developers...")); startActivity(Intent.createChooser(i, getString(R.string.send_exception_to_developers)));
} catch (e: Throwable) { } catch (e: Throwable) {
//Ignored //Ignored
@@ -7,6 +7,8 @@ import android.webkit.ConsoleMessage
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
import android.webkit.WebView import android.webkit.WebView
import android.widget.ImageButton
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.* import com.futo.platformplayer.*
@@ -15,6 +17,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.LoginWebViewClient import com.futo.platformplayer.others.LoginWebViewClient
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
@@ -23,13 +26,25 @@ import kotlinx.serialization.json.Json
class LoginActivity : AppCompatActivity() { class LoginActivity : AppCompatActivity() {
private lateinit var _webView: WebView; private lateinit var _webView: WebView;
private lateinit var _textUrl: TextView;
private lateinit var _buttonClose: ImageButton;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_login); setContentView(R.layout.activity_login);
setNavigationBarColorAndIcons(); setNavigationBarColorAndIcons();
_textUrl = findViewById(R.id.text_url);
_buttonClose = findViewById(R.id.button_close);
_buttonClose.setOnClickListener {
finish();
}
_webView = findViewById(R.id.web_view); _webView = findViewById(R.id.web_view);
_webView.settings.javaScriptEnabled = true; _webView.settings.javaScriptEnabled = true;
CookieManager.getInstance().setAcceptCookie(true); CookieManager.getInstance().setAcceptCookie(true);
@@ -60,6 +75,8 @@ class LoginActivity : AppCompatActivity() {
}; };
var isFirstLoad = true; var isFirstLoad = true;
webViewClient.onPageLoaded.subscribe { view, url -> webViewClient.onPageLoaded.subscribe { view, url ->
_textUrl.setText(url ?: "");
if(!isFirstLoad) if(!isFirstLoad)
return@subscribe; return@subscribe;
isFirstLoad = false; isFirstLoad = false;
@@ -7,6 +7,8 @@ import android.content.pm.ActivityInfo
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
import android.preference.PreferenceManager
import android.util.Log
import android.util.TypedValue import android.util.TypedValue
import android.view.View import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
@@ -154,6 +156,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
} }
override fun attachBaseContext(newBase: Context?) {
Logger.i(TAG, "MainActivity.attachBaseContext")
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
StateApp.instance.setGlobalContext(this, lifecycleScope); StateApp.instance.setGlobalContext(this, lifecycleScope);
StateApp.instance.mainAppStarting(this); StateApp.instance.mainAppStarting(this);
@@ -321,6 +328,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
fragCurrent.onOrientationChanged(it); fragCurrent.onOrientationChanged(it);
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED) if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED)
_fragVideoDetail.onOrientationChanged(it); _fragVideoDetail.onOrientationChanged(it);
else if(Settings.instance.other.bypassRotationPrevention)
{
requestedOrientation = when(orientation) {
OrientationManager.Orientation.PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
OrientationManager.Orientation.LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
OrientationManager.Orientation.REVERSED_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
OrientationManager.Orientation.REVERSED_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
}
}
}; };
_orientationManager.enable(); _orientationManager.enable();
@@ -459,6 +475,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.i(TAG, "View Received: " + targetData); Logger.i(TAG, "View Received: " + targetData);
} }
} }
"VIDEO" -> {
val url = intent.getStringExtra("VIDEO");
navigate(_fragVideoDetail, url);
}
"TAB" -> { "TAB" -> {
when(intent.getStringExtra("TAB")){ when(intent.getStringExtra("TAB")){
"Sources" -> { "Sources" -> {
@@ -478,13 +498,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if(targetData.startsWith("grayjay://license/")) { if(targetData.startsWith("grayjay://license/")) {
if(StatePayment.instance.setPaymentLicenseUrl(targetData)) if(StatePayment.instance.setPaymentLicenseUrl(targetData))
{ {
UIDialogs.showDialogOk(this, R.drawable.ic_check, "Your license key has been set!\nAn app restart might be required."); UIDialogs.showDialogOk(this, R.drawable.ic_check, getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required));
if(fragCurrent is BuyFragment) if(fragCurrent is BuyFragment)
closeSegment(fragCurrent); closeSegment(fragCurrent);
} }
else else
UIDialogs.toast("Invalid license format"); UIDialogs.toast(getString(R.string.invalid_license_format));
} }
else if(targetData.startsWith("grayjay://plugin/")) { else if(targetData.startsWith("grayjay://plugin/")) {
@@ -493,13 +513,21 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}; };
startActivity(intent); startActivity(intent);
} }
else if(targetData.startsWith("grayjay://video/")) {
val videoUrl = targetData.substring("grayjay://video/".length);
navigate(_fragVideoDetail, videoUrl);
}
else if(targetData.startsWith("grayjay://channel/")) {
val channelUrl = targetData.substring("grayjay://channel/".length);
navigate(_fragMainChannel, channelUrl);
}
} }
"content" -> { "content" -> {
if(!handleContent(targetData, intent.type)) { if(!handleContent(targetData, intent.type)) {
UIDialogs.showSingleButtonDialog( UIDialogs.showSingleButtonDialog(
this, this,
R.drawable.ic_play, R.drawable.ic_play,
"Unknown content format [${targetData}]", getString(R.string.unknown_content_format) + " [${targetData}]",
"Ok", "Ok",
{ }); { });
} }
@@ -509,7 +537,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
UIDialogs.showSingleButtonDialog( UIDialogs.showSingleButtonDialog(
this, this,
R.drawable.ic_play, R.drawable.ic_play,
"Unknown file format [${targetData}]", getString(R.string.unknown_file_format) + " [${targetData}]",
"Ok", "Ok",
{ }); { });
} }
@@ -519,7 +547,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
UIDialogs.showSingleButtonDialog( UIDialogs.showSingleButtonDialog(
this, this,
R.drawable.ic_play, R.drawable.ic_play,
"Unknown Polycentric format [${targetData}]", getString(R.string.unknown_polycentric_format) + " [${targetData}]",
"Ok", "Ok",
{ }); { });
} }
@@ -529,7 +557,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
UIDialogs.showSingleButtonDialog( UIDialogs.showSingleButtonDialog(
this, this,
R.drawable.ic_play, R.drawable.ic_play,
"Unknown url format [${targetData}]", getString(R.string.unknown_url_format) + " [${targetData}]",
"Ok", "Ok",
{ }); { });
} }
@@ -538,7 +566,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
} }
catch(ex: Throwable) { catch(ex: Throwable) {
UIDialogs.showGeneralErrorDialog(this, "Failed to handle file", ex); UIDialogs.showGeneralErrorDialog(this, getString(R.string.failed_to_handle_file), ex);
} }
} }
@@ -579,6 +607,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateBackup.importZipBytes(this, lifecycleScope, data); StateBackup.importZipBytes(this, lifecycleScope, data);
return true; return true;
} }
else if(file.lowercase().endsWith(".txt") || mime == "text/plain") {
return handleUnknownText(String(data));
}
return false; return false;
} }
fun handleFile(file: String): Boolean { fun handleFile(file: String): Boolean {
@@ -596,6 +627,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateBackup.importZipBytes(this, lifecycleScope, readSharedFile(file)); StateBackup.importZipBytes(this, lifecycleScope, readSharedFile(file));
return true; return true;
} }
else if(file.lowercase().endsWith(".txt")) {
return handleUnknownText(String(readSharedFile(file)));
}
return false; return false;
} }
fun handleReconstruction(recon: String) { fun handleReconstruction(recon: String) {
@@ -603,7 +637,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
val store: ManagedStore<*> = when(type) { val store: ManagedStore<*> = when(type) {
"Playlist" -> StatePlaylists.instance.playlistStore "Playlist" -> StatePlaylists.instance.playlistStore
else -> { else -> {
UIDialogs.toast("Unknown reconstruction type ${type}", false); UIDialogs.toast(getString(R.string.unknown_reconstruction_type) + " ${type}", false);
return; return;
}; };
}; };
@@ -621,6 +655,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
} }
fun handleUnknownText(text: String): Boolean {
try {
if(text.startsWith("@/Subscription") || text.startsWith("Subscriptions")) {
val lines = text.split("\n").map { it.trim() }.drop(1).filter { it.isNotEmpty() };
navigate(_fragImportSubscriptions, lines);
return true;
}
}
catch(ex: Throwable) {
Logger.e(TAG, ex.message, ex);
UIDialogs.showGeneralErrorDialog(this, getString(R.string.failed_to_parse_text_file), ex);
}
return false;
}
fun handleUnknownJson(name: String?, json: String): Boolean { fun handleUnknownJson(name: String?, json: String): Boolean {
val context = this; val context = this;
@@ -646,7 +694,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
catch(ex: Exception) { catch(ex: Exception) {
Logger.e(TAG, ex.message, ex); Logger.e(TAG, ex.message, ex);
UIDialogs.showGeneralErrorDialog(context, "Failed to parse NewPipe Subscriptions", ex); UIDialogs.showGeneralErrorDialog(context, getString(R.string.failed_to_parse_newpipe_subscriptions), ex);
} }
/* /*
@@ -741,6 +789,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateSaved.instance.setVideoToOpenBlocking(null); StateSaved.instance.setVideoToOpenBlocking(null);
} }
inline fun <reified T> isFragmentActive(): Boolean {
return fragCurrent is T;
}
/** /**
* Navigate takes a MainFragment, and makes them the current main visible view * Navigate takes a MainFragment, and makes them the current main visible view
@@ -834,15 +885,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if((fragment?.isOverlay ?: false) && fragBeforeOverlay != null) { if((fragment?.isOverlay ?: false) && fragBeforeOverlay != null) {
navigate(fragBeforeOverlay!!, null, false, true); navigate(fragBeforeOverlay!!, null, false, true);
} else {
}
else {
val last = _queue.lastOrNull(); val last = _queue.lastOrNull();
if (last != null) { if (last != null) {
_queue.remove(last); _queue.remove(last);
navigate(last.first, last.second, false, true); navigate(last.first, last.second, false, true);
} else } else {
finish(); if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
finish();
} else {
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
finish();
})
}
}
} }
} }
@@ -925,5 +981,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
return sourcesIntent; return sourcesIntent;
} }
fun getVideoIntent(context: Context, videoUrl: String) : Intent {
val sourcesIntent = Intent(context, MainActivity::class.java);
sourcesIntent.action = "VIDEO";
sourcesIntent.putExtra("VIDEO", videoUrl);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
return sourcesIntent;
}
} }
} }
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.widget.ImageButton import android.widget.ImageButton
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
@@ -10,6 +11,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
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.adapters.ItemMoveCallback import com.futo.platformplayer.views.adapters.ItemMoveCallback
@@ -23,6 +25,10 @@ class ManageTabsActivity : AppCompatActivity() {
private lateinit var _recyclerTabs: RecyclerView; private lateinit var _recyclerTabs: RecyclerView;
private lateinit var _touchHelper: ItemTouchHelper; private lateinit var _touchHelper: ItemTouchHelper;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_manage_tabs); setContentView(R.layout.activity_manage_tabs);
@@ -16,6 +16,7 @@ import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
import com.futo.polycentric.core.* import com.futo.polycentric.core.*
@@ -33,6 +34,10 @@ class PolycentricBackupActivity : AppCompatActivity() {
private lateinit var _exportBundle: String; private lateinit var _exportBundle: String;
private lateinit var _textQR: TextView; private lateinit var _textQR: TextView;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_backup); setContentView(R.layout.activity_polycentric_backup);
@@ -53,7 +58,7 @@ class PolycentricBackupActivity : AppCompatActivity() {
val qrCodeBitmap = generateQRCode(_exportBundle, dimension, dimension); val qrCodeBitmap = generateQRCode(_exportBundle, dimension, dimension);
_imageQR.setImageBitmap(qrCodeBitmap); _imageQR.setImageBitmap(qrCodeBitmap);
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "Failed to generate QR code", e); Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e);
_imageQR.visibility = View.INVISIBLE; _imageQR.visibility = View.INVISIBLE;
_textQR.visibility = View.INVISIBLE; _textQR.visibility = View.INVISIBLE;
} }
@@ -63,12 +68,12 @@ class PolycentricBackupActivity : AppCompatActivity() {
type = "text/plain"; type = "text/plain";
putExtra(Intent.EXTRA_TEXT, _exportBundle); putExtra(Intent.EXTRA_TEXT, _exportBundle);
} }
startActivity(Intent.createChooser(shareIntent, "Share Text")); startActivity(Intent.createChooser(shareIntent, getString(R.string.share_text)));
}; };
_buttonCopy.onClick.subscribe { _buttonCopy.onClick.subscribe {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager; val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager;
val clip = ClipData.newPlainText("Copied Text", _exportBundle); val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle);
clipboard.setPrimaryClip(clip); clipboard.setPrimaryClip(clip);
}; };
} }
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.widget.EditText import android.widget.EditText
@@ -9,8 +10,10 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
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
@@ -28,6 +31,10 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
private var _creating = false; private var _creating = false;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_create_profile); setContentView(R.layout.activity_polycentric_create_profile);
@@ -54,7 +61,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
try { try {
val username = _profileName.text.toString(); val username = _profileName.text.toString();
if (username.length < 3) { if (username.length < 3) {
UIDialogs.toast(this@PolycentricCreateProfileActivity, "Must be at least 3 characters long."); UIDialogs.toast(this@PolycentricCreateProfileActivity, getString(R.string.must_be_at_least_3_characters_long));
return@setOnClickListener; return@setOnClickListener;
} }
@@ -68,16 +75,18 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
processHandle.setUsername(username); processHandle.setUsername(username);
StatePolycentric.instance.setProcessHandle(processHandle); StatePolycentric.instance.setProcessHandle(processHandle);
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to create profile .", e); Logger.e(TAG, getString(R.string.failed_to_create_profile), e);
return@launch; return@launch;
} finally { } finally {
_creating = false; _creating = false;
} }
try { try {
processHandle.fullyBackfillServers(); Logger.i(TAG, "Started backfill");
processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to fully backfill servers."); Logger.e(TAG, getString(R.string.failed_to_fully_backfill_servers), e);
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
@@ -15,6 +16,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
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.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
import com.futo.polycentric.core.Store import com.futo.polycentric.core.Store
@@ -27,6 +29,10 @@ class PolycentricHomeActivity : AppCompatActivity() {
private lateinit var _buttonImportProfile: BigButton; private lateinit var _buttonImportProfile: BigButton;
private lateinit var _layoutButtons: LinearLayout; private lateinit var _layoutButtons: LinearLayout;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_home); setContentView(R.layout.activity_polycentric_home);
@@ -47,7 +53,7 @@ class PolycentricHomeActivity : AppCompatActivity() {
this.setMargins(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f, resources.displayMetrics).toInt()); this.setMargins(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f, resources.displayMetrics).toInt());
}; };
profileButton.withPrimaryText(systemState.username); profileButton.withPrimaryText(systemState.username);
profileButton.withSecondaryText("Sign in to this identity"); profileButton.withSecondaryText(getString(R.string.sign_in_to_this_identity));
profileButton.onClick.subscribe { profileButton.onClick.subscribe {
StatePolycentric.instance.setProcessHandle(processHandle); StatePolycentric.instance.setProcessHandle(processHandle);
startActivity(Intent(this@PolycentricHomeActivity, PolycentricProfileActivity::class.java)); startActivity(Intent(this@PolycentricHomeActivity, PolycentricProfileActivity::class.java));
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.widget.EditText import android.widget.EditText
@@ -12,6 +13,7 @@ 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.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
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.*
import com.google.zxing.integration.android.IntentIntegrator import com.google.zxing.integration.android.IntentIntegrator
@@ -39,6 +41,10 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
} }
} }
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_import_profile); setContentView(R.layout.activity_polycentric_import_profile);
@@ -59,7 +65,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
_buttonScanProfile.setOnClickListener { _buttonScanProfile.setOnClickListener {
val integrator = IntentIntegrator(this) val integrator = IntentIntegrator(this)
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt("Scan a QR code") integrator.setPrompt(getString(R.string.scan_a_qr_code))
integrator.setOrientationLocked(true); integrator.setOrientationLocked(true);
integrator.setCameraId(0) integrator.setCameraId(0)
integrator.setBeepEnabled(false) integrator.setBeepEnabled(false)
@@ -70,7 +76,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
_buttonImportProfile.setOnClickListener { _buttonImportProfile.setOnClickListener {
if (_editProfile.text.isEmpty()) { if (_editProfile.text.isEmpty()) {
UIDialogs.toast(this, "Text field does not contain any data"); UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data));
return@setOnClickListener; return@setOnClickListener;
} }
@@ -85,7 +91,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
private fun import(url: String) { private fun import(url: String) {
if (!url.startsWith("polycentric://")) { if (!url.startsWith("polycentric://")) {
UIDialogs.toast(this, "Not a valid URL"); UIDialogs.toast(this, getString(R.string.not_a_valid_url));
return; return;
} }
@@ -101,7 +107,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey); val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
if (existingProcessSecret != null) { if (existingProcessSecret != null) {
UIDialogs.toast(this, "This profile is already imported"); UIDialogs.toast(this, getString(R.string.this_profile_is_already_imported));
return; return;
} }
@@ -124,15 +130,11 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
finish(); finish();
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to import profile", e); Logger.w(TAG, "Failed to import profile", e);
UIDialogs.toast(this, "Failed to import profile: '${e.message}'"); UIDialogs.toast(this, getString(R.string.failed_to_import_profile) + " '${e.message}'");
} }
} }
companion object { companion object {
private const val TAG = "PolycentricImportProfileActivity"; private const val TAG = "PolycentricImportProfileActivity";
} }
class QRCaptureActivity: CaptureActivity() {
}
} }
@@ -2,6 +2,7 @@ package com.futo.platformplayer.activities
import android.app.Activity import android.app.Activity
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
@@ -16,7 +17,9 @@ import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.dialogs.CommentDialog
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
@@ -28,6 +31,7 @@ import com.futo.polycentric.core.Store
import com.futo.polycentric.core.Synchronization import com.futo.polycentric.core.Synchronization
import com.futo.polycentric.core.SystemState import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.toURLInfoDataLink import com.futo.polycentric.core.toURLInfoDataLink
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
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -46,6 +50,10 @@ class PolycentricProfileActivity : AppCompatActivity() {
private lateinit var _imagePolycentric: ImageView; private lateinit var _imagePolycentric: ImageView;
private var _avatarUri: Uri? = null; private var _avatarUri: Uri? = null;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_profile); setContentView(R.layout.activity_polycentric_profile);
@@ -72,7 +80,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
} }
} catch (e: Throwable) { } catch (e: Throwable) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, "Failed to backfill client"); UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_backfill_client));
} }
} }
} }
@@ -101,10 +109,10 @@ class PolycentricProfileActivity : AppCompatActivity() {
} }
_buttonDelete.onClick.subscribe { _buttonDelete.onClick.subscribe {
UIDialogs.showConfirmationDialog(this, "Are you sure you want to remove this profile?", { UIDialogs.showConfirmationDialog(this, getString(R.string.are_you_sure_you_want_to_remove_this_profile), {
val processHandle = StatePolycentric.instance.processHandle; val processHandle = StatePolycentric.instance.processHandle;
if (processHandle == null) { if (processHandle == null) {
UIDialogs.toast(this, "No process handle set"); UIDialogs.toast(this, getString(R.string.no_process_handle_set));
return@showConfirmationDialog; return@showConfirmationDialog;
} }
@@ -122,13 +130,13 @@ 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, "Name must be at least 3 characters long"); 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, "Process handle unset"); UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.process_handle_unset));
return@launch; return@launch;
} }
@@ -143,7 +151,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
val bytes = readBytesFromUri(applicationContext.contentResolver, avatarUri); val bytes = readBytesFromUri(applicationContext.contentResolver, avatarUri);
if (bytes == null) { if (bytes == null) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, "Failed to read image"); UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_read_image));
} }
return@launch; return@launch;
@@ -186,14 +194,16 @@ class PolycentricProfileActivity : AppCompatActivity() {
if (hasChanges) { if (hasChanges) {
try { try {
processHandle.fullyBackfillServers(); Logger.i(TAG, "Started backfill");
processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, "Changes have been saved"); UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.changes_have_been_saved));
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to synchronize changes", e); Logger.w(TAG, "Failed to synchronize changes", e);
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, "Failed to synchronize changes"); UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_synchronize_changes));
} }
} }
} }
@@ -219,7 +229,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
val avatar = systemState.avatar.selectBestImage(dp_80 * dp_80); val avatar = systemState.avatar.selectBestImage(dp_80 * dp_80);
Glide.with(_imagePolycentric) Glide.with(_imagePolycentric)
.load(avatar?.toURLInfoDataLink(processHandle.system.toProto(), processHandle.processSecret.process.toProto(), systemState.servers.toList())) .load(avatar?.toURLInfoSystemLinkUrl(processHandle.system.toProto(), avatar.process, systemState.servers.toList()))
.placeholder(R.drawable.placeholder_profile) .placeholder(R.drawable.placeholder_profile)
.crossfade() .crossfade()
.into(_imagePolycentric) .into(_imagePolycentric)
@@ -235,7 +245,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
} else if (resultCode == ImagePicker.RESULT_ERROR) { } else if (resultCode == ImagePicker.RESULT_ERROR) {
UIDialogs.toast(this, ImagePicker.getError(data)); UIDialogs.toast(this, ImagePicker.getError(data));
} else { } else {
UIDialogs.toast(this, "Image picker cancelled"); UIDialogs.toast(this, getString(R.string.image_picker_cancelled));
} }
} }
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
@@ -7,12 +8,17 @@ import android.widget.ImageButton
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
class PolycentricWhyActivity : AppCompatActivity() { class PolycentricWhyActivity : AppCompatActivity() {
private lateinit var _buttonVideo: BigButton; private lateinit var _buttonVideo: BigButton;
private lateinit var _buttonTechnical: BigButton; private lateinit var _buttonTechnical: BigButton;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_why); setContentView(R.layout.activity_polycentric_why);
@@ -0,0 +1,7 @@
package com.futo.platformplayer.activities
import com.journeyapps.barcodescanner.CaptureActivity
class QRCaptureActivity : CaptureActivity() {
}
@@ -1,6 +1,7 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
@@ -13,6 +14,7 @@ import androidx.appcompat.app.AppCompatActivity
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
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.Loader import com.futo.platformplayer.views.Loader
import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.ReadOnlyTextField import com.futo.platformplayer.views.fields.ReadOnlyTextField
@@ -28,6 +30,10 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
private var _isFinished = false; private var _isFinished = false;
override fun attachBaseContext(newBase: Context?) {
Logger.i("SettingsActivity", "SettingsActivity.attachBaseContext")
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_settings); setContentView(R.layout.activity_settings);
@@ -43,6 +49,11 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving"); Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
_form.setObjectValues(); _form.setObjectValues();
Settings.instance.save(); Settings.instance.save();
if(field.descriptor?.id == "app_language") {
Logger.i("SettingsActivity", "App language change detected, propogating to shared preferences");
StateApp.instance.setLocaleSetting(this, Settings.instance.language.getAppLanguageLocaleString());
}
}; };
_buttonBack.setOnClickListener { _buttonBack.setOnClickListener {
finish(); finish();
@@ -70,7 +81,7 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
SettingsDev.instance.developerMode = true; SettingsDev.instance.developerMode = true;
SettingsDev.instance.save(); SettingsDev.instance.save();
updateDevMode(); updateDevMode();
UIDialogs.toast(this, "You are now in developer mode"); UIDialogs.toast(this, getString(R.string.you_are_now_in_developer_mode));
} }
}; };
}; };
@@ -8,16 +8,20 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.BufferedReader import java.io.BufferedReader
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.io.StringWriter import java.io.StringWriter
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
class HttpContext : AutoCloseable { class HttpContext : AutoCloseable {
private val _stream: BufferedReader; private val _inputStream: InputStream;
private var _responseStream: OutputStream? = null; private var _responseStream: OutputStream? = null;
var id: String? = null; var id: String? = null;
var head: String = ""; var head: String = "";
var headers: HttpHeaders = HttpHeaders(); var headers: HttpHeaders = HttpHeaders();
@@ -39,76 +43,130 @@ class HttpContext : AutoCloseable {
private val _responseHeaders: HttpHeaders = HttpHeaders(); private val _responseHeaders: HttpHeaders = HttpHeaders();
constructor(stream: BufferedReader, responseStream: OutputStream? = null, requestId: String? = null, timeout: Int? = null) { constructor(inputStream: InputStream, responseStream: OutputStream? = null, requestId: String? = null, timeout: Int? = null) {
_stream = stream; _inputStream = inputStream;
_responseStream = responseStream; _responseStream = responseStream;
this.id = requestId; this.id = requestId;
try { val headerBytes = readHeaderBytes()
head = stream.readLine() ?: throw EmptyRequestException("No head found"); ByteArrayInputStream(headerBytes).use {
} val reader = it.bufferedReader(Charsets.UTF_8)
catch(ex: SocketTimeoutException) { try {
if((timeout ?: 0) > 0) head = reader.readLine() ?: throw EmptyRequestException("No head found");
throw KeepAliveTimeoutException("Keep-Alive timedout", ex); }
throw ex; catch(ex: SocketTimeoutException) {
} if((timeout ?: 0) > 0)
throw KeepAliveTimeoutException("Keep-Alive timedout", ex);
val methodEndIndex = head.indexOf(' '); throw ex;
val urlEndIndex = head.indexOf(' ', methodEndIndex + 1);
if (methodEndIndex == -1 || urlEndIndex == -1) {
Logger.w(TAG, "Skipped request, wrong format.");
throw IllegalStateException("Invalid request");
}
method = head.substring(0, methodEndIndex);
path = head.substring(methodEndIndex + 1, urlEndIndex);
if (path.contains("?")) {
val queryPartIndex = path.indexOf("?");
val queryParts = path.substring(queryPartIndex + 1).split("&");
path = path.substring(0, queryPartIndex);
for(queryPart in queryParts) {
val eqIndex = queryPart.indexOf("=");
if(eqIndex > 0)
query.put(queryPart.substring(0, eqIndex), queryPart.substring(eqIndex + 1));
else
query.put(queryPart, "");
} }
}
while (true) { val methodEndIndex = head.indexOf(' ');
val line = stream.readLine(); val urlEndIndex = head.indexOf(' ', methodEndIndex + 1);
val headerEndIndex = line.indexOf(":"); if (methodEndIndex == -1 || urlEndIndex == -1) {
if (headerEndIndex == -1) Logger.w(TAG, "Skipped request, wrong format.");
break; throw IllegalStateException("Invalid request");
}
val headerKey = line.substring(0, headerEndIndex).lowercase() method = head.substring(0, methodEndIndex);
val headerValue = line.substring(headerEndIndex + 1).trim(); path = head.substring(methodEndIndex + 1, urlEndIndex);
headers[headerKey] = headerValue;
when(headerKey) { if (path.contains("?")) {
"content-length" -> contentLength = headerValue.toLong(); val queryPartIndex = path.indexOf("?");
"content-type" -> contentType = headerValue; val queryParts = path.substring(queryPartIndex + 1).split("&");
"connection" -> keepAlive = headerValue.lowercase() == "keep-alive"; path = path.substring(0, queryPartIndex);
"keep-alive" -> {
val keepAliveParams = headerValue.split(","); for(queryPart in queryParts) {
for(keepAliveParam in keepAliveParams) { val eqIndex = queryPart.indexOf("=");
val eqIndex = keepAliveParam.indexOf("="); if(eqIndex > 0)
if(eqIndex > 0){ query.put(queryPart.substring(0, eqIndex), queryPart.substring(eqIndex + 1));
when(keepAliveParam.substring(0, eqIndex)) { else
"timeout" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt(); query.put(queryPart, "");
"max" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt(); }
}
while (true) {
val line = reader.readLine();
val headerEndIndex = line.indexOf(":");
if (headerEndIndex == -1)
break;
val headerKey = line.substring(0, headerEndIndex).lowercase()
val headerValue = line.substring(headerEndIndex + 1).trim();
headers[headerKey] = headerValue;
when(headerKey) {
"content-length" -> contentLength = headerValue.toLong();
"content-type" -> contentType = headerValue;
"connection" -> keepAlive = headerValue.lowercase() == "keep-alive";
"keep-alive" -> {
val keepAliveParams = headerValue.split(",");
for(keepAliveParam in keepAliveParams) {
val eqIndex = keepAliveParam.indexOf("=");
if(eqIndex > 0){
when(keepAliveParam.substring(0, eqIndex)) {
"timeout" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt();
"max" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt();
}
} }
} }
} }
} }
if(line.isNullOrEmpty())
break;
} }
if(line.isNullOrEmpty())
break;
} }
} }
private fun readHeaderBytes(): ByteArray {
val headerBytes = ByteArrayOutputStream()
var crlfCount = 0
while (crlfCount < 4) {
val b = _inputStream.read()
if (b == -1) {
throw IOException("Unexpected end of stream while reading headers")
}
if (b == 0x0D || b == 0x0A) { // CR or LF
crlfCount++
} else {
crlfCount = 0
}
headerBytes.write(b)
}
return headerBytes.toByteArray()
}
fun readContentBytes(buffer: ByteArray, length: Int): Int {
val remainingBytes = (contentLength - _totalRead).coerceAtMost(length.toLong()).toInt()
val read = _inputStream.read(buffer, 0, remainingBytes);
if (read > 0) {
_totalRead += read
}
return read;
}
fun readContentString(): String {
val byteArrayOutputStream = ByteArrayOutputStream()
val buffer = ByteArray(4096)
var read: Int
while (true) {
read = readContentBytes(buffer, buffer.size)
if (read <= 0) break
byteArrayOutputStream.write(buffer, 0, read)
}
return byteArrayOutputStream.toString(Charsets.UTF_8.name())
}
inline fun <reified T> readContentJson() : T {
return Serializer.json.decodeFromString(readContentString());
}
fun skipBody() {
if (contentLength > 0)
_inputStream.skip(contentLength - _totalRead)
}
fun getHttpHeaderString(): String { fun getHttpHeaderString(): String {
val writer = StringWriter(); val writer = StringWriter();
writer.write(head + "\r\n"); writer.write(head + "\r\n");
@@ -161,8 +219,7 @@ class HttpContext : AutoCloseable {
headersToRespond.put("keep-alive", "timeout=5, max=1000"); headersToRespond.put("keep-alive", "timeout=5, max=1000");
} }
val responseHeader = HttpResponse(status, headers); val responseHeader = HttpResponse(status, headersToRespond);
responseStream.write(responseHeader.getHttpHeaderBytes()); responseStream.write(responseHeader.getHttpHeaderBytes());
if(method != "HEAD") { if(method != "HEAD") {
@@ -172,38 +229,9 @@ class HttpContext : AutoCloseable {
statusCode = status; statusCode = status;
} }
fun readContentBytes(buffer: CharArray, length: Int) : Int {
val reading = Math.min(length, (contentLength - _totalRead).toInt());
val read = _stream.read(buffer, 0, reading);
_totalRead += read;
//TODO: Fix this properly
if(contentLength - _totalRead < 400 && read < length) {
_totalRead = contentLength;
}
return read;
}
fun readContentString() : String{
val writer = StringWriter();
var read = 0;
val buffer = CharArray(4096);
do {
read = readContentBytes(buffer, buffer.size);
writer.write(buffer, 0, read);
} while(read > 0);
return writer.toString();
}
inline fun <reified T> readContentJson() : T {
return Serializer.json.decodeFromString(readContentString());
}
fun skipBody() {
if(contentLength > 0)
_stream.skip(contentLength - _totalRead);
}
override fun close() { override fun close() {
if(!keepAlive) { if(!keepAlive) {
_stream?.close(); _inputStream.close();
_responseStream?.close(); _responseStream?.close();
} }
} }
@@ -5,8 +5,7 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException
import com.futo.platformplayer.api.http.server.handlers.HttpFuntionHandler import com.futo.platformplayer.api.http.server.handlers.HttpFuntionHandler
import com.futo.platformplayer.api.http.server.handlers.HttpHandler import com.futo.platformplayer.api.http.server.handlers.HttpHandler
import java.io.BufferedReader import java.io.BufferedInputStream
import java.io.InputStreamReader
import java.io.OutputStream import java.io.OutputStream
import java.lang.reflect.Field import java.lang.reflect.Field
import java.lang.reflect.Method import java.lang.reflect.Method
@@ -18,6 +17,7 @@ import java.util.*
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.stream.IntStream.range import java.util.stream.IntStream.range
import kotlin.collections.HashMap
class ManagedHttpServer(private val _requestedPort: Int = 0) { class ManagedHttpServer(private val _requestedPort: Int = 0) {
private val _client : ManagedHttpClient = ManagedHttpClient(); private val _client : ManagedHttpClient = ManagedHttpClient();
@@ -29,7 +29,8 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
var port = 0 var port = 0
private set; private set;
private val _handlers = mutableListOf<HttpHandler>(); private val _handlers = hashMapOf<String, HashMap<String, HttpHandler>>()
private val _headHandlers = hashMapOf<String, HttpHandler>()
private var _workerPool: ExecutorService? = null; private var _workerPool: ExecutorService? = null;
@Synchronized @Synchronized
@@ -76,12 +77,12 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
private fun handleClientRequest(socket: Socket) { private fun handleClientRequest(socket: Socket) {
_workerPool?.submit { _workerPool?.submit {
val requestReader = BufferedReader(InputStreamReader(socket.getInputStream())) val requestStream = BufferedInputStream(socket.getInputStream());
val responseStream = socket.getOutputStream(); val responseStream = socket.getOutputStream();
val requestId = UUID.randomUUID().toString().substring(0, 5); val requestId = UUID.randomUUID().toString().substring(0, 5);
try { try {
keepAliveLoop(requestReader, responseStream, requestId) { req -> keepAliveLoop(requestStream, responseStream, requestId) { req ->
req.use { httpContext -> req.use { httpContext ->
if(!httpContext.path.startsWith("/plugin/")) if(!httpContext.path.startsWith("/plugin/"))
Logger.i(TAG, "[${req.id}] ${httpContext.method}: ${httpContext.path}") Logger.i(TAG, "[${req.id}] ${httpContext.method}: ${httpContext.path}")
@@ -107,7 +108,7 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
Logger.e(TAG, "Failed to handle client request.", e); Logger.e(TAG, "Failed to handle client request.", e);
} }
finally { finally {
requestReader.close(); requestStream.close();
responseStream.close(); responseStream.close();
} }
}; };
@@ -115,32 +116,61 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
fun getHandler(method: String, path: String) : HttpHandler? { fun getHandler(method: String, path: String) : HttpHandler? {
synchronized(_handlers) { synchronized(_handlers) {
//TODO: Support regex paths? if (method == "HEAD") {
if(method == "HEAD") return _headHandlers[path]
return _handlers.firstOrNull { it.path == path && (it.allowHEAD || it.method == "HEAD") } }
return _handlers.firstOrNull { it.method == method && it.path == path };
val handlerMap = _handlers[method] ?: return null
return handlerMap[path]
} }
} }
fun addHandler(handler: HttpHandler, withHEAD: Boolean = false) : HttpHandler { fun addHandler(handler: HttpHandler, withHEAD: Boolean = false) : HttpHandler {
synchronized(_handlers) { synchronized(_handlers) {
_handlers.add(handler);
handler.allowHEAD = withHEAD; handler.allowHEAD = withHEAD;
var handlerMap: HashMap<String, HttpHandler>? = _handlers[handler.method];
if (handlerMap == null) {
handlerMap = hashMapOf()
_handlers[handler.method] = handlerMap
}
handlerMap[handler.path] = handler;
if (handler.allowHEAD || handler.method == "HEAD") {
_headHandlers[handler.path] = handler
}
} }
return handler; return handler;
} }
fun removeHandler(method: String, path: String) { fun removeHandler(method: String, path: String) {
synchronized(_handlers) { synchronized(_handlers) {
val handler = getHandler(method, path); val handlerMap = _handlers[method] ?: return
if(handler != null) val handler = handlerMap.remove(path) ?: return
_handlers.remove(handler); if (method == "HEAD" || handler.allowHEAD) {
_headHandlers.remove(path)
}
} }
} }
fun removeAllHandlers(tag: String? = null) { fun removeAllHandlers(tag: String? = null) {
synchronized(_handlers) { synchronized(_handlers) {
if(tag == null) if(tag == null)
_handlers.clear(); _handlers.clear();
else else {
_handlers.removeIf { it.tag == tag }; for (pair in _handlers) {
val toRemove = ArrayList<String>()
for (innerPair in pair.value) {
if (innerPair.value.tag == tag) {
toRemove.add(innerPair.key)
if (pair.key == "HEAD" || innerPair.value.allowHEAD) {
_headHandlers.remove(innerPair.key)
}
}
}
for (x in toRemove)
pair.value.remove(x)
}
}
} }
} }
fun addBridgeHandlers(obj: Any, tag: String? = null) { fun addBridgeHandlers(obj: Any, tag: String? = null) {
@@ -188,7 +218,7 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
} }
} }
private fun keepAliveLoop(requestReader: BufferedReader, responseStream: OutputStream, requestId: String, handler: (HttpContext)->Unit) { private fun keepAliveLoop(requestReader: BufferedInputStream, responseStream: OutputStream, requestId: String, handler: (HttpContext)->Unit) {
val stopCount = _stopCount; val stopCount = _stopCount;
var keepAlive = false; var keepAlive = false;
var requestsMax = 0; var requestsMax = 0;
@@ -7,7 +7,6 @@ class HttpConstantHandler(method: String, path: String, val content: String, val
val headers = this.headers.clone(); val headers = this.headers.clone();
if(contentType != null) if(contentType != null)
headers["Content-Type"] = contentType; headers["Content-Type"] = contentType;
headers["Content-Length"] = content.length.toString();
httpContext.respondCode(200, headers, content); httpContext.respondCode(200, headers, content);
} }
@@ -1,14 +1,16 @@
package com.futo.platformplayer.api.http.server.handlers package com.futo.platformplayer.api.http.server.handlers
import com.futo.platformplayer.api.http.server.HttpContext import com.futo.platformplayer.api.http.server.HttpContext
import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.nio.file.Files import java.nio.file.Files
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import java.util.zip.GZIPOutputStream import java.util.zip.GZIPOutputStream
class HttpFileHandler(method: String, path: String, private val contentType: String, private val filePath: String, private val closeAfterRequest: Boolean = false): HttpHandler(method, path) { class HttpFileHandler(method: String, path: String, private val contentType: String, private val filePath: String): HttpHandler(method, path) {
override fun handle(httpContext: HttpContext) { override fun handle(httpContext: HttpContext) {
val requestHeaders = httpContext.headers; val requestHeaders = httpContext.headers;
val responseHeaders = this.headers.clone(); val responseHeaders = this.headers.clone();
@@ -30,19 +32,13 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
responseHeaders["Content-Disposition"] = "attachment; filename=\"${file.name.replace("\"", "\\\"")}\"" responseHeaders["Content-Disposition"] = "attachment; filename=\"${file.name.replace("\"", "\\\"")}\""
val acceptEncoding = requestHeaders["Accept-Encoding"]
val shouldGzip = acceptEncoding != null && acceptEncoding.split(',').any { it.trim().equals("gzip", ignoreCase = true) || it == "*" }
if (shouldGzip) {
responseHeaders["Content-Encoding"] = "gzip"
}
val range = requestHeaders["Range"] val range = requestHeaders["Range"]
var start: Long val start: Long
val end: Long val end: Long
if (range != null && range.startsWith("bytes=")) { if (range != null && range.startsWith("bytes=")) {
val parts = range.substring(6).split("-") val parts = range.substring(6).split("-")
start = parts[0].toLong() start = parts[0].toLong()
end = parts.getOrNull(1)?.toLong() ?: (file.length() - 1) end = parts.getOrNull(1)?.toLongOrNull() ?: (file.length() - 1)
responseHeaders["Content-Range"] = "bytes $start-$end/${file.length()}" responseHeaders["Content-Range"] = "bytes $start-$end/${file.length()}"
} else { } else {
start = 0 start = 0
@@ -51,18 +47,19 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
var totalBytesSent = 0 var totalBytesSent = 0
val contentLength = end - start + 1 val contentLength = end - start + 1
Logger.i(TAG, "Sending $contentLength bytes (start: $start, end: $end, shouldGzip: $shouldGzip)")
responseHeaders["Content-Length"] = contentLength.toString() responseHeaders["Content-Length"] = contentLength.toString()
Logger.i(TAG, "Sending $contentLength bytes (start: $start, end: $end)")
file.inputStream().use { inputStream -> file.inputStream().use { inputStream ->
httpContext.respond(if (range == null) 200 else 206, responseHeaders) { responseStream -> httpContext.respond(if (range != null) 206 else 200, responseHeaders) { responseStream ->
try { try {
val buffer = ByteArray(8192) val buffer = ByteArray(8192)
inputStream.skip(start) inputStream.skip(start)
var current = start
val outputStream = if (shouldGzip) GZIPOutputStream(responseStream) else responseStream val outputStream = responseStream
while (true) { while (true) {
val expectedBytesRead = (end - start + 1).coerceAtMost(buffer.size.toLong()); val expectedBytesRead = (end - current + 1).coerceAtMost(buffer.size.toLong());
val bytesRead = inputStream.read(buffer); val bytesRead = inputStream.read(buffer);
if (bytesRead < 0) { if (bytesRead < 0) {
Logger.i(TAG, "End of file reached") Logger.i(TAG, "End of file reached")
@@ -73,27 +70,21 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
outputStream.write(buffer, 0, bytesToSend) outputStream.write(buffer, 0, bytesToSend)
totalBytesSent += bytesToSend totalBytesSent += bytesToSend
Logger.v(TAG, "Sent bytes $start-${start + bytesToSend}, totalBytesSent=$totalBytesSent") Logger.v(TAG, "Sent bytes $current-${current + bytesToSend}, totalBytesSent=$totalBytesSent")
start += bytesToSend.toLong() current += bytesToSend.toLong()
if (start >= end) { if (current >= end) {
Logger.i(TAG, "Expected amount of bytes sent") Logger.i(TAG, "Expected amount of bytes sent")
break break
} }
} }
Logger.i(TAG, "Finished sending file (segment)") Logger.i(TAG, "Finished sending file (segment)")
if (shouldGzip) (outputStream as GZIPOutputStream).finish()
outputStream.flush() outputStream.flush()
} catch (e: Exception) { } catch (e: Exception) {
httpContext.respondCode(500, headers) httpContext.respondCode(500, headers)
} }
} }
if (closeAfterRequest) {
httpContext.keepAlive = false;
}
} }
} }
@@ -15,6 +15,7 @@ abstract class HttpHandler(val method: String, val path: String) {
headers.put(key, value); headers.put(key, value);
return this; return this;
} }
fun withContentType(contentType: String) = withHeader("Content-Type", contentType); fun withContentType(contentType: String) = withHeader("Content-Type", contentType);
fun withTag(tag: String) : HttpHandler { fun withTag(tag: String) : HttpHandler {
@@ -1,11 +1,20 @@
package com.futo.platformplayer.api.http.server.handlers package com.futo.platformplayer.api.http.server.handlers
import android.net.Uri import android.net.Uri
import android.util.Log
import com.futo.platformplayer.api.http.server.HttpContext import com.futo.platformplayer.api.http.server.HttpContext
import com.futo.platformplayer.api.http.server.HttpHeaders import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.parsers.HttpResponseParser
import com.futo.platformplayer.readLine
import java.io.InputStream
import java.io.OutputStream
import java.lang.Exception
import java.net.Socket
import javax.net.ssl.SSLSocketFactory
class HttpProxyHandler(method: String, path: String, val targetUrl: String): HttpHandler(method, path) { class HttpProxyHandler(method: String, path: String, val targetUrl: String, private val useTcp: Boolean = false): HttpHandler(method, path) {
var content: String? = null; var content: String? = null;
var contentType: String? = null; var contentType: String? = null;
@@ -17,10 +26,17 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
private var _injectHost = false; private var _injectHost = false;
private var _injectReferer = false; private var _injectReferer = false;
private val _client = ManagedHttpClient(); private val _client = ManagedHttpClient();
override fun handle(context: HttpContext) { override fun handle(context: HttpContext) {
if (useTcp) {
handleWithTcp(context)
} else {
handleWithOkHttp(context)
}
}
private fun handleWithOkHttp(context: HttpContext) {
val proxyHeaders = HashMap<String, String>(); val proxyHeaders = HashMap<String, String>();
for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) }) for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) })
proxyHeaders[header.key] = header.value; proxyHeaders[header.key] = header.value;
@@ -34,8 +50,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
proxyHeaders.put("Referer", targetUrl); proxyHeaders.put("Referer", targetUrl);
val useMethod = if (method == "inherit") context.method else method; val useMethod = if (method == "inherit") context.method else method;
//Logger.i(TAG, "Proxied Request ${useMethod}: ${targetUrl}"); Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${targetUrl}");
//Logger.i(TAG, "Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n")); Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
val resp = when (useMethod) { val resp = when (useMethod) {
"GET" -> _client.get(targetUrl, proxyHeaders); "GET" -> _client.get(targetUrl, proxyHeaders);
@@ -44,8 +60,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
else -> _client.requestMethod(useMethod, targetUrl, proxyHeaders); else -> _client.requestMethod(useMethod, targetUrl, proxyHeaders);
}; };
//Logger.i(TAG, "Proxied Response [${resp.code}]"); Logger.i(TAG, "Proxied Response [${resp.code}]");
val headersFiltered = HttpHeaders(resp.getHeadersFlat().filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) }); val headersFiltered = HttpHeaders(resp.getHeadersFlat().filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) });
for(newHeader in headers) for(newHeader in headers)
headersFiltered.put(newHeader.key, newHeader.value); headersFiltered.put(newHeader.key, newHeader.value);
@@ -65,6 +81,129 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
} }
} }
private fun handleWithTcp(context: HttpContext) {
if (content != null)
throw NotImplementedError("Content body is not supported")
val proxyHeaders = HashMap<String, String>();
for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) })
proxyHeaders[header.key] = header.value;
for (injectHeader in _injectRequestHeader)
proxyHeaders[injectHeader.first] = injectHeader.second;
val parsed = Uri.parse(targetUrl);
if(_injectHost)
proxyHeaders.put("Host", parsed.host!!);
if(_injectReferer)
proxyHeaders.put("Referer", targetUrl);
val useMethod = if (method == "inherit") context.method else method;
Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${targetUrl}");
Logger.i(TAG, "handleWithTcp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
val requestBuilder = StringBuilder()
requestBuilder.append("$useMethod $targetUrl HTTP/1.1\r\n")
proxyHeaders.forEach { (key, value) -> requestBuilder.append("$key: $value\r\n") }
requestBuilder.append("\r\n")
val port = if (parsed.port == -1) {
when (parsed.scheme) {
"https" -> 443
"http" -> 80
else -> throw Exception("Unhandled scheme")
}
} else {
parsed.port
}
val socket = if (parsed.scheme == "https") {
val sslSocketFactory = SSLSocketFactory.getDefault() as SSLSocketFactory
sslSocketFactory.createSocket(parsed.host, port)
} else {
Socket(parsed.host, port)
}
socket.use { s ->
s.getOutputStream().write(requestBuilder.toString().encodeToByteArray())
val inputStream = s.getInputStream()
val resp = HttpResponseParser(inputStream)
val isChunked = resp.transferEncoding.equals("chunked", ignoreCase = true)
val contentLength = resp.contentLength.toInt()
val headersFiltered = HttpHeaders(resp.headers.filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) });
for(newHeader in headers)
headersFiltered.put(newHeader.key, newHeader.value);
context.respond(resp.statusCode, headersFiltered) { responseStream ->
if (isChunked) {
Logger.i(TAG, "handleWithTcp handleChunkedTransfer");
handleChunkedTransfer(inputStream, responseStream)
} else if (contentLength != -1) {
Logger.i(TAG, "handleWithTcp transferFixedLengthContent $contentLength");
transferFixedLengthContent(inputStream, responseStream, contentLength)
} else {
Logger.i(TAG, "handleWithTcp transferUntilEndOfStream");
transferUntilEndOfStream(inputStream, responseStream)
}
}
}
}
private fun handleChunkedTransfer(inputStream: InputStream, responseStream: OutputStream) {
var line: String?
val buffer = ByteArray(8192)
while (inputStream.readLine().also { line = it } != null) {
val size = line!!.trim().toInt(16)
Logger.i(TAG, "handleWithTcp handleChunkedTransfer chunk size $size")
responseStream.write(line!!.encodeToByteArray())
responseStream.write("\r\n".encodeToByteArray())
if (size == 0) {
inputStream.skip(2)
responseStream.write("\r\n".encodeToByteArray())
break
}
var totalRead = 0
while (totalRead < size) {
val read = inputStream.read(buffer, 0, minOf(buffer.size, size - totalRead))
if (read == -1) break
responseStream.write(buffer, 0, read)
totalRead += read
}
inputStream.skip(2)
responseStream.write("\r\n".encodeToByteArray())
responseStream.flush()
}
}
private fun transferFixedLengthContent(inputStream: InputStream, responseStream: OutputStream, contentLength: Int) {
val buffer = ByteArray(8192)
var totalRead = 0
while (totalRead < contentLength) {
val read = inputStream.read(buffer, 0, minOf(buffer.size, contentLength - totalRead))
if (read == -1) break
responseStream.write(buffer, 0, read)
totalRead += read
}
responseStream.flush()
}
private fun transferUntilEndOfStream(inputStream: InputStream, responseStream: OutputStream) {
val buffer = ByteArray(8192)
var read: Int
while (inputStream.read(buffer).also { read = it } >= 0) {
responseStream.write(buffer, 0, read)
}
responseStream.flush()
}
fun withContent(body: String) : HttpProxyHandler { fun withContent(body: String) : HttpProxyHandler {
this.content = body; this.content = body;
return this; return this;
@@ -92,4 +231,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
_ignoreRequestHeaders.add("referer"); _ignoreRequestHeaders.add("referer");
return this; return this;
} }
companion object {
private const val TAG = "HttpProxyHandler"
}
} }
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media
import androidx.collection.LruCache import androidx.collection.LruCache
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
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
@@ -49,6 +50,7 @@ class CachedPlatformClient : IPlatformClient {
return result; return result;
} }
override fun getContentChapters(url: String): List<IChapter> = _client.getContentChapters(url);
override fun getPlaybackTracker(url: String): IPlaybackTracker? = _client.getPlaybackTracker(url); override fun getPlaybackTracker(url: String): IPlaybackTracker? = _client.getPlaybackTracker(url);
override fun isChannelUrl(url: String): Boolean = _client.isChannelUrl(url); override fun isChannelUrl(url: String): Boolean = _client.isChannelUrl(url);
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
@@ -100,6 +101,8 @@ interface IPlatformClient {
*/ */
fun getContentDetails(url: String): IPlatformContentDetails; fun getContentDetails(url: String): IPlatformContentDetails;
fun getContentChapters(url: String): List<IChapter>;
/** /**
* Gets the playback tracker for a piece of content * Gets the playback tracker for a piece of content
*/ */
@@ -15,7 +15,8 @@ data class PlatformClientCapabilities(
val hasGetSearchCapabilities: Boolean = false, val hasGetSearchCapabilities: Boolean = false,
val hasGetChannelCapabilities: Boolean = false, val hasGetChannelCapabilities: Boolean = false,
val hasGetLiveEvents: Boolean = false, val hasGetLiveEvents: Boolean = false,
val hasGetLiveChatWindow: Boolean = false val hasGetLiveChatWindow: Boolean = false,
val hasGetContentChapters: Boolean = false
) { ) {
} }
@@ -10,7 +10,7 @@ import com.futo.platformplayer.getOrThrow
* A link to a channel, often with its own name and thumbnail * A link to a channel, often with its own name and thumbnail
*/ */
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
class PlatformAuthorLink { open class PlatformAuthorLink {
val id: PlatformID; val id: PlatformID;
val name: String; val name: String;
val url: String; val url: String;
@@ -28,6 +28,9 @@ class PlatformAuthorLink {
companion object { companion object {
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorLink { fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorLink {
if(value.has("membershipUrl"))
return PlatformAuthorMembershipLink.fromV8(config, value);
val context = "AuthorLink" val context = "AuthorLink"
return PlatformAuthorLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)), return PlatformAuthorLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)),
value.getOrThrow(config ,"name", context), value.getOrThrow(config ,"name", context),
@@ -0,0 +1,33 @@
package com.futo.platformplayer.api.media.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
/**
* A link to a channel, often with its own name and thumbnail
*/
@kotlinx.serialization.Serializable
class PlatformAuthorMembershipLink: PlatformAuthorLink {
val membershipUrl: String?;
constructor(id: PlatformID, name: String, url: String, thumbnail: String? = null, subscribers: Long? = null, membershipUrl: String? = null): super(id, name, url, thumbnail, subscribers)
{
this.membershipUrl = membershipUrl;
}
companion object {
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorMembershipLink {
val context = "AuthorMembershipLink"
return PlatformAuthorMembershipLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)),
value.getOrThrow(config ,"name", context),
value.getOrThrow(config, "url", context),
value.getOrDefault<String>(config, "thumbnail", context, null),
if(value.has("subscribers")) value.getOrThrow(config,"subscribers", context) else null,
if(value.has("membershipUrl")) value.getOrThrow(config, "membershipUrl", context) else null
);
}
}
}
@@ -29,6 +29,7 @@ class ResultCapabilities(
const val TYPE_LIVE = "LIVE"; const val TYPE_LIVE = "LIVE";
const val TYPE_POSTS = "POSTS"; const val TYPE_POSTS = "POSTS";
const val TYPE_MIXED = "MIXED"; const val TYPE_MIXED = "MIXED";
const val TYPE_SUBSCRIPTIONS = "SUBSCRIPTIONS";
const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL"; const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL";
@@ -20,6 +20,10 @@ class Thumbnails {
fun getLQThumbnail() : String? { fun getLQThumbnail() : String? {
return sources.firstOrNull()?.url; return sources.firstOrNull()?.url;
} }
fun getMinimumThumbnail(quality: Int): String? {
return sources.firstOrNull { it.quality >= quality }?.url ?: getHQThumbnail();
}
fun hasMultiple() = sources.size > 1; fun hasMultiple() = sources.size > 1;
@@ -0,0 +1,31 @@
package com.futo.platformplayer.api.media.models.chapters
import com.futo.platformplayer.api.media.exceptions.UnknownPlatformException
import com.futo.platformplayer.api.media.models.contents.ContentType
interface IChapter {
val name: String;
val type: ChapterType;
val timeStart: Double;
val timeEnd: Double;
}
enum class ChapterType(val value: Int) {
NORMAL(0),
SKIPPABLE(5),
SKIP(6);
companion object {
fun fromInt(value: Int): ChapterType
{
val result = ChapterType.values().firstOrNull { it.value == value };
if(result == null)
throw UnknownPlatformException(value.toString());
return result;
}
}
}
@@ -13,6 +13,7 @@ enum class ContentType(val value: Int) {
NESTED_VIDEO(11), NESTED_VIDEO(11),
LOCKED(70),
PLACEHOLDER(90), PLACEHOLDER(90),
DEFERRED(91); DEFERRED(91);
@@ -0,0 +1,13 @@
package com.futo.platformplayer.api.media.models.locked
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
interface IPlatformLockedContent: IPlatformContent {
val lockContentType: ContentType;
val lockDescription: String?;
val unlockUrl: String?;
val contentName: String?;
val contentThumbnails: Thumbnails;
}
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.video
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.locked.IPlatformLockedContent
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
import com.futo.platformplayer.api.media.models.post.IPlatformPost import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.serializers.PlatformContentSerializer import com.futo.platformplayer.serializers.PlatformContentSerializer
@@ -18,6 +19,7 @@ interface SerializedPlatformContent: IPlatformContent {
ContentType.MEDIA -> SerializedPlatformVideo.fromVideo(content as IPlatformVideo); ContentType.MEDIA -> SerializedPlatformVideo.fromVideo(content as IPlatformVideo);
ContentType.NESTED_VIDEO -> SerializedPlatformNestedContent.fromNested(content as IPlatformNestedContent); ContentType.NESTED_VIDEO -> SerializedPlatformNestedContent.fromNested(content as IPlatformNestedContent);
ContentType.POST -> SerializedPlatformPost.fromPost(content as IPlatformPost); ContentType.POST -> SerializedPlatformPost.fromPost(content as IPlatformPost);
ContentType.LOCKED -> SerializedPlatformLockedContent.fromLocked(content as IPlatformLockedContent);
else -> throw NotImplementedError("Content type ${content.contentType} not implemented"); else -> throw NotImplementedError("Content type ${content.contentType} not implemented");
}; };
} }
@@ -0,0 +1,62 @@
package com.futo.platformplayer.api.media.models.video
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.states.StatePlatform
import com.futo.polycentric.core.combineHashCodes
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.OffsetDateTime
@kotlinx.serialization.Serializable
open class SerializedPlatformLockedContent(
override val id: PlatformID,
override val name: String,
override val author: PlatformAuthorLink,
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override val datetime: OffsetDateTime?,
override val url: String,
override val shareUrl: String,
override val lockContentType: ContentType,
override val contentName: String?,
override val lockDescription: String? = null,
override val unlockUrl: String? = null,
override val contentThumbnails: Thumbnails
) : IPlatformLockedContent, SerializedPlatformContent {
final override val contentType: ContentType get() = ContentType.LOCKED;
override fun toJson() : String {
return Json.encodeToString(this);
}
override fun fromJson(str : String) : SerializedPlatformLockedContent {
return Serializer.json.decodeFromString<SerializedPlatformLockedContent>(str);
}
override fun fromJsonArray(str : String) : Array<SerializedPlatformContent> {
return Serializer.json.decodeFromString<Array<SerializedPlatformContent>>(str);
}
companion object {
fun fromLocked(content: IPlatformLockedContent) : SerializedPlatformLockedContent {
return SerializedPlatformLockedContent(
content.id,
content.name,
content.author,
content.datetime,
content.url,
content.shareUrl,
content.lockContentType,
content.contentName,
content.lockDescription,
content.unlockUrl,
content.contentThumbnails
);
}
}
}
@@ -15,6 +15,7 @@ import com.futo.platformplayer.api.media.PlatformClientCapabilities
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
@@ -91,6 +92,19 @@ open class JSClient : IPlatformClient {
val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true
val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true
fun getSubscriptionRateLimit(): Int? {
val pluginRateLimit = config.subscriptionRateLimit;
val settingsRateLimit = descriptor.appSettings.rateLimit.getSubRateLimit();
if(settingsRateLimit > 0) {
if(pluginRateLimit != null)
return settingsRateLimit.coerceAtMost(pluginRateLimit);
else
return settingsRateLimit;
}
else
return pluginRateLimit;
}
val onDisabled = Event1<JSClient>(); val onDisabled = Event1<JSClient>();
val onCaptchaException = Event2<JSClient, ScriptCaptchaRequiredException>(); val onCaptchaException = Event2<JSClient, ScriptCaptchaRequiredException>();
@@ -181,6 +195,7 @@ open class JSClient : IPlatformClient {
hasGetChannelCapabilities = plugin.executeBoolean("!!source.getChannelCapabilities") ?: false, hasGetChannelCapabilities = plugin.executeBoolean("!!source.getChannelCapabilities") ?: false,
hasGetLiveEvents = plugin.executeBoolean("!!source.getLiveEvents") ?: false, hasGetLiveEvents = plugin.executeBoolean("!!source.getLiveEvents") ?: false,
hasGetLiveChatWindow = plugin.executeBoolean("!!source.getLiveChatWindow") ?: false, hasGetLiveChatWindow = plugin.executeBoolean("!!source.getLiveChatWindow") ?: false,
hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false,
); );
try { try {
@@ -414,6 +429,17 @@ open class JSClient : IPlatformClient {
plugin.executeTyped("source.getContentDetails(${Json.encodeToString(url)})")); plugin.executeTyped("source.getContentDetails(${Json.encodeToString(url)})"));
} }
@JSOptional //getContentChapters = function(url, initialData)
@JSDocs(15, "source.getContentChapters(url)", "Gets chapters for content details")
@JSDocsParameter("url", "A content url (this platform)")
override fun getContentChapters(url: String): List<IChapter> = isBusyWith {
if(!capabilities.hasGetContentChapters)
return@isBusyWith listOf();
ensureEnabled();
return@isBusyWith JSChapter.fromV8(config,
plugin.executeTyped("source.getContentChapters(${Json.encodeToString(url)})"));
}
@JSOptional @JSOptional
@JSDocs(15, "source.getPlaybackTracker(url)", "Gets a playback tracker for given content url") @JSDocs(15, "source.getPlaybackTracker(url)", "Gets a playback tracker for given content url")
@JSDocsParameter("url", "A content url (this platform)") @JSDocsParameter("url", "A content url (this platform)")
@@ -558,7 +584,7 @@ open class JSClient : IPlatformClient {
if(it.containsKey(claimType)) { if(it.containsKey(claimType)) {
val templates = it[claimType]; val templates = it[claimType];
if(templates != null) if(templates != null)
for(value in values.keys.sortedBy { it }) { for(value in values.keys.sortedBy { if(it == config.primaryClaimFieldType) Int.MIN_VALUE else it }) {
if(templates.containsKey(value)) { if(templates.containsKey(value)) {
return templates[value]!!.replace("{{CLAIMVALUE}}", values[value]!!); return templates[value]!!.replace("{{CLAIMVALUE}}", values[value]!!);
} }
@@ -568,6 +594,23 @@ open class JSClient : IPlatformClient {
}; };
} }
fun resolveChannelUrlsByClaimTemplates(claimType: Int, values: Map<Int, String>): List<String> {
val urls = arrayListOf<String>();
channelClaimTemplates?.let {
if(it.containsKey(claimType)) {
val templates = it[claimType];
if(templates != null)
for(value in values.keys.sortedBy { it }) {
if(templates.containsKey(value)) {
urls.add(templates[value]!!.replace("{{CLAIMVALUE}}", values[value]!!));
}
}
}
};
return urls;
}
private fun <T> isBusyWith(handle: ()->T): T { private fun <T> isBusyWith(handle: ()->T): T {
try { try {
@@ -1,20 +1,17 @@
package com.futo.platformplayer.api.media.platforms.js package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.encryption.EncryptionProvider
import com.futo.platformplayer.logging.Logger
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) { data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) {
override fun toString(): String { override fun toString(): String {
return "(headers: '$headers', cookieString: '$cookieMap')"; return "(headers: '$headers', cookieString: '$cookieMap')";
} }
fun toEncrypted(): String{ fun toEncrypted(): String{
return EncryptionProvider.instance.encrypt(serialize()); return SourceEncrypted.fromDecrypted { serialize() }.toJson();
} }
private fun serialize(): String { private fun serialize(): String {
@@ -25,20 +22,10 @@ data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? =
val TAG = "SourceAuth"; val TAG = "SourceAuth";
fun fromEncrypted(encrypted: String?): SourceAuth? { fun fromEncrypted(encrypted: String?): SourceAuth? {
if(encrypted == null) return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
return null;
val decrypted = EncryptionProvider.instance.decrypt(encrypted);
try {
return deserialize(decrypted);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to deserialize authentication", ex);
return null;
}
} }
fun deserialize(str: String): SourceAuth { private fun deserialize(str: String): SourceAuth {
val data = Json.decodeFromString<SerializedAuth>(str); val data = Json.decodeFromString<SerializedAuth>(str);
return SourceAuth(data.cookieMap, data.headers); return SourceAuth(data.cookieMap, data.headers);
} }
@@ -1,7 +1,5 @@
package com.futo.platformplayer.api.media.platforms.js package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.encryption.EncryptionProvider
import com.futo.platformplayer.logging.Logger
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@@ -13,7 +11,7 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
} }
fun toEncrypted(): String{ fun toEncrypted(): String{
return EncryptionProvider.instance.encrypt(serialize()); return SourceEncrypted.fromDecrypted { serialize() }.toJson();
} }
private fun serialize(): String { private fun serialize(): String {
@@ -21,20 +19,10 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
} }
companion object { companion object {
val TAG = "SourceAuth"; val TAG = "SourceCaptchaData";
fun fromEncrypted(encrypted: String?): SourceCaptchaData? { fun fromEncrypted(encrypted: String?): SourceCaptchaData? {
if(encrypted == null) return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
return null;
val decrypted = EncryptionProvider.instance.decrypt(encrypted);
try {
return deserialize(decrypted);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to deserialize authentication", ex);
return null;
}
} }
fun deserialize(str: String): SourceCaptchaData { fun deserialize(str: String): SourceCaptchaData {
@@ -0,0 +1,59 @@
package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.encryption.GEncryptionProvider
import com.futo.platformplayer.encryption.GEncryptionProviderV0
import com.futo.platformplayer.logging.Logger
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.lang.Exception
@Serializable
data class SourceEncrypted(
val encrypted: String,
val version: Int = GEncryptionProvider.version
) {
fun toJson(): String {
return Json.encodeToString(this);
}
companion object {
fun fromDecrypted(serializer: () -> String): SourceEncrypted {
return SourceEncrypted(GEncryptionProvider.instance.encrypt(serializer()));
}
fun <T> decryptEncrypted(encrypted: String?, deserializer: (decrypted: String) -> T): T? {
if(encrypted == null)
return null;
try {
val encryptedSourceAuth = Json.decodeFromString<SourceEncrypted>(encrypted)
if (encryptedSourceAuth.version != GEncryptionProvider.version) {
throw Exception("Invalid encryption version.");
}
val decrypted = GEncryptionProvider.instance.decrypt(encryptedSourceAuth.encrypted);
try {
return deserializer(decrypted);
} catch(ex: Throwable) {
Logger.e(SourceAuth.TAG, "Failed to deserialize SourceEncrypted<T>", ex);
return null;
}
} catch (e: Throwable) {
//Try to fall back to old mechanism, remove this eventually
if (!encrypted.contains("version")) {
val decrypted = GEncryptionProviderV0.instance.decrypt(encrypted);
try {
return deserializer(decrypted);
} catch (ex: Throwable) {
Logger.e(SourceAuth.TAG, "Failed to deserialize SourceEncrypted<T>", ex);
return null;
}
} else {
return null;
}
}
}
}
}
@@ -41,10 +41,12 @@ class SourcePluginConfig(
val constants: HashMap<String, String> = hashMapOf(), val constants: HashMap<String, String> = hashMapOf(),
//TODO: These should be vals...but prob for serialization reasons cannot be changed. //TODO: These should be vals...but prob for serialization reasons cannot be changed.
var platformUrl: String? = null,
var subscriptionRateLimit: Int? = null, var subscriptionRateLimit: Int? = null,
var enableInSearch: Boolean = true, var enableInSearch: Boolean = true,
var enableInHome: Boolean = true, var enableInHome: Boolean = true,
var supportedClaimTypes: List<Int> = listOf() var supportedClaimTypes: List<Int> = listOf(),
var primaryClaimFieldType: Int? = null
) : IV8PluginConfig { ) : IV8PluginConfig {
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl); val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
@@ -142,7 +144,10 @@ class SourcePluginConfig(
val description: String, val description: String,
val type: String, val type: String,
val default: String? = null, val default: String? = null,
val variable: String? = null val variable: String? = null,
val dependency: String? = null,
val warningDialog: String? = null,
val options: List<String>? = null
) { ) {
@kotlinx.serialization.Transient @kotlinx.serialization.Transient
val variableOrName: String get() = variable ?: name; val variableOrName: String get() = variable ?: name;
@@ -1,7 +1,9 @@
package com.futo.platformplayer.api.media.platforms.js package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
import com.futo.platformplayer.views.fields.DropdownFieldOptions
import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormField import com.futo.platformplayer.views.fields.FormField
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -66,18 +68,41 @@ class SourcePluginDescriptor {
@Serializable @Serializable
class AppPluginSettings { class AppPluginSettings {
@FormField("Visibility", "group", "Enable where this plugin's 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
class TabEnabled { class TabEnabled {
@FormField("Home", FieldForm.TOGGLE, "Show content in home tab", 1) @FormField(R.string.home, FieldForm.TOGGLE, R.string.show_content_in_home_tab, 1)
var enableHome: Boolean? = null; var enableHome: Boolean? = null;
@FormField("Search", FieldForm.TOGGLE, "Show content in search results", 2) @FormField(R.string.search, FieldForm.TOGGLE, R.string.show_content_in_search_results, 2)
var enableSearch: Boolean? = null; var enableSearch: Boolean? = null;
} }
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 3)
var rateLimit = RateLimit();
@Serializable
class RateLimit {
@FormField(R.string.subscriptions, FieldForm.DROPDOWN, R.string.ratelimit_sub_setting_description, 1)
@DropdownFieldOptions("Plugin defined", "25", "50", "75", "100", "125", "150", "200")
var rateLimitSubs: Int = 0;
fun getSubRateLimit(): Int {
return when(rateLimitSubs) {
0 -> -1
1 -> 25
2 -> 50
3 -> 75
4 -> 100
5 -> 125
6 -> 150
7 -> 200
else -> -1
}
}
}
fun loadDefaults(config: SourcePluginConfig) { fun loadDefaults(config: SourcePluginConfig) {
@@ -6,10 +6,13 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
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.SourceAuth 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.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.matchesDomain import com.futo.platformplayer.matchesDomain
class JSHttpClient : ManagedHttpClient { class JSHttpClient : ManagedHttpClient {
private val _jsClient: JSClient?; private val _jsClient: JSClient?;
private val _jsConfig: SourcePluginConfig?;
private val _auth: SourceAuth?; private val _auth: SourceAuth?;
private val _captcha: SourceCaptchaData?; private val _captcha: SourceCaptchaData?;
@@ -20,8 +23,9 @@ class JSHttpClient : ManagedHttpClient {
private var _currentCookieMap: HashMap<String, HashMap<String, String>>; private var _currentCookieMap: HashMap<String, HashMap<String, String>>;
constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null) : super() { constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, config: SourcePluginConfig? = null) : super() {
_jsClient = jsClient; _jsClient = jsClient;
_jsConfig = config;
_auth = auth; _auth = auth;
_captcha = captcha; _captcha = captcha;
@@ -87,7 +91,11 @@ class JSHttpClient : ManagedHttpClient {
} }
} }
_jsClient?.validateUrlOrThrow(request.url.toString()); if(_jsClient != null)
_jsClient?.validateUrlOrThrow(request.url.toString());
else if (_jsConfig != null && !_jsConfig.isUrlAllowed(request.url.toString()))
throw ScriptImplementationException(_jsConfig, "Attempted to access non-whitelisted url: ${request.url.toString()}\nAdd it to your config");
return newBuilder?.let { it.build() } ?: request; return newBuilder?.let { it.build() } ?: request;
} }
@@ -23,6 +23,7 @@ interface IJSContent: IPlatformContent {
ContentType.POST -> JSPost(config, obj); ContentType.POST -> JSPost(config, obj);
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj); ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
ContentType.PLAYLIST -> JSPlaylist(config, obj); ContentType.PLAYLIST -> JSPlaylist(config, obj);
ContentType.LOCKED -> JSLockedContent(config, obj);
else -> throw NotImplementedError("Unknown content type ${type}"); else -> throw NotImplementedError("Unknown content type ${type}");
} }
} }
@@ -0,0 +1,45 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueArray
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.chapters.ChapterType
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
class JSChapter : IChapter {
override val name: String;
override val type: ChapterType;
override val timeStart: Double;
override val timeEnd: Double;
constructor(name: String, timeStart: Double, timeEnd: Double, type: ChapterType = ChapterType.NORMAL) {
this.name = name;
this.timeStart = timeStart;
this.timeEnd = timeEnd;
this.type = type;
}
companion object {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject): IChapter {
val context = "Chapter";
val name = obj.getOrThrow<String>(config,"name", context);
val type = ChapterType.fromInt(obj.getOrDefault<Int>(config, "type", context, ChapterType.NORMAL.value) ?: ChapterType.NORMAL.value);
val timeStart = obj.getOrThrow<Double>(config, "timeStart", context);
val timeEnd = obj.getOrThrow<Double>(config, "timeEnd", context);
return JSChapter(name, timeStart, timeEnd, type);
}
fun fromV8(config: IV8PluginConfig, arr: V8ValueArray): List<IChapter> {
return arr.keys.mapNotNull {
val obj = arr.get<V8ValueObject>(it);
return@mapNotNull fromV8(config, obj);
};
}
}
}
@@ -0,0 +1,36 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.states.StatePlatform
//TODO: Refactor into video-only
class JSLockedContent: IPlatformLockedContent, JSContent {
override val contentType: ContentType get() = ContentType.LOCKED;
override val lockContentType: ContentType get() = ContentType.MEDIA;
override val lockDescription: String?;
override val unlockUrl: String?;
override val contentName: String?;
override val contentThumbnails: Thumbnails;
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformLockedContent";
this.contentName = obj.getOrDefault(config, "contentName", contextName, null);
this.contentThumbnails = obj.getOrDefault<V8ValueObject?>(config, "contentThumbnails", contextName, null)?.let {
return@let Thumbnails.fromV8(config, it);
} ?: Thumbnails();
lockDescription = obj.getOrDefault(config, "lockDescription", contextName, null);
unlockUrl = obj.getOrDefault(config, "unlockUrl", contextName, null);
}
}
@@ -59,8 +59,6 @@ abstract class JSPager<T> : IPager<T> {
} }
override fun getResults(): List<T> { override fun getResults(): List<T> {
warnIfMainThread("JSPager.getResults");
val previousResults = _lastResults?.let { val previousResults = _lastResults?.let {
if(!_resultChanged) if(!_resultChanged)
return@let it; return@let it;
@@ -70,6 +68,7 @@ abstract class JSPager<T> : IPager<T> {
if(previousResults != null) if(previousResults != null)
return previousResults; return previousResults;
warnIfMainThread("JSPager.getResults");
val items = pager.getOrThrow<V8ValueArray>(config, "results", "JSPager"); val items = pager.getOrThrow<V8ValueArray>(config, "results", "JSPager");
val newResults = items.toArray() val newResults = items.toArray()
.map { convertResult(it as V8ValueObject) } .map { convertResult(it as V8ValueObject) }
@@ -52,7 +52,7 @@ class DedupContentPager : IPager<IPlatformContent>, IAsyncPager<IPlatformContent
val sameItems = results.filter { isSameItem(result, it) }; val sameItems = results.filter { isSameItem(result, it) };
val platformItemMap = sameItems.groupBy { it.id.pluginId }.mapValues { (_, items) -> items.first() } val platformItemMap = sameItems.groupBy { it.id.pluginId }.mapValues { (_, items) -> items.first() }
val bestPlatform = _preferredPlatform.map { it.lowercase() }.firstOrNull { platformItemMap.containsKey(it) } val bestPlatform = _preferredPlatform.map { it.lowercase() }.firstOrNull { platformItemMap.containsKey(it) }
val bestItem = platformItemMap[bestPlatform] ?: sameItems.first() val bestItem = platformItemMap[bestPlatform] ?: sameItems.firstOrNull();
resultsToRemove.addAll(sameItems.filter { it != bestItem }); resultsToRemove.addAll(sameItems.filter { it != bestItem });
} }
@@ -75,7 +75,12 @@ class DedupContentPager : IPager<IPlatformContent>, IAsyncPager<IPlatformContent
return toReturn; return toReturn;
} }
private fun isSameItem(item: IPlatformContent, item2: IPlatformContent): Boolean { private fun isSameItem(item: IPlatformContent, item2: IPlatformContent): Boolean {
return item.name == item2.name && (item.datetime == null || item2.datetime == null || abs(item.datetime!!.getDiffDays(item2.datetime!!)) < 2); //return item == item2;
val daysAgo = Math.abs(item.datetime?.getNowDiffDays() ?: return false);
val maxDelta = Math.max(2, (daysAgo / 1.5).toInt()); //TODO: Better scaling delta
val isSame = item.name.equals(item2.name, true) && (item.datetime == null || item2.datetime == null || abs(item.datetime!!.getDiffDays(item2.datetime!!)) < maxDelta);
return isSame;
} }
private fun calculateHash(item: IPlatformContent): Int { private fun calculateHash(item: IPlatformContent): Int {
return combineHashCodes(listOf(item.name.hashCode(), item.datetime?.hashCode())); return combineHashCodes(listOf(item.name.hashCode(), item.datetime?.hashCode()));
@@ -8,6 +8,7 @@ import java.util.stream.IntStream
*/ */
class MultiChronoContentPager : MultiPager<IPlatformContent> { class MultiChronoContentPager : MultiPager<IPlatformContent> {
constructor(pagers : Array<IPager<IPlatformContent>>, allowFailure: Boolean = false, pageSize: Int = 9) : super(pagers.map { it }.toList(), allowFailure, pageSize) {} constructor(pagers : Array<IPager<IPlatformContent>>, allowFailure: Boolean = false, pageSize: Int = 9) : super(pagers.map { it }.toList(), allowFailure, pageSize) {}
constructor(pagers : List<IPager<IPlatformContent>>, allowFailure: Boolean = false, pageSize: Int = 9) : super(pagers, allowFailure, pageSize) {}
@Synchronized @Synchronized
override fun selectItemIndex(options: Array<SelectionOption<IPlatformContent>>): Int { override fun selectItemIndex(options: Array<SelectionOption<IPlatformContent>>): Int {
@@ -0,0 +1,33 @@
package com.futo.platformplayer.api.media.structures
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import kotlinx.coroutines.runBlocking
import java.util.stream.IntStream
/**
* A Content AsyncMultiPager that returns results based on a specified distribution
* Unlike its non-async counterpart, this one uses parallel nextPage requests
*/
class MultiChronoContentParallelPager : MultiParallelPager<IPlatformContent> {
constructor(pagers: List<IPager<IPlatformContent>>) : super(pagers)
@Synchronized
override fun selectItemIndex(options: Array<SelectionOption<IPlatformContent>>): Int {
if(options.size == 0)
return -1;
var bestIndex = 0;
val allResults = runBlocking { options.map { Pair(it, it.item?.await()) } };
for(i in IntStream.range(1, options.size)) {
val best = allResults[bestIndex].second;
val cur = allResults[i].second ?: continue;
if(best?.datetime == null || (cur.datetime != null && cur.datetime!! > best.datetime!!))
bestIndex = i;
}
return bestIndex;
}
}
@@ -66,25 +66,25 @@ abstract class MultiRefreshPager<T>: IRefreshPager<T>, IPager<T> {
override fun getResults(): List<T> = synchronized(_pagersReusable){ _currentPager.getResults() }; override fun getResults(): List<T> = synchronized(_pagersReusable){ _currentPager.getResults() };
private fun updatePager(pagerToAdd: IPager<T>?, toReplacePager: IPager<T>? = null, error: Throwable? = null) { private fun updatePager(pagerToAdd: IPager<T>?, toReplacePager: IPager<T>? = null, error: Throwable? = null) {
if(pagerToAdd == null) {
if(toReplacePager != null && toReplacePager is PlaceholderPager && error != null) {
val pluginId = toReplacePager.placeholderFactory.invoke().id?.pluginId ?: "";
_pagersReusable.add((PlaceholderPager(5) {
return@PlaceholderPager PlatformContentPlaceholder(pluginId, error)
} as IPager<T>).asReusable());
_currentPager = recreatePager(getCurrentSubPagers());
if(_currentPager is MultiParallelPager<*>)
runBlocking { (_currentPager as MultiParallelPager).initialize(); };
else if(_currentPager is MultiPager<*>)
(_currentPager as MultiPager).initialize()
onPagerChanged.emit(_currentPager);
}
return;
}
synchronized(_pagersReusable) { synchronized(_pagersReusable) {
if(pagerToAdd == null) {
if(toReplacePager != null && toReplacePager is PlaceholderPager && error != null) {
val pluginId = toReplacePager.placeholderFactory.invoke().id?.pluginId ?: "";
_pagersReusable.add((PlaceholderPager(5) {
return@PlaceholderPager PlatformContentPlaceholder(pluginId, error)
} as IPager<T>).asReusable());
_currentPager = recreatePager(getCurrentSubPagers());
if(_currentPager is MultiParallelPager<*>)
runBlocking { (_currentPager as MultiParallelPager).initialize(); };
else if(_currentPager is MultiPager<*>)
(_currentPager as MultiPager).initialize()
onPagerChanged.emit(_currentPager);
}
return;
}
Logger.i("RefreshMultiDistributionContentPager", "Received new pager for RefreshPager") Logger.i("RefreshMultiDistributionContentPager", "Received new pager for RefreshPager")
_pagersReusable.add(pagerToAdd.asReusable()); _pagersReusable.add(pagerToAdd.asReusable());
@@ -0,0 +1,19 @@
package com.futo.platformplayer.api.media.structures
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import kotlinx.coroutines.Deferred
/**
* A RefreshMultiPager that simply returns all respective pagers in equal distribution, optionally inserting PlaceholderPager results as provided for their respective promised pagers
* (Eg. Pager A is completed, Pager [B,C,D] are promised/deferred. placeholderPagers [1,2,3] will map B=>1, C=>2, D=>3 until promised pagers are completed)
* Uses wrapped MultiDistributionContentAsyncPager for inidivual pagers.
*/
class RefreshChronoContentPager(pagers: List<IPager<IPlatformContent>>, pendingPagers: List<Deferred<IPager<IPlatformContent>?>>, placeholderPagers: List<IPager<IPlatformContent>>? = null)
: MultiRefreshPager<IPlatformContent>(pagers, pendingPagers, placeholderPagers) {
override fun recreatePager(pagers: List<IPager<IPlatformContent>>): IPager<IPlatformContent> {
return MultiChronoContentPager(pagers);
//return MultiChronoContentParallelPager(pagers);
//return MultiDistributionContentPager(pagers.associateWith { 1f });
}
}
@@ -43,6 +43,7 @@ class SingleAsyncItemPager<T> {
if (_currentResultPos >= _requestedPageItems.size) { if (_currentResultPos >= _requestedPageItems.size) {
val startPos = fillDeferredUntil(_currentResultPos); val startPos = fillDeferredUntil(_currentResultPos);
if(!_pager.hasMorePages()) { if(!_pager.hasMorePages()) {
Logger.i("SingleAsyncItemPager", "end of async page reached");
completeRemainder { it?.complete(null) }; completeRemainder { it?.complete(null) };
} }
if(_isRequesting) if(_isRequesting)
@@ -4,33 +4,34 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import androidx.concurrent.futures.CallbackToFutureAdapter import android.graphics.Bitmap
import androidx.concurrent.futures.ResolvableFuture import android.graphics.drawable.Drawable
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ListenableWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.Settings
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.models.video.IPlatformVideo
import com.futo.platformplayer.getNowDiffSeconds import com.futo.platformplayer.getNowDiffSeconds
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.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StateNotifications
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.adapters.viewholders.TabViewHolder import com.futo.platformplayer.toHumanNowDiffString
import com.google.common.util.concurrent.ListenableFuture import com.futo.platformplayer.toHumanNowDiffStringMinDay
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.time.OffsetDateTime import java.time.OffsetDateTime
class BackgroundWorker(private val appContext: Context, workerParams: WorkerParameters) : class BackgroundWorker(private val appContext: Context, private val workerParams: WorkerParameters) :
CoroutineWorker(appContext, workerParams) { CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
if(StateApp.instance.isMainActive) { if(StateApp.instance.isMainActive && !inputData.getBoolean("bypassMainCheck", false)) {
Logger.i("BackgroundWorker", "CANCELLED"); Logger.i("BackgroundWorker", "CANCELLED");
return Result.success(); return Result.success();
} }
@@ -45,8 +46,10 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
this.setSound(null, null); this.setSound(null, null);
}; };
notificationManager.createNotificationChannel(notificationChannel); notificationManager.createNotificationChannel(notificationChannel);
val contentChannel = StateNotifications.instance.contentNotifChannel
notificationManager.createNotificationChannel(contentChannel);
try { try {
doSubscriptionUpdating(notificationManager, notificationChannel); doSubscriptionUpdating(notificationManager, notificationChannel, contentChannel);
} }
catch(ex: Throwable) { catch(ex: Throwable) {
exception = ex; exception = ex;
@@ -68,13 +71,13 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
} }
suspend fun doSubscriptionUpdating(manager: NotificationManager, notificationChannel: NotificationChannel) { suspend fun doSubscriptionUpdating(manager: NotificationManager, backgroundChannel: NotificationChannel, contentChannel: NotificationChannel) {
val notif = NotificationCompat.Builder(appContext, notificationChannel.id) val notif = NotificationCompat.Builder(appContext, backgroundChannel.id)
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground) .setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
.setContentTitle("Grayjay") .setContentTitle("Grayjay")
.setContentText("Updating subscriptions...") .setContentText("Updating subscriptions...")
.setSilent(true) .setSilent(true)
.setChannelId(notificationChannel.id) .setChannelId(backgroundChannel.id)
.setProgress(1, 0, true); .setProgress(1, 0, true);
manager.notify(12, notif.build()); manager.notify(12, notif.build());
@@ -83,8 +86,12 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
val newSubChanges = hashSetOf<Subscription>(); val newSubChanges = hashSetOf<Subscription>();
val newItems = mutableListOf<IPlatformContent>(); val newItems = mutableListOf<IPlatformContent>();
val now = OffsetDateTime.now();
val threeDays = now.minusDays(4);
val contentNotifs = mutableListOf<Pair<Subscription, IPlatformContent>>();
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(true, false,this, { progress, total -> val results = StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(true, false,this, { progress, total ->
Logger.i("BackgroundWorker", "SUBSCRIPTION PROGRESS: ${progress}/${total}"); Logger.i("BackgroundWorker", "SUBSCRIPTION PROGRESS: ${progress}/${total}");
synchronized(manager) { synchronized(manager) {
@@ -97,21 +104,51 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
} }
}, { sub, content -> }, { sub, content ->
synchronized(newSubChanges) { synchronized(newSubChanges) {
if(!newSubChanges.contains(sub)) if(!newSubChanges.contains(sub)) {
newSubChanges.add(sub); newSubChanges.add(sub);
if(sub.doNotifications) {
if(content.datetime != null) {
if(content.datetime!! <= now.plusMinutes(StateNotifications.instance.plannedWarningMinutesEarly) && content.datetime!! > threeDays)
contentNotifs.add(Pair(sub, content));
else if(content.datetime!! > now.plusMinutes(StateNotifications.instance.plannedWarningMinutesEarly) && Settings.instance.notifications.plannedContentNotification)
StateNotifications.instance.scheduleContentNotification(applicationContext, content);
}
}
}
newItems.add(content); newItems.add(content);
} }
}); });
//Only for testing notifications
val testNotifs = 0;
if(contentNotifs.size == 0 && testNotifs > 0) {
results.first.getResults().filter { it is IPlatformVideo && it.datetime?.let { it < now } == true }
.take(testNotifs).forEach {
contentNotifs.add(Pair(StateSubscriptions.instance.getSubscriptions().first(), it));
}
}
} }
manager.cancel(12); manager.cancel(12);
if(newItems.size > 0) if(contentNotifs.size > 0) {
try {
val items = contentNotifs.take(5).toList()
for(i in items.indices) {
val contentNotif = items.get(i);
StateNotifications.instance.notifyNewContentWithThumbnail(appContext, manager, contentChannel, 13 + i, contentNotif.second);
}
}
catch(ex: Throwable) {
Logger.e("BackgroundWorker", "Failed to create notif", ex);
}
}
/*
manager.notify(13, NotificationCompat.Builder(appContext, notificationChannel.id) manager.notify(13, NotificationCompat.Builder(appContext, notificationChannel.id)
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground) .setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
.setContentTitle("Grayjay") .setContentTitle("Grayjay")
.setContentText("${newItems.size} new content from ${newSubChanges.size} creators") .setContentText("${newItems.size} new content from ${newSubChanges.size} creators")
.setSilent(true) .setSilent(true)
.setChannelId(notificationChannel.id).build()); .setChannelId(notificationChannel.id).build());*/
} }
} }
@@ -1,37 +0,0 @@
package com.futo.platformplayer.builders
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import java.io.PrintWriter
import java.io.StringWriter
class HlsBuilder {
companion object{
fun generateOnDemandHLS(vidSource: IVideoSource, vidUrl: String, audioSource: IAudioSource?, audioUrl: String?, subtitleSource: ISubtitleSource?, subtitleUrl: String?): String {
val hlsBuilder = StringWriter()
PrintWriter(hlsBuilder).use { writer ->
writer.println("#EXTM3U")
// Audio
if (audioSource != null && audioUrl != null) {
val audioFormat = audioSource.container.substringAfter("/")
writer.println("#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${audioUrl.replace("&", "&amp;")}\",FORMAT=\"$audioFormat\"")
}
// Subtitles
if (subtitleSource != null && subtitleUrl != null) {
val subtitleFormat = subtitleSource.format ?: "text/vtt"
writer.println("#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${subtitleUrl.replace("&", "&amp;")}\",FORMAT=\"$subtitleFormat\"")
}
// Video
val videoFormat = vidSource.container.substringAfter("/")
writer.println("#EXT-X-STREAM-INF:BANDWIDTH=100000,CODECS=\"${vidSource.codec}\",RESOLUTION=${vidSource.width}x${vidSource.height}${if (audioSource != null) ",AUDIO=\"audio\"" else ""}${if (subtitleSource != null) ",SUBTITLES=\"subs\"" else ""},FORMAT=\"$videoFormat\"")
writer.println(vidUrl.replace("&", "&amp;"))
}
return hlsBuilder.toString()
}
}
}
@@ -12,19 +12,60 @@ import com.futo.platformplayer.serializers.PlatformContentSerializer
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.v2.ManagedStore
import com.futo.platformplayer.toSafeFileName import com.futo.platformplayer.toSafeFileName
import com.futo.polycentric.core.toUrl
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.OffsetDateTime
import kotlin.streams.toList
import kotlin.system.measureTimeMillis
class ChannelContentCache { class ChannelContentCache {
private val _targetCacheSize = 3000;
val _channelCacheDir = FragmentedStorage.getOrCreateDirectory("channelCache"); val _channelCacheDir = FragmentedStorage.getOrCreateDirectory("channelCache");
val _channelContents = HashMap(_channelCacheDir.listFiles() val _channelContents: HashMap<String, ManagedStore<SerializedPlatformContent>>;
.filter { it.isDirectory } init {
.associate { Pair(it.name, FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, it.name, PlatformContentSerializer()) val allFiles = _channelCacheDir.listFiles() ?: arrayOf();
.withoutBackup() val initializeTime = measureTimeMillis {
.load()) }); _channelContents = HashMap(allFiles
.filter { it.isDirectory }
.parallelStream().map {
Pair(it.name, FragmentedStorage.storeJson(_channelCacheDir, it.name, PlatformContentSerializer())
.withoutBackup()
.load())
}.toList().associate { it })
}
val minDays = OffsetDateTime.now().minusDays(10);
val totalItems = _channelContents.map { it.value.count() }.sum();
val toTrim = totalItems - _targetCacheSize;
val trimmed: Int;
if(toTrim > 0) {
val redundantContent = _channelContents.flatMap { it.value.getItems().filter { it.datetime != null && it.datetime!!.isBefore(minDays) }.drop(9) }
.sortedBy { it.datetime!! }.take(toTrim);
for(content in redundantContent)
uncacheContent(content);
trimmed = redundantContent.size;
}
else trimmed = 0;
Logger.i(TAG, "ChannelContentCache time: ${initializeTime}ms channels: ${allFiles.size}, videos: ${totalItems}, trimmed: ${trimmed}, total: ${totalItems - trimmed}");
}
fun clear() {
synchronized(_channelContents) {
for(channel in _channelContents)
for(content in channel.value.getItems())
uncacheContent(content);
}
}
fun clearToday() {
val yesterday = OffsetDateTime.now().minusDays(1);
synchronized(_channelContents) {
for(channel in _channelContents)
for(content in channel.value.getItems().filter { it.datetime?.isAfter(yesterday) == true })
uncacheContent(content);
}
}
fun getChannelCachePager(channelUrl: String): PlatformContentPager { fun getChannelCachePager(channelUrl: String): PlatformContentPager {
val validID = channelUrl.toSafeFileName(); val validID = channelUrl.toSafeFileName();
@@ -38,7 +79,9 @@ class ChannelContentCache {
return PlatformContentPager(items, Math.min(150, items.size)); return PlatformContentPager(items, Math.min(150, items.size));
} }
fun getSubscriptionCachePager(): DedupContentPager { fun getSubscriptionCachePager(): DedupContentPager {
Logger.i(TAG, "Subscriptions CachePager get subscriptions");
val subs = StateSubscriptions.instance.getSubscriptions(); val subs = StateSubscriptions.instance.getSubscriptions();
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))
@@ -46,6 +89,7 @@ class ChannelContentCache {
else else
return@map otherUrls; return@map otherUrls;
}.flatten().distinct(); }.flatten().distinct();
Logger.i(TAG, "Subscriptions CachePager compiling");
val validSubIds = allUrls.map { it.toSafeFileName() }.toHashSet(); val validSubIds = allUrls.map { it.toSafeFileName() }.toHashSet();
val validStores = _channelContents val validStores = _channelContents
@@ -55,10 +99,14 @@ class ChannelContentCache {
val items = validStores.flatMap { it.getItems() } val items = validStores.flatMap { it.getItems() }
.sortedByDescending { it.datetime }; .sortedByDescending { it.datetime };
return DedupContentPager(PlatformContentPager(items, Math.min(150, items.size)), StatePlatform.instance.getEnabledClients().map { it.id }); return DedupContentPager(PlatformContentPager(items, Math.min(30, items.size)), StatePlatform.instance.getEnabledClients().map { it.id });
} }
fun cacheVideos(contents: List<IPlatformContent>): List<IPlatformContent> { fun uncacheContent(content: SerializedPlatformContent) {
val store = getContentStore(content);
store?.delete(content);
}
fun cacheContents(contents: List<IPlatformContent>): List<IPlatformContent> {
return contents.filter { cacheContent(it) }; return contents.filter { cacheContent(it) };
} }
fun cacheContent(content: IPlatformContent, doUpdate: Boolean = false): Boolean { fun cacheContent(content: IPlatformContent, doUpdate: Boolean = false): Boolean {
@@ -66,14 +114,14 @@ class ChannelContentCache {
return false; return false;
val channelId = content.author.url.toSafeFileName(); val channelId = content.author.url.toSafeFileName();
val store = synchronized(_channelContents) { val store = getContentStore(channelId).let {
var channelStore = _channelContents.get(channelId); if(it == null) {
if(channelStore == null) { Logger.i(TAG, "New Channel Cache for channel ${content.author.name}");
Logger.i(TAG, "New Subscription Cache for channel ${content.author.name}"); val store = FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, channelId, PlatformContentSerializer()).load();
channelStore = FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, channelId, PlatformContentSerializer()).load(); _channelContents.put(channelId, store);
_channelContents.put(channelId, channelStore); return@let store;
} }
return@synchronized channelStore; else return@let it;
} }
val serialized = SerializedPlatformContent.fromContent(content); val serialized = SerializedPlatformContent.fromContent(content);
val existing = store.findItems { it.url == content.url }; val existing = store.findItems { it.url == content.url };
@@ -88,6 +136,17 @@ class ChannelContentCache {
return existing.isEmpty(); return existing.isEmpty();
} }
private fun getContentStore(content: IPlatformContent): ManagedStore<SerializedPlatformContent>? {
val channelId = content.author.url.toSafeFileName();
return getContentStore(channelId);
}
private fun getContentStore(channelId: String): ManagedStore<SerializedPlatformContent>? {
return synchronized(_channelContents) {
var channelStore = _channelContents.get(channelId);
return@synchronized channelStore;
}
}
companion object { companion object {
private val TAG = "ChannelCache"; private val TAG = "ChannelCache";
@@ -95,10 +154,11 @@ class ChannelContentCache {
private var _instance: ChannelContentCache? = null; private var _instance: ChannelContentCache? = null;
val instance: ChannelContentCache get() { val instance: ChannelContentCache get() {
synchronized(_lock) { synchronized(_lock) {
if(_instance == null) if(_instance == null) {
_instance = ChannelContentCache(); _instance = ChannelContentCache();
return _instance!!; }
} }
return _instance!!;
} }
fun cachePagerResults(scope: CoroutineScope, pager: IPager<IPlatformContent>, onNewCacheHit: ((IPlatformContent)->Unit)? = null): IPager<IPlatformContent> { fun cachePagerResults(scope: CoroutineScope, pager: IPager<IPlatformContent>, onNewCacheHit: ((IPlatformContent)->Unit)? = null): IPager<IPlatformContent> {
@@ -114,7 +174,7 @@ class ChannelContentCache {
Logger.i(TAG, "Caching ${results.size} subscription initial results [${pager.hashCode()}]"); Logger.i(TAG, "Caching ${results.size} subscription initial results [${pager.hashCode()}]");
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
val newCacheItems = instance.cacheVideos(results); val newCacheItems = instance.cacheContents(results);
if(onNewCacheItem != null) if(onNewCacheItem != null)
newCacheItems.forEach { onNewCacheItem!!(it) } newCacheItems.forEach { onNewCacheItem!!(it) }
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -134,7 +194,7 @@ class ChannelContentCache {
Logger.i(TAG, "Caching ${results.size} subscription results"); Logger.i(TAG, "Caching ${results.size} subscription results");
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
val newCacheItems = instance.cacheVideos(results); val newCacheItems = instance.cacheContents(results);
if(onNewCacheItem != null) if(onNewCacheItem != null)
newCacheItems.forEach { onNewCacheItem!!(it) } newCacheItems.forEach { onNewCacheItem!!(it) }
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -4,6 +4,7 @@ import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.os.Looper import android.os.Looper
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.http.server.ManagedHttpServer import com.futo.platformplayer.api.http.server.ManagedHttpServer
import com.futo.platformplayer.api.http.server.handlers.* import com.futo.platformplayer.api.http.server.handlers.*
import com.futo.platformplayer.api.media.models.streams.sources.* import com.futo.platformplayer.api.media.models.streams.sources.*
@@ -12,8 +13,10 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.builders.DashBuilder import com.futo.platformplayer.builders.DashBuilder
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.net.InetAddress import java.net.InetAddress
@@ -44,6 +47,7 @@ class StateCasting {
val onActiveDevicePlayChanged = Event1<Boolean>(); val onActiveDevicePlayChanged = Event1<Boolean>();
val onActiveDeviceTimeChanged = Event1<Double>(); val onActiveDeviceTimeChanged = Event1<Double>();
var activeDevice: CastingDevice? = null; var activeDevice: CastingDevice? = null;
private val _client = ManagedHttpClient();
val isCasting: Boolean get() = activeDevice != null; val isCasting: Boolean get() = activeDevice != null;
@@ -352,16 +356,33 @@ class StateCasting {
} }
} }
} else { } else {
if (videoSource is IVideoUrlSource) { if (videoSource is IVideoUrlSource)
ad.loadVideo("BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble()); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble());
} else if (audioSource is IAudioUrlSource) { else if (audioSource is IAudioUrlSource)
ad.loadVideo("BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble()); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble());
} else if (videoSource is LocalVideoSource) { else if(videoSource is IHLSManifestSource) {
if (ad is ChromecastCastingDevice && video.isLive) {
castHlsIndirect(video, videoSource.url, resumePosition);
} else {
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble());
}
} else if(audioSource is IHLSManifestAudioSource) {
if (ad is ChromecastCastingDevice && video.isLive) {
castHlsIndirect(video, audioSource.url, resumePosition);
} else {
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble());
}
} else if (videoSource is LocalVideoSource)
castLocalVideo(video, videoSource, resumePosition); castLocalVideo(video, videoSource, resumePosition);
} else if (audioSource is LocalAudioSource) { else if (audioSource is LocalAudioSource)
castLocalAudio(video, audioSource, resumePosition); castLocalAudio(video, audioSource, resumePosition);
} else { else {
throw Exception("Unhandled source type videoSource=$videoSource audioSource=$audioSource subtitleSource=$subtitleSource"); var str = listOf(
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null,
if(subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null
).filterNotNull().joinToString(", ");
throw UnsupportedCastException(str);
} }
} }
@@ -395,7 +416,7 @@ class StateCasting {
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List<String> { private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress}:${_castServer.port}"; val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val videoPath = "/video-${id}" val videoPath = "/video-${id}"
val videoUrl = url + videoPath; val videoUrl = url + videoPath;
@@ -414,7 +435,7 @@ class StateCasting {
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double) : List<String> { private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress}:${_castServer.port}"; val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val audioPath = "/audio-${id}" val audioPath = "/audio-${id}"
val audioUrl = url + audioPath; val audioUrl = url + audioPath;
@@ -434,7 +455,7 @@ class StateCasting {
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double) : List<String> { private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress}:${_castServer.port}"; val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val dashPath = "/dash-${id}" val dashPath = "/dash-${id}"
@@ -476,7 +497,7 @@ class StateCasting {
} }
if (subtitleSource != null) { if (subtitleSource != null) {
_castServer.addHandler( _castServer.addHandler(
HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath, true) HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath)
.withHeader("Access-Control-Allow-Origin", "*"), true .withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast"); ).withTag("cast");
_castServer.addHandler( _castServer.addHandler(
@@ -495,7 +516,7 @@ class StateCasting {
private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> { private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress}:${_castServer.port}"; val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val subtitlePath = "/subtitle-${id}"; val subtitlePath = "/subtitle-${id}";
@@ -537,11 +558,132 @@ class StateCasting {
return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: ""); return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "");
} }
private fun castHlsIndirect(video: IPlatformVideoDetails, sourceUrl: String, resumePosition: Double): List<String> {
_castServer.removeAllHandlers("castHlsIndirectMaster")
val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
val id = UUID.randomUUID();
val hlsPath = "/hls-${id}"
val hlsUrl = url + hlsPath
Logger.i(TAG, "HLS url: $hlsUrl");
_castServer.addHandler(HttpFuntionHandler("GET", hlsPath) { masterContext ->
_castServer.removeAllHandlers("castHlsIndirectVariant")
val headers = masterContext.headers.clone()
headers["Content-Type"] = "application/vnd.apple.mpegurl";
val masterPlaylist = HLS.downloadAndParseMasterPlaylist(_client, sourceUrl)
val newVariantPlaylistRefs = arrayListOf<HLS.VariantPlaylistReference>()
val newMediaRenditions = arrayListOf<HLS.MediaRendition>()
val newMasterPlaylist = HLS.MasterPlaylist(newVariantPlaylistRefs, newMediaRenditions, masterPlaylist.sessionDataList, masterPlaylist.independentSegments)
for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) {
val playlistId = UUID.randomUUID();
val newPlaylistPath = "/hls-playlist-${playlistId}"
val newPlaylistUrl = url + newPlaylistPath;
_castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, variantPlaylistRef.url)
val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castHlsIndirectVariant")
newVariantPlaylistRefs.add(HLS.VariantPlaylistReference(
newPlaylistUrl,
variantPlaylistRef.streamInfo
))
}
for (mediaRendition in masterPlaylist.mediaRenditions) {
val playlistId = UUID.randomUUID();
val newPlaylistPath = "/hls-playlist-${playlistId}"
val newPlaylistUrl = url + newPlaylistPath;
if (mediaRendition.uri != null) {
_castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, mediaRendition.uri)
val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castHlsIndirectVariant")
}
newMediaRenditions.add(HLS.MediaRendition(
mediaRendition.type,
newPlaylistUrl,
mediaRendition.groupID,
mediaRendition.language,
mediaRendition.name,
mediaRendition.isDefault,
mediaRendition.isAutoSelect,
mediaRendition.isForced
))
}
masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8());
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castHlsIndirectMaster")
Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath).");
ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble());
return listOf(hlsUrl);
}
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, proxySegments: Boolean = true): HLS.VariantPlaylist {
val newSegments = arrayListOf<HLS.Segment>()
if (proxySegments) {
variantPlaylist.segments.forEachIndexed { index, segment ->
val sequenceNumber = variantPlaylist.mediaSequence + index.toLong()
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber))
}
} else {
newSegments.addAll(variantPlaylist.segments)
}
return HLS.VariantPlaylist(
variantPlaylist.version,
variantPlaylist.targetDuration,
variantPlaylist.mediaSequence,
variantPlaylist.discontinuitySequence,
variantPlaylist.programDateTime,
newSegments
)
}
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment {
val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}"
val newSegmentUrl = url + newSegmentPath;
if (_castServer.getHandler("GET", newSegmentPath) == null) {
_castServer.addHandler(
HttpProxyHandler("GET", newSegmentPath, segment.uri, true)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant")
}
return HLS.Segment(
segment.duration,
newSegmentUrl
)
}
private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> { private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val proxyStreams = ad !is FastCastCastingDevice; val proxyStreams = ad !is FastCastCastingDevice;
val url = "http://${ad.localAddress}:${_castServer.port}"; val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
Logger.i(TAG, "DASH url: $url"); Logger.i(TAG, "DASH url: $url");
val id = UUID.randomUUID(); val id = UUID.randomUUID();
@@ -24,6 +24,7 @@ import com.google.gson.JsonArray
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.InvocationTargetException
import java.util.UUID import java.util.UUID
import kotlin.reflect.jvm.jvmErasure import kotlin.reflect.jvm.jvmErasure
@@ -185,7 +186,11 @@ class DeveloperEndpoints(private val context: Context) {
val config = context.readContentJson<SourcePluginConfig>() val config = context.readContentJson<SourcePluginConfig>()
try { try {
_testPluginVariables.clear(); _testPluginVariables.clear();
_testPlugin = V8Plugin(StateApp.instance.context, config);
val client = JSHttpClient(null, null, null, config);
val clientAuth = JSHttpClient(null, null, null, config);
_testPlugin = V8Plugin(StateApp.instance.context, config, null, client, clientAuth);
context.respondJson(200, testPluginOrThrow.getPackageVariables()); context.respondJson(200, testPluginOrThrow.getPackageVariables());
} }
catch(ex: Throwable) { catch(ex: Throwable) {
@@ -235,7 +240,7 @@ class DeveloperEndpoints(private val context: Context) {
} }
LoginActivity.showLogin(StateApp.instance.context, config) { LoginActivity.showLogin(StateApp.instance.context, config) {
_testPluginVariables.clear(); _testPluginVariables.clear();
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null), JSHttpClient(null, it)); _testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
}; };
context.respondCode(200, "Login started"); context.respondCode(200, "Login started");
@@ -287,7 +292,6 @@ class DeveloperEndpoints(private val context: Context) {
@HttpPOST("/plugin/remoteCall") @HttpPOST("/plugin/remoteCall")
fun pluginRemoteCall(context: HttpContext) { fun pluginRemoteCall(context: HttpContext) {
try { try {
val parameters = context.readContentString();
val objId = context.query.get("id") val objId = context.query.get("id")
val method = context.query.get("method") val method = context.query.get("method")
@@ -299,16 +303,24 @@ class DeveloperEndpoints(private val context: Context) {
context.respondCode(400, "Missing method"); context.respondCode(400, "Missing method");
return; return;
} }
if(method != "isLoggedIn")
Logger.i(TAG, "Remote Call [${objId}].${method}(...)");
val parameters = context.readContentString();
val remoteObj = getRemoteObject(objId); val remoteObj = getRemoteObject(objId);
val paras = JsonParser.parseString(parameters); val paras = JsonParser.parseString(parameters);
if(!paras.isJsonArray) if(!paras.isJsonArray)
throw IllegalArgumentException("Expected json array as body"); throw IllegalArgumentException("Expected json array as body");
if(method != "isLoggedIn")
Logger.i(TAG, "Remote Call [${objId}].${method}(...)");
val callResult = remoteObj.call(method, paras as JsonArray); val callResult = remoteObj.call(method, paras as JsonArray);
val json = wrapRemoteResult(callResult, false); val json = wrapRemoteResult(callResult, false);
context.respondCode(200, json, "application/json"); context.respondCode(200, json, "application/json");
} }
catch(invocation: InvocationTargetException) {
val innerException = invocation.targetException;
Logger.e("DeveloperEndpoints", innerException.message, innerException);
context.respondCode(500, innerException::class.simpleName + ":" + innerException.message ?: "", "text/plain")
}
catch(ilEx: IllegalArgumentException) { catch(ilEx: IllegalArgumentException) {
if(ilEx.message?.contains("does not exist") ?: false) { if(ilEx.message?.contains("does not exist") ?: false) {
context.respondCode(400, ilEx.message ?: "", "text/plain"); context.respondCode(400, ilEx.message ?: "", "text/plain");
@@ -95,6 +95,8 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
_buttonUpdate.visibility = Button.GONE; _buttonUpdate.visibility = Button.GONE;
setCancelable(false); setCancelable(false);
setCanceledOnTouchOutside(false); setCanceledOnTouchOutside(false);
Logger.i(TAG, "Keep screen on set update")
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
_text.text = context.resources.getText(R.string.downloading_update); _text.text = context.resources.getText(R.string.downloading_update);
@@ -178,6 +180,7 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
} }
} finally { } finally {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
Logger.i(TAG, "Keep screen on unset install")
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} }
} }
@@ -6,6 +6,7 @@ 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
@@ -19,6 +20,7 @@ import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComm
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
@@ -84,13 +86,20 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
return@setOnClickListener; return@setOnClickListener;
} }
if (_editComment.text.isBlank()) {
UIDialogs.toast(context, "Comment should not be blank.");
return@setOnClickListener;
}
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, null, ref)
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
processHandle.fullyBackfillServers() Logger.i(TAG, "Started backfill");
processHandle.fullyBackfillServersAnnounceExceptions()
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers.", e); Logger.e(TAG, "Failed to backfill servers.", e);
} }
@@ -134,6 +134,8 @@ class ImportDialog : AlertDialog {
setCancelable(false); setCancelable(false);
setCanceledOnTouchOutside(false); setCanceledOnTouchOutside(false);
Logger.i(TAG, "Keep screen on set import")
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
_updateSpinner.drawable?.assume<Animatable>()?.start(); _updateSpinner.drawable?.assume<Animatable>()?.start();
@@ -201,6 +203,7 @@ class ImportDialog : AlertDialog {
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to update import UI.", e) Logger.e(TAG, "Failed to update import UI.", e)
} finally { } finally {
Logger.i(TAG, "Keep screen on unset update")
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} }
} }
@@ -144,6 +144,7 @@ class MigrateDialog : AlertDialog {
setCancelable(false); setCancelable(false);
setCanceledOnTouchOutside(false); setCanceledOnTouchOutside(false);
Logger.i(TAG, "Keep screen on set restore")
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
_updateSpinner.drawable?.assume<Animatable>()?.start(); _updateSpinner.drawable?.assume<Animatable>()?.start();
@@ -214,6 +215,7 @@ class MigrateDialog : AlertDialog {
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to update import UI.", e) Logger.e(TAG, "Failed to update import UI.", e)
} finally { } finally {
Logger.i(TAG, "Keep screen on unset restore")
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} }
} }
@@ -1,13 +1,18 @@
package com.futo.platformplayer.downloads package com.futo.platformplayer.downloads
import android.content.Context
import android.net.Uri
import android.os.Environment import android.os.Environment
import androidx.documentfile.provider.DocumentFile
import com.arthenica.ffmpegkit.* import com.arthenica.ffmpegkit.*
import com.futo.platformplayer.api.media.models.streams.sources.* import com.futo.platformplayer.api.media.models.streams.sources.*
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toHumanBitrate import com.futo.platformplayer.toHumanBitrate
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.io.* import java.io.*
import java.util.UUID
import java.util.concurrent.CancellationException import java.util.concurrent.CancellationException
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@@ -43,7 +48,7 @@ class VideoExport {
this.subtitleSource = subtitleSource; this.subtitleSource = subtitleSource;
} }
suspend fun export(onProgress: ((Double) -> Unit)? = null): File = coroutineScope { suspend fun export(context: Context, onProgress: ((Double) -> Unit)? = null): DocumentFile = coroutineScope {
if(isCancelled) throw CancellationException("Export got cancelled"); if(isCancelled) throw CancellationException("Export got cancelled");
val v = videoSource; val v = videoSource;
@@ -55,34 +60,47 @@ class VideoExport {
if (a != null) sourceCount++; if (a != null) sourceCount++;
if (s != null) sourceCount++; if (s != null) sourceCount++;
var outputFile: File? = null; val outputFile: DocumentFile?;
val moviesRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES); val downloadRoot = StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set");
val musicRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC);
val moviesGrayjay = File(moviesRoot, "Grayjay");
val musicGrayjay = File(musicRoot, "Grayjay");
if(!moviesGrayjay.exists())
moviesGrayjay.mkdirs();
if(!musicGrayjay.exists())
musicGrayjay.mkdirs();
if (sourceCount > 1) { if (sourceCount > 1) {
val outputFileName = toSafeFileName(videoLocal.name) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container); val outputFileName = toSafeFileName(videoLocal.name) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container);
val f = File(moviesGrayjay, outputFileName); val f = downloadRoot.createFile("video/mp4", outputFileName)
?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Combining video and audio through FFMPEG."); Logger.i(TAG, "Combining video and audio through FFMPEG.");
combine(a?.filePath, v?.filePath, s?.filePath, f.absolutePath, videoLocal.duration.toDouble()) { progress -> onProgress?.invoke(progress) }; val tempFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4");
try {
combine(a?.filePath, v?.filePath, s?.filePath, tempFile.absolutePath, videoLocal.duration.toDouble()) { progress -> onProgress?.invoke(progress) };
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
copy(tempFile.absolutePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
} finally {
tempFile.delete();
}
outputFile = f; outputFile = f;
} else if (v != null) { } else if (v != null) {
val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.videoContainerToExtension(v.container); val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.videoContainerToExtension(v.container);
val f = File(moviesGrayjay, outputFileName); val f = downloadRoot.createFile(v.container, outputFileName)
?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying video."); Logger.i(TAG, "Copying video.");
copy(v.filePath, f.absolutePath) { progress -> onProgress?.invoke(progress) };
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
copy(v.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
outputFile = f; outputFile = f;
} else if (a != null) { } else if (a != null) {
val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.audioContainerToExtension(a.container); val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.audioContainerToExtension(a.container);
val f = File(musicGrayjay, outputFileName); val f = downloadRoot.createFile(a.container, outputFileName)
?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying audio."); Logger.i(TAG, "Copying audio.");
copy(a.filePath, f.absolutePath) { progress -> onProgress?.invoke(progress) };
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
copy(a.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
outputFile = f; outputFile = f;
} else { } else {
throw Exception("Cannot export when no audio or video source is set."); throw Exception("Cannot export when no audio or video source is set.");
@@ -179,10 +197,9 @@ class VideoExport {
} }
} }
private suspend fun copy(fromPath: String, toPath: String, bufferSize: Int = 8192, onProgress: ((Double) -> Unit)? = null) { private suspend fun copy(fromPath: String, outputStream: OutputStream, bufferSize: Int = 8192, onProgress: ((Double) -> Unit)? = null) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
var inputStream: FileInputStream? = null var inputStream: FileInputStream? = null
var outputStream: FileOutputStream? = null
try { try {
val srcFile = File(fromPath) val srcFile = File(fromPath)
@@ -190,17 +207,7 @@ class VideoExport {
throw IOException("Source file not found.") throw IOException("Source file not found.")
} }
val dstFile = File(toPath)
val parentDir = dstFile.parentFile ?: throw IOException("Non existent parent dir.")
if (!parentDir.exists()) {
if (!parentDir.mkdirs()) {
throw IOException("Failed to create destination directory.")
}
}
inputStream = FileInputStream(srcFile) inputStream = FileInputStream(srcFile)
outputStream = FileOutputStream(dstFile)
val buffer = ByteArray(bufferSize) val buffer = ByteArray(bufferSize)
val totalBytes = srcFile.length() val totalBytes = srcFile.length()
@@ -221,7 +228,6 @@ class VideoExport {
throw IOException("Error occurred while copying file: ${e.message}", e) throw IOException("Error occurred while copying file: ${e.message}", e)
} finally { } finally {
inputStream?.close() inputStream?.close()
outputStream?.close()
} }
} }
} }
@@ -0,0 +1,8 @@
package com.futo.platformplayer.encryption
class GEncryptionProvider {
companion object {
val instance: GEncryptionProviderV1 = GEncryptionProviderV1.instance;
val version = 1;
}
}
@@ -8,9 +8,8 @@ import java.security.KeyStore
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.KeyGenerator import javax.crypto.KeyGenerator
import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
class EncryptionProvider { class GEncryptionProviderV0 {
private val _keyStore: KeyStore; private val _keyStore: KeyStore;
private val secretKey: Key? get() = _keyStore.getKey(KEY_ALIAS, null); private val secretKey: Key? get() = _keyStore.getKey(KEY_ALIAS, null);
@@ -25,45 +24,43 @@ class EncryptionProvider {
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(false) .setRandomizedEncryptionRequired(false)
.build()); .build());
keyGenerator.generateKey(); keyGenerator.generateKey();
} }
} }
fun encrypt(decrypted: String, password: String? = null): String { fun encrypt(decrypted: String): String {
val encodedBytes = encrypt(decrypted.toByteArray(), password); val encodedBytes = encrypt(decrypted.toByteArray());
val encrypted = Base64.encodeToString(encodedBytes, Base64.DEFAULT); val encrypted = Base64.encodeToString(encodedBytes, Base64.DEFAULT);
return encrypted; return encrypted;
} }
fun encrypt(decrypted: ByteArray, password: String? = null): ByteArray { fun encrypt(decrypted: ByteArray): ByteArray {
val c: Cipher = Cipher.getInstance(AES_MODE); val c: Cipher = Cipher.getInstance(AES_MODE);
val keyToUse = if(password == null) secretKey else SecretKeySpec(password.toByteArray(), "AES"); c.init(Cipher.ENCRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
c.init(Cipher.ENCRYPT_MODE, keyToUse, GCMParameterSpec(128, FIXED_IV));
val encodedBytes: ByteArray = c.doFinal(decrypted); val encodedBytes: ByteArray = c.doFinal(decrypted);
return encodedBytes; return encodedBytes;
} }
fun decrypt(encrypted: String, password: String? = null): String { fun decrypt(encrypted: String): String {
val c = Cipher.getInstance(AES_MODE); val c = Cipher.getInstance(AES_MODE);
val keyToUse = if(password == null) secretKey else SecretKeySpec(password.toByteArray(), "AES"); c.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
c.init(Cipher.DECRYPT_MODE, keyToUse, GCMParameterSpec(128, FIXED_IV));
val decrypted = String(c.doFinal(Base64.decode(encrypted, Base64.DEFAULT))); val decrypted = String(c.doFinal(Base64.decode(encrypted, Base64.DEFAULT)));
return decrypted; return decrypted;
} }
fun decrypt(encrypted: ByteArray, password: String? = null): ByteArray { fun decrypt(encrypted: ByteArray): ByteArray {
val c = Cipher.getInstance(AES_MODE); val c = Cipher.getInstance(AES_MODE);
val keyToUse = if(password == null) secretKey else SecretKeySpec(password.toByteArray(), "AES"); c.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
c.init(Cipher.DECRYPT_MODE, keyToUse, GCMParameterSpec(128, FIXED_IV));
return c.doFinal(encrypted); return c.doFinal(encrypted);
} }
companion object { companion object {
val instance: EncryptionProvider = EncryptionProvider(); val instance: GEncryptionProviderV0 = GEncryptionProviderV0();
private val FIXED_IV = byteArrayOf(12, 43, 127, 2, 99, 22, 6, 78, 24, 53, 8, 101); private val FIXED_IV = byteArrayOf(12, 43, 127, 2, 99, 22, 6, 78, 24, 53, 8, 101);
private const val AndroidKeyStore = "AndroidKeyStore"; private const val AndroidKeyStore = "AndroidKeyStore";
private const val KEY_ALIAS = "FUTOMedia_Key"; private const val KEY_ALIAS = "FUTOMedia_Key";
private const val AES_MODE = "AES/GCM/NoPadding"; private const val AES_MODE = "AES/GCM/NoPadding";
private val TAG = "EncryptionProvider"; private const val TAG_LENGTH = 128
private val TAG = "GEncryptionProviderV0";
} }
} }
@@ -0,0 +1,76 @@
package com.futo.platformplayer.encryption
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.Key
import java.security.KeyStore
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.spec.GCMParameterSpec
class GEncryptionProviderV1 {
private val _keyStore: KeyStore;
private val secretKey: Key? get() = _keyStore.getKey(KEY_ALIAS, null);
constructor() {
_keyStore = KeyStore.getInstance(AndroidKeyStore);
_keyStore.load(null);
if (!_keyStore.containsAlias(KEY_ALIAS)) {
val keyGenerator: KeyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, AndroidKeyStore)
keyGenerator.init(KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(false)
.build());
keyGenerator.generateKey();
}
}
fun encrypt(decrypted: String): String {
val encrypted = encrypt(decrypted.toByteArray());
val encoded = Base64.encodeToString(encrypted, Base64.DEFAULT);
return encoded;
}
fun encrypt(decrypted: ByteArray): ByteArray {
val ivBytes = generateIv()
val c: Cipher = Cipher.getInstance(AES_MODE);
c.init(Cipher.ENCRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, ivBytes));
val encodedBytes: ByteArray = c.doFinal(decrypted);
return ivBytes + encodedBytes;
}
fun decrypt(data: String): String {
val bytes = Base64.decode(data, Base64.DEFAULT)
return String(decrypt(bytes));
}
fun decrypt(bytes: ByteArray): ByteArray {
val encrypted = bytes.sliceArray(IntRange(IV_SIZE, bytes.size - 1))
val ivBytes = bytes.sliceArray(IntRange(0, IV_SIZE - 1))
val c = Cipher.getInstance(AES_MODE);
c.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, ivBytes));
return c.doFinal(encrypted);
}
private fun generateIv(): ByteArray {
val r = SecureRandom()
val ivBytes = ByteArray(IV_SIZE)
r.nextBytes(ivBytes)
return ivBytes
}
companion object {
val instance: GEncryptionProviderV1 = GEncryptionProviderV1();
private const val AndroidKeyStore = "AndroidKeyStore";
private const val KEY_ALIAS = "FUTOMedia_Key";
private const val AES_MODE = "AES/GCM/NoPadding";
private const val IV_SIZE = 12;
private const val TAG_LENGTH = 128
private val TAG = "GEncryptionProviderV1";
}
}
@@ -0,0 +1,8 @@
package com.futo.platformplayer.encryption
class GPasswordEncryptionProvider {
companion object {
val version = 1;
val instance = GPasswordEncryptionProviderV1.instance;
}
}
@@ -0,0 +1,45 @@
package com.futo.platformplayer.encryption
import android.util.Base64
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
class GPasswordEncryptionProviderV0 {
private val _key: SecretKeySpec;
constructor(password: String) {
_key = SecretKeySpec(password.toByteArray(), "AES");
}
fun encrypt(decrypted: String): String {
val encodedBytes = encrypt(decrypted.toByteArray());
val encrypted = Base64.encodeToString(encodedBytes, Base64.DEFAULT);
return encrypted;
}
fun encrypt(decrypted: ByteArray): ByteArray {
val c: Cipher = Cipher.getInstance(AES_MODE);
c.init(Cipher.ENCRYPT_MODE, _key, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
val encodedBytes: ByteArray = c.doFinal(decrypted);
return encodedBytes;
}
fun decrypt(encrypted: String): String {
val c = Cipher.getInstance(AES_MODE);
c.init(Cipher.DECRYPT_MODE, _key, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
val decrypted = String(c.doFinal(Base64.decode(encrypted, Base64.DEFAULT)));
return decrypted;
}
fun decrypt(encrypted: ByteArray): ByteArray {
val c = Cipher.getInstance(AES_MODE);
c.init(Cipher.DECRYPT_MODE, _key, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
return c.doFinal(encrypted);
}
companion object {
private val FIXED_IV = byteArrayOf(12, 43, 127, 2, 99, 22, 6, 78, 24, 53, 8, 101);
private const val TAG_LENGTH = 128
private const val AES_MODE = "AES/GCM/NoPadding";
private val TAG = "GPasswordEncryptionProviderV0";
}
}
@@ -0,0 +1,75 @@
package com.futo.platformplayer.encryption
import android.util.Base64
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
class GPasswordEncryptionProviderV1 {
fun encrypt(decrypted: String, password: String): String {
val encrypted = encrypt(decrypted.toByteArray(), password);
val encoded = Base64.encodeToString(encrypted, Base64.DEFAULT);
return encoded;
}
fun encrypt(decrypted: ByteArray, password: String): ByteArray {
val saltBytes = generateSalt()
val ivBytes = generateIv()
val c: Cipher = Cipher.getInstance(AES_MODE);
val key = deriveKeyFromPassword(password, saltBytes)
c.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(TAG_LENGTH, ivBytes));
val encodedBytes: ByteArray = c.doFinal(decrypted);
return saltBytes + ivBytes + encodedBytes;
}
fun decrypt(data: String, password: String): String {
val bytes = Base64.decode(data, Base64.DEFAULT)
return String(decrypt(bytes, password));
}
fun decrypt(bytes: ByteArray, password: String): ByteArray {
val encrypted = bytes.sliceArray(IntRange(SALT_SIZE + IV_SIZE, bytes.size - 1))
val ivBytes = bytes.sliceArray(IntRange(SALT_SIZE, SALT_SIZE + IV_SIZE - 1))
val saltBytes = bytes.sliceArray(IntRange(0, SALT_SIZE - 1))
val key = deriveKeyFromPassword(password, saltBytes)
val c = Cipher.getInstance(AES_MODE);
c.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_LENGTH, ivBytes));
return c.doFinal(encrypted);
}
private fun deriveKeyFromPassword(password: String, salt: ByteArray): SecretKeySpec {
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
val spec = PBEKeySpec(password.toCharArray(), salt, ITERATION_COUNT, KEY_LENGTH)
val tmp = factory.generateSecret(spec)
return SecretKeySpec(tmp.encoded, "AES")
}
private fun generateSalt(): ByteArray {
val random = SecureRandom()
val salt = ByteArray(SALT_SIZE)
random.nextBytes(salt)
return salt
}
private fun generateIv(): ByteArray {
val r = SecureRandom()
val ivBytes = ByteArray(IV_SIZE)
r.nextBytes(ivBytes)
return ivBytes
}
companion object {
val instance = GPasswordEncryptionProviderV1();
private const val AES_MODE = "AES/GCM/NoPadding";
private const val IV_SIZE = 12
private const val SALT_SIZE = 16
private const val ITERATION_COUNT = 2 * 65536
private const val KEY_LENGTH = 256
private const val TAG_LENGTH = 128
private val TAG = "GPasswordEncryptionProviderV1";
}
}
@@ -0,0 +1,6 @@
package com.futo.platformplayer.exceptions
import java.lang.Exception
class UnsupportedCastException(msg: String) : Exception(msg) {
}
@@ -77,7 +77,7 @@ class ChannelAboutFragment : Fragment, IChannelTabFragment {
}; };
_textName?.text = channel.name; _textName?.text = channel.name;
val metadata = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} subscribers" else ""; val metadata = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} " + (context?.getString(R.string.subscribers)?.lowercase() ?: "") else "";
_textMetadata?.text = metadata; _textMetadata?.text = metadata;
_lastChannel = channel; _lastChannel = channel;
setLinks(channel.links, channel.name); setLinks(channel.links, channel.name);
@@ -91,7 +91,7 @@ class ChannelAboutFragment : Fragment, IChannelTabFragment {
l.removeAllViews(); l.removeAllViews();
if (links.isNotEmpty()) { if (links.isNotEmpty()) {
_textFindOn?.text = "Find $name on"; _textFindOn?.text = getString(R.string.find_name_on).replace("{name}", name);
_textFindOn?.visibility = View.VISIBLE; _textFindOn?.visibility = View.VISIBLE;
for (pair in links) { for (pair in links) {
@@ -4,7 +4,6 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@@ -12,8 +11,8 @@ import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
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.contents.ContentType import com.futo.platformplayer.api.media.models.contents.ContentType
@@ -25,6 +24,7 @@ import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.IRefreshPager import com.futo.platformplayer.api.media.structures.IRefreshPager
import com.futo.platformplayer.api.media.structures.IReplacerPager import com.futo.platformplayer.api.media.structures.IReplacerPager
import com.futo.platformplayer.api.media.structures.MultiPager import com.futo.platformplayer.api.media.structures.MultiPager
import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
@@ -33,8 +33,9 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
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.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.PreviewContentListAdapter import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -54,6 +55,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
val onContentClicked = Event2<IPlatformContent, Long>(); val onContentClicked = Event2<IPlatformContent, Long>();
val onContentUrlClicked = Event2<String, ContentType>(); val onContentUrlClicked = Event2<String, ContentType>();
val onUrlClicked = Event1<String>();
val onChannelClicked = Event1<PlatformAuthorLink>(); val onChannelClicked = Event1<PlatformAuthorLink>();
val onAddToClicked = Event1<IPlatformContent>(); val onAddToClicked = Event1<IPlatformContent>();
val onAddToQueueClicked = Event1<IPlatformContent>(); val onAddToQueueClicked = Event1<IPlatformContent>();
@@ -73,10 +75,14 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
} }
private val _taskLoadVideos = TaskHandler<IPlatformChannel, IPager<IPlatformContent>>({lifecycleScope}, { private val _taskLoadVideos = TaskHandler<IPlatformChannel, IPager<IPlatformContent>>({lifecycleScope}, {
return@TaskHandler getContentPager(it); val livePager = getContentPager(it);
}).success { return@TaskHandler if(_channel?.let { StateSubscriptions.instance.isSubscribed(it) } == true)
ChannelContentCache.cachePagerResults(lifecycleScope, livePager);
else livePager;
}).success { livePager ->
setLoading(false); setLoading(false);
setPager(it);
setPager(livePager);
} }
.exception<ScriptCaptchaRequiredException> { } .exception<ScriptCaptchaRequiredException> { }
.exception<Throwable> { .exception<Throwable> {
@@ -146,8 +152,9 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
_recyclerResults = view.findViewById(R.id.recycler_videos); _recyclerResults = view.findViewById(R.id.recycler_videos);
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results).apply { _adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar).apply {
this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit); this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit);
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit); this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit); this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit);
this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit); this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit);
@@ -248,7 +255,6 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
if(_pager is IReplacerPager<*>) if(_pager is IReplacerPager<*>)
(_pager as IReplacerPager<*>).onReplaced.remove(this); (_pager as IReplacerPager<*>).onReplaced.remove(this);
if(pager is IReplacerPager<*>) { if(pager is IReplacerPager<*>) {
pager.onReplaced.subscribe(this) { oldItem, newItem -> pager.onReplaced.subscribe(this) { oldItem, newItem ->
if(_pager != pager) if(_pager != pager)
@@ -257,11 +263,14 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
if(_pager !is IPager<IPlatformContent>) if(_pager !is IPager<IPlatformContent>)
return@subscribe; return@subscribe;
val toReplaceIndex = _results.indexOfFirst { it == newItem };
if(toReplaceIndex >= 0) { lifecycleScope.launch(Dispatchers.Main) {
_results[toReplaceIndex] = newItem as IPlatformContent; val toReplaceIndex = _results.indexOfFirst { it == oldItem };
_adapterResults?.let { if (toReplaceIndex >= 0) {
it.notifyItemChanged(it.childToParentPosition(toReplaceIndex)); _results[toReplaceIndex] = newItem as IPlatformContent;
_adapterResults?.let {
it.notifyItemChanged(it.childToParentPosition(toReplaceIndex));
}
} }
} }
} }
@@ -56,7 +56,7 @@ class ChannelListFragment : Fragment, IChannelTabFragment {
}.exception<ScriptCaptchaRequiredException> { } }.exception<ScriptCaptchaRequiredException> { }
.exceptionWithParameter<Throwable> { ex, para -> .exceptionWithParameter<Throwable> { ex, para ->
Logger.w(ChannelFragment.TAG, "Failed to load results.", ex); Logger.w(ChannelFragment.TAG, "Failed to load results.", ex);
UIDialogs.toast(requireContext(), "Failed to fetch\n${para}", false) UIDialogs.toast(requireContext(), getString(R.string.failed_to_fetch) + "\n " + para, false)
loadNext(); loadNext();
}; };
@@ -1,21 +1,20 @@
package com.futo.platformplayer.fragment.channel.tab package com.futo.platformplayer.fragment.channel.tab
import android.content.Intent
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.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.views.SupportView
import com.futo.platformplayer.views.buttons.BigButton
class ChannelMonetizationFragment : Fragment, IChannelTabFragment { class ChannelMonetizationFragment : Fragment, IChannelTabFragment {
private var _buttonStore: BigButton? = null; private var _supportView: SupportView? = null
private var _textMonetization: TextView? = null
private var _lastChannel: IPlatformChannel? = null; private var _lastChannel: IPlatformChannel? = null;
private var _lastPolycentricProfile: PolycentricProfile? = null; private var _lastPolycentricProfile: PolycentricProfile? = null;
@@ -24,51 +23,39 @@ class ChannelMonetizationFragment : Fragment, IChannelTabFragment {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_channel_monetization, container, false); val view = inflater.inflate(R.layout.fragment_channel_monetization, container, false);
_buttonStore = view.findViewById(R.id.button_store); _supportView = view.findViewById(R.id.support);
_textMonetization = view.findViewById(R.id.text_monetization);
_buttonStore?.onClick?.subscribe {
_lastPolycentricProfile?.systemState?.store?.let {
try {
val uri = Uri.parse(it);
val intent = Intent(Intent.ACTION_VIEW)
intent.data = uri
startActivity(intent)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to open URI: '${it}'.", e);
}
}
};
_lastChannel?.also { _lastChannel?.also {
setChannel(it); setChannel(it);
}; };
_lastPolycentricProfile?.also { _supportView?.visibility = View.GONE;
setPolycentricProfile(it, animate = false); _textMonetization?.visibility = View.GONE;
} setPolycentricProfile(_lastPolycentricProfile, animate = false);
return view; return view;
} }
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView(); super.onDestroyView();
_buttonStore = null; _supportView = null;
_textMonetization = null;
} }
override fun setChannel(channel: IPlatformChannel) { override fun setChannel(channel: IPlatformChannel) {
_lastChannel = channel; _lastChannel = channel;
_buttonStore?.visibility = View.GONE;
} }
fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) { fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) {
_lastPolycentricProfile = polycentricProfile; _lastPolycentricProfile = polycentricProfile
if (polycentricProfile != null) {
if (polycentricProfile == null) { _supportView?.setPolycentricProfile(polycentricProfile, animate)
return; _supportView?.visibility = View.VISIBLE
} _textMonetization?.visibility = View.GONE
} else {
if (polycentricProfile.systemState.store.isNotEmpty()) { _supportView?.setPolycentricProfile(null, animate)
_buttonStore?.visibility = View.VISIBLE; _supportView?.visibility = View.GONE
_textMonetization?.visibility = View.VISIBLE
} }
} }
@@ -220,6 +220,13 @@ class MenuBottomBarFragment : MainActivityFragment() {
buttons.removeAt(buyIndex) buttons.removeAt(buyIndex)
buttons.add(0, button) buttons.add(0, button)
} }
//Force faq to be second
val faqIndex = buttons.indexOfFirst { b -> b.id == 97 };
if (faqIndex != -1) {
val button = buttons[faqIndex]
buttons.removeAt(faqIndex)
buttons.add(if (buttons.size == 1) 1 else 0, button)
}
for (data in buttons) { for (data in buttons) {
val button = MenuButton(context, data, _fragment, true); val button = MenuButton(context, data, _fragment, true);
@@ -245,8 +252,8 @@ 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 - 2 >= defs.size) { if (_buttonsVisible - 1 >= defs.size) {
updateBottomMenuButtons(defs.slice(IntRange(0, defs.size - 1)).toMutableList(), false); updateBottomMenuButtons(defs.toMutableList(), false);
} else { } else {
updateBottomMenuButtons(defs.slice(IntRange(0, _buttonsVisible - 2)).toMutableList(), true); updateBottomMenuButtons(defs.slice(IntRange(0, _buttonsVisible - 2)).toMutableList(), true);
updateMoreButtons(defs.slice(IntRange(_buttonsVisible - 1, defs.size - 1)).toMutableList()); updateMoreButtons(defs.slice(IntRange(_buttonsVisible - 1, defs.size - 1)).toMutableList());
@@ -289,6 +296,9 @@ class MenuBottomBarFragment : MainActivityFragment() {
if (!StatePayment.instance.hasPaid) { if (!StatePayment.instance.hasPaid) {
newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate<BuyFragment>() })) newCurrentButtonDefinitions.add(ButtonDefinition(98, R.drawable.ic_paid, R.drawable.ic_paid, R.string.buy, canToggle = false, { it.currentMain is BuyFragment }, { it.navigate<BuyFragment>() }))
} }
newCurrentButtonDefinitions.add(ButtonDefinition(97, R.drawable.ic_quiz, R.drawable.ic_quiz, R.string.faq, canToggle = false, { false }, {
it.navigate<BrowserFragment>(Settings.URL_FAQ);
}))
//Add conditional buttons here, when you add a conditional button, be sure to add the register and unregister events for when the button needs to be updated //Add conditional buttons here, when you add a conditional button, be sure to add the register and unregister events for when the button needs to be updated
@@ -60,12 +60,12 @@ class BuyFragment : MainFragment() {
_paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, purchaseId, exception -> _paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, purchaseId, exception ->
if(success) { if(success) {
UIDialogs.showDialog(context, R.drawable.ic_check, "Payment succeeded", "Thanks for your purchase, a key will be sent to your email after your payment has been received!", null, 0, UIDialogs.showDialog(context, R.drawable.ic_check, context.getString(R.string.payment_succeeded), context.getString(R.string.thanks_for_your_purchase_a_key_will_be_sent_to_your_email_after_your_payment_has_been_received), null, 0,
UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY)); UIDialogs.Action("Ok", {}, UIDialogs.ActionStyle.PRIMARY));
_fragment.close(true); _fragment.close(true);
} }
else { else {
UIDialogs.showGeneralErrorDialog(context, "Payment failed", exception); UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.payment_failed), exception);
} }
} }
@@ -91,7 +91,7 @@ class BuyFragment : MainFragment() {
val price = prices[currency.id]!!; val price = prices[currency.id]!!;
val priceDecimal = (price.toDouble() / 100); val priceDecimal = (price.toDouble() / 100);
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_buttonBuyText.text = currency.symbol + String.format("%.2f", priceDecimal); _buttonBuyText.text = currency.symbol + String.format("%.2f", priceDecimal) + context.getString(R.string.plus_tax);
} }
} }
} }
@@ -107,12 +107,12 @@ class BuyFragment : MainFragment() {
} }
private fun paid() { private fun paid() {
val licenseInput = SlideUpMenuTextInput(context, "License"); val licenseInput = SlideUpMenuTextInput(context, context.getString(R.string.license));
val productLicenseDialog = SlideUpMenuOverlay(context, findViewById<FrameLayout>(R.id.overlay_paid), "Enter license key", "Ok", true, licenseInput); val productLicenseDialog = SlideUpMenuOverlay(context, findViewById<FrameLayout>(R.id.overlay_paid), context.getString(R.string.enter_license_key), context.getString(R.string.ok), true, licenseInput);
productLicenseDialog.onOK.subscribe { productLicenseDialog.onOK.subscribe {
val licenseText = licenseInput.text; val licenseText = licenseInput.text;
if (licenseText.isNullOrEmpty()) { if (licenseText.isNullOrEmpty()) {
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Invalid license key"); UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.invalid_license_key));
return@subscribe; return@subscribe;
} }
@@ -127,19 +127,19 @@ class BuyFragment : MainFragment() {
licenseInput.clear(); licenseInput.clear();
productLicenseDialog.hide(true); productLicenseDialog.hide(true);
UIDialogs.showDialogOk(context, R.drawable.ic_check, "Your license key has been set!\nAn app restart might be required."); UIDialogs.showDialogOk(context, R.drawable.ic_check, context.getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required));
_fragment.close(true); _fragment.close(true);
} }
else else
{ {
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Invalid license key"); UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.invalid_license_key));
} }
} }
} }
catch(ex: Throwable) { catch(ex: Throwable) {
Logger.e("BuyFragment", "Failed to activate key", ex); Logger.e("BuyFragment", "Failed to activate key", ex);
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(context, "Failed to activate key", ex); UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_activate_key), ex);
} }
} }
} }
@@ -9,6 +9,7 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
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
@@ -41,6 +42,7 @@ import com.futo.platformplayer.polycentric.PolycentricCache
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.StateSubscriptions
import com.futo.platformplayer.views.others.CreatorThumbnail import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.subscriptions.SubscribeButton import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter
@@ -100,6 +102,7 @@ class ChannelFragment : MainFragment() {
private var _viewPager: ViewPager2; private var _viewPager: ViewPager2;
private var _tabLayoutMediator: TabLayoutMediator; private var _tabLayoutMediator: TabLayoutMediator;
private var _buttonSubscribe: SubscribeButton; private var _buttonSubscribe: SubscribeButton;
private var _buttonSubscriptionSettings: ImageButton;
private var _overlayContainer: FrameLayout; private var _overlayContainer: FrameLayout;
private var _overlay_loading: LinearLayout; private var _overlay_loading: LinearLayout;
@@ -141,7 +144,7 @@ class ChannelFragment : MainFragment() {
UIDialogs.showDialog(context, UIDialogs.showDialog(context,
R.drawable.ic_sources, R.drawable.ic_sources,
"No source enabled to support this channel\n(${_url})", null, null, context.getString(R.string.no_source_enabled_to_support_this_channel) + "\n(${_url})", null, null,
0, 0,
UIDialogs.Action("Back", { UIDialogs.Action("Back", {
fragment.close(true); fragment.close(true);
@@ -160,10 +163,25 @@ class ChannelFragment : MainFragment() {
_creatorThumbnail = findViewById(R.id.creator_thumbnail); _creatorThumbnail = findViewById(R.id.creator_thumbnail);
_imageBanner = findViewById(R.id.image_channel_banner); _imageBanner = findViewById(R.id.image_channel_banner);
_buttonSubscribe = findViewById(R.id.button_subscribe); _buttonSubscribe = findViewById(R.id.button_subscribe);
_buttonSubscriptionSettings = findViewById(R.id.button_sub_settings);
_overlay_loading = findViewById(R.id.channel_loading_overlay); _overlay_loading = findViewById(R.id.channel_loading_overlay);
_overlay_loading_spinner = findViewById(R.id.channel_loader); _overlay_loading_spinner = findViewById(R.id.channel_loader);
_overlayContainer = findViewById(R.id.overlay_container); _overlayContainer = findViewById(R.id.overlay_container);
_buttonSubscribe.onSubscribed.subscribe {
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
}
_buttonSubscribe.onUnSubscribed.subscribe {
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
}
_buttonSubscriptionSettings.setOnClickListener {
val url = channel?.url ?: _url ?: return@setOnClickListener;
val sub = StateSubscriptions.instance.getSubscription(url) ?: return@setOnClickListener;
UISlideOverlays.showSubscriptionOptionsOverlay(sub, _overlayContainer);
};
//TODO: Determine if this is really the only solution (isSaveEnabled=false) //TODO: Determine if this is really the only solution (isSaveEnabled=false)
viewPager.isSaveEnabled = false; viewPager.isSaveEnabled = false;
viewPager.registerOnPageChangeCallback(_onPageChangeCallback); viewPager.registerOnPageChangeCallback(_onPageChangeCallback);
@@ -192,6 +210,9 @@ class ChannelFragment : MainFragment() {
UIDialogs.toast(context, "Queued [$name]", false); UIDialogs.toast(context, "Queued [$name]", false);
} }
} }
adapter.onUrlClicked.subscribe { url ->
fragment.navigate<BrowserFragment>(url);
}
adapter.onContentUrlClicked.subscribe { url, contentType -> adapter.onContentUrlClicked.subscribe { url, contentType ->
when(contentType) { when(contentType) {
ContentType.MEDIA -> { ContentType.MEDIA -> {
@@ -246,28 +267,46 @@ class ChannelFragment : MainFragment() {
if (parameter is String) { if (parameter is String) {
_buttonSubscribe.setSubscribeChannel(parameter); _buttonSubscribe.setSubscribeChannel(parameter);
_textChannel.text = ""; _buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
_textChannelSub.text = ""; setPolycentricProfileOr(parameter) {
_textChannel.text = "";
_textChannelSub.text = "";
_creatorThumbnail.setThumbnail(null, true);
Glide.with(_imageBanner)
.clear(_imageBanner);
};
_url = parameter; _url = parameter;
loadChannel(); loadChannel();
} else if (parameter is SerializedChannel) { } else if (parameter is SerializedChannel) {
showChannel(parameter); showChannel(parameter);
_url = parameter.url; _url = parameter.url;
_creatorThumbnail.setThumbnail(parameter.url, false);
loadChannel(); loadChannel();
} else if (parameter is IPlatformChannel) } else if (parameter is IPlatformChannel)
showChannel(parameter); showChannel(parameter);
else if (parameter is PlatformAuthorLink) { else if (parameter is PlatformAuthorLink) {
_textChannel.text = parameter.name; setPolycentricProfileOr(parameter.url) {
_textChannelSub.text = ""; _textChannel.text = parameter.name;
_creatorThumbnail.setThumbnail(parameter.url, false); _textChannelSub.text = "";
_creatorThumbnail.setThumbnail(parameter.thumbnail, true);
Glide.with(_imageBanner)
.clear(_imageBanner);
_taskLoadPolycentricProfile.run(parameter.id);
};
_url = parameter.url; _url = parameter.url;
loadChannel(); loadChannel();
} else if (parameter is Subscription) { } else if (parameter is Subscription) {
_textChannel.text = parameter.channel.name; setPolycentricProfileOr(parameter.channel.url) {
_textChannelSub.text = ""; _textChannel.text = parameter.channel.name;
_creatorThumbnail.setThumbnail(parameter.channel.thumbnail, false); _textChannelSub.text = "";
_creatorThumbnail.setThumbnail(parameter.channel.thumbnail, true);
Glide.with(_imageBanner)
.clear(_imageBanner);
_taskLoadPolycentricProfile.run(parameter.channel.id);
};
_url = parameter.channel.url; _url = parameter.channel.url;
loadChannel(); loadChannel();
@@ -327,19 +366,19 @@ class ChannelFragment : MainFragment() {
_fragment.topBar?.onShown(channel); _fragment.topBar?.onShown(channel);
val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) { val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) {
UIDialogs.showConfirmationDialog(context, "Do you want to convert channel ${channel.name} to a playlist?", { UIDialogs.showConfirmationDialog(context, context.getString(R.string.do_you_want_to_convert_channel_channelname_to_a_playlist).replace("{channelName}", channel.name), {
UIDialogs.showDialogProgress(context) { UIDialogs.showDialogProgress(context) {
_fragment.lifecycleScope.launch(Dispatchers.IO) { _fragment.lifecycleScope.launch(Dispatchers.IO) {
try { try {
StatePlaylists.instance.createPlaylistFromChannel(channel) { page -> StatePlaylists.instance.createPlaylistFromChannel(channel) { page ->
_fragment.lifecycleScope.launch(Dispatchers.Main) { _fragment.lifecycleScope.launch(Dispatchers.Main) {
it.setText("${channel.name}\nPage ${page}"); it.setText("${channel.name}\n" + context.getString(R.string.page) + " $page");
} }
}; };
} }
catch(ex: Exception) { catch(ex: Exception) {
Logger.e(TAG, "Error", ex); Logger.e(TAG, "Error", ex);
UIDialogs.showGeneralErrorDialog(context, "Failed to convert channel", ex); UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_convert_channel), ex);
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -350,24 +389,22 @@ class ChannelFragment : MainFragment() {
}); });
}); });
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url); _fragment.lifecycleScope.launch(Dispatchers.IO) {
if (plugin != null && plugin.capabilities.hasSearchChannelContents) { val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url);
buttons.add(Pair(R.drawable.ic_search) { withContext(Dispatchers.Main) {
_fragment.navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO, channel.url)); if (plugin != null && plugin.capabilities.hasSearchChannelContents) {
}); buttons.add(Pair(R.drawable.ic_search) {
_fragment.navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO, channel.url));
});
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons);
}
}
} }
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons);
_buttonSubscribe.setSubscribeChannel(channel); _buttonSubscribe.setSubscribeChannel(channel);
_textChannel.text = channel.name; _buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
_textChannelSub.text = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} subscribers" else ""; _textChannelSub.text = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} " + context.getString(R.string.subscribers).lowercase() else "";
_creatorThumbnail.setThumbnail(channel.thumbnail, true);
Glide.with(_imageBanner)
.load(channel.banner)
.crossfade()
.into(_imageBanner)
//TODO: Find a better way to access the adapter fragments.. //TODO: Find a better way to access the adapter fragments..
@@ -381,51 +418,68 @@ class ChannelFragment : MainFragment() {
this.channel = channel; this.channel = channel;
val cachedProfile = PolycentricCache.instance.getCachedProfile(channel.url); setPolycentricProfileOr(channel.url) {
_textChannel.text = channel.name;
_creatorThumbnail.setThumbnail(channel.thumbnail, true);
Glide.with(_imageBanner)
.load(channel.banner)
.crossfade()
.into(_imageBanner);
_taskLoadPolycentricProfile.run(channel.id);
};
}
private fun setPolycentricProfileOr(url: String, or: () -> Unit) {
val cachedProfile = channel?.let { PolycentricCache.instance.getCachedProfile(it.url) };
if (cachedProfile != null) { if (cachedProfile != null) {
setPolycentricProfile(cachedProfile, animate = false); setPolycentricProfile(cachedProfile, animate = false);
} else { } else {
setPolycentricProfile(null, animate = false); setPolycentricProfile(null, animate = false);
_taskLoadPolycentricProfile.run(channel.id); or();
} }
} }
private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
Log.i(TAG, "setPolycentricProfile(cachedPolycentricProfile = $cachedPolycentricProfile, animate = $animate)") Log.i(TAG, "setPolycentricProfile(cachedPolycentricProfile = $cachedPolycentricProfile, animate = $animate)")
val polycentricProfile = cachedPolycentricProfile?.profile; val dp_35 = 35.dp(resources)
if (polycentricProfile != null) { val profile = cachedPolycentricProfile?.profile;
_fragment.topBar?.onShown(polycentricProfile); val avatar = profile?.systemState?.avatar?.selectBestImage(dp_35 * dp_35)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
if (polycentricProfile.systemState.username.isNotBlank()) if (avatar != null) {
_textChannel.text = polycentricProfile.systemState.username; _creatorThumbnail.setThumbnail(avatar, animate);
} else {
_creatorThumbnail.setThumbnail(channel?.thumbnail, animate);
_creatorThumbnail.setHarborAvailable(profile != null, animate);
}
val dp_35 = 35.dp(resources) val banner = profile?.systemState?.banner?.selectHighestResolutionImage()
val avatar = polycentricProfile.systemState.avatar?.selectBestImage(dp_35 * dp_35) ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
?.let { it.toURLInfoSystemLinkUrl(polycentricProfile.system.toProto(), it.process, polycentricProfile.systemState.servers.toList()) };
if (avatar != null) { if (banner != null) {
_creatorThumbnail.setThumbnail(avatar, true); Glide.with(_imageBanner)
} else { .load(banner)
_creatorThumbnail.setHarborAvailable(true, true); .crossfade()
} .into(_imageBanner);
} else {
Glide.with(_imageBanner)
.load(channel?.banner)
.crossfade()
.into(_imageBanner);
}
val banner = polycentricProfile.systemState.banner?.selectHighestResolutionImage() if (profile != null) {
?.let { it.toURLInfoSystemLinkUrl(polycentricProfile.system.toProto(), it.process, polycentricProfile.systemState.servers.toList()) }; _fragment.topBar?.onShown(profile);
_textChannel.text = profile.systemState.username;
if (banner != null) {
Glide.with(_imageBanner)
.load(banner)
.crossfade()
.into(_imageBanner);
}
} }
(_viewPager.adapter as ChannelViewPagerAdapter?)?.let { (_viewPager.adapter as ChannelViewPagerAdapter?)?.let {
it.getFragment<ChannelAboutFragment>().setPolycentricProfile(polycentricProfile, animate); it.getFragment<ChannelAboutFragment>().setPolycentricProfile(profile, animate);
it.getFragment<ChannelMonetizationFragment>().setPolycentricProfile(polycentricProfile, animate); it.getFragment<ChannelMonetizationFragment>().setPolycentricProfile(profile, animate);
it.getFragment<ChannelListFragment>().setPolycentricProfile(polycentricProfile, animate); it.getFragment<ChannelListFragment>().setPolycentricProfile(profile, animate);
it.getFragment<ChannelContentsFragment>().setPolycentricProfile(polycentricProfile, animate); it.getFragment<ChannelContentsFragment>().setPolycentricProfile(profile, animate);
//TODO: Call on other tabs as needed //TODO: Call on other tabs as needed
} }
} }
@@ -1,6 +1,5 @@
package com.futo.platformplayer.fragment.mainactivity.main package com.futo.platformplayer.fragment.mainactivity.main
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
@@ -15,15 +14,18 @@ import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.structures.* import com.futo.platformplayer.api.media.structures.*
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.video.PlayerManager import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.PreviewContentListAdapter import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
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.adapters.PreviewNestedVideoViewHolder import com.futo.platformplayer.views.adapters.feedtypes.PreviewNestedVideoViewHolder
import com.futo.platformplayer.views.adapters.PreviewVideoViewHolder import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoViewHolder
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import kotlin.math.floor import kotlin.math.floor
abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent, IPlatformContent, IPager<IPlatformContent>, ContentPreviewViewHolder> where TFragment : MainFragment { abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent, IPlatformContent, IPager<IPlatformContent>, ContentPreviewViewHolder> where TFragment : MainFragment {
@@ -34,6 +36,8 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
private var _previewsEnabled: Boolean = true; private var _previewsEnabled: Boolean = true;
override val visibleThreshold: Int get() = if (feedStyle == FeedStyle.PREVIEW) { 5 } else { 10 }; override val visibleThreshold: Int get() = if (feedStyle == FeedStyle.PREVIEW) { 5 } else { 10 };
protected lateinit var headerView: LinearLayout; protected lateinit var headerView: LinearLayout;
private var _videoOptionsOverlay: SlideUpMenuOverlay? = null;
protected open val shouldShowTimeBar: Boolean get() = true
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) { constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
@@ -54,48 +58,86 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
}; };
headerView = v; headerView = v;
return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(v)).apply { return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(v), arrayListOf(), shouldShowTimeBar).apply {
attachAdapterEvents(this); attachAdapterEvents(this);
} }
} }
private fun attachAdapterEvents(adapter: PreviewContentListAdapter) { private fun attachAdapterEvents(adapter: PreviewContentListAdapter) {
adapter.onContentUrlClicked.subscribe(this, this@ContentFeedView::onContentUrlClicked); adapter.onContentUrlClicked.subscribe(this, this@ContentFeedView::onContentUrlClicked);
adapter.onUrlClicked.subscribe(this, this@ContentFeedView::onUrlClicked);
adapter.onContentClicked.subscribe(this) { content, time -> adapter.onContentClicked.subscribe(this) { content, time ->
this@ContentFeedView.onContentClicked(content, time); this@ContentFeedView.onContentClicked(content, time);
}; };
adapter.onChannelClicked.subscribe(this) { fragment.navigate<ChannelFragment>(it) }; adapter.onChannelClicked.subscribe(this) { fragment.navigate<ChannelFragment>(it) };
adapter.onAddToClicked.subscribe(this) { content -> adapter.onAddToClicked.subscribe(this) { content ->
//TODO: Reconstruct search video from detail if search is null //TODO: Reconstruct search video from detail if search is null
_overlayContainer.let { if(content is IPlatformVideo) {
if(content is IPlatformVideo) showVideoOptionsOverlay(content)
UISlideOverlays.showVideoOptionsOverlay(content, it) {
if (fragment is HomeFragment) {
val removeIndex = recyclerData.results.indexOf(content);
if (removeIndex >= 0) {
recyclerData.results.removeAt(removeIndex);
recyclerData.adapter.notifyItemRemoved(recyclerData.adapter.childToParentPosition(removeIndex));
}
}
};
} }
}; };
adapter.onAddToQueueClicked.subscribe(this) { adapter.onAddToQueueClicked.subscribe(this) {
if(it is IPlatformVideo) { if(it is IPlatformVideo) {
StatePlayer.instance.addToQueue(it); StatePlayer.instance.addToQueue(it);
val name = if (it.name.length > 20) (it.name.subSequence(0, 20).toString() + "...") else it.name; val name = if (it.name.length > 20) (it.name.subSequence(0, 20).toString() + "...") else it.name;
UIDialogs.toast(context, "Queued [$name]", false); UIDialogs.toast(context, context.getString(R.string.queued) + " [$name]", false);
} }
}; };
adapter.onLongPress.subscribe(this) {
if (it is IPlatformVideo) {
showVideoOptionsOverlay(it)
}
};
}
fun onBackPressed(): Boolean {
val videoOptionsOverlay = _videoOptionsOverlay
if (videoOptionsOverlay != null) {
if (videoOptionsOverlay.isVisible) {
videoOptionsOverlay.hide();
_videoOptionsOverlay = null
return true;
}
_videoOptionsOverlay = null
return false
}
return false
}
private fun showVideoOptionsOverlay(content: IPlatformVideo) {
_overlayContainer.let {
_videoOptionsOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem(context, R.drawable.ic_visibility_off, context.getString(R.string.hide), context.getString(R.string.hide_from_home), "hide",
{ StateMeta.instance.addHiddenVideo(content.url);
if (fragment is HomeFragment) {
val removeIndex = recyclerData.results.indexOf(content);
if (removeIndex >= 0) {
recyclerData.results.removeAt(removeIndex);
recyclerData.adapter.notifyItemRemoved(recyclerData.adapter.childToParentPosition(removeIndex));
}
}
}),
SlideUpMenuItem(context, R.drawable.ic_playlist, context.getString(R.string.play_feed_as_queue), context.getString(R.string.play_entire_feed), "playFeed",
{
val newQueue = listOf(content) + recyclerData.results
.filterIsInstance<IPlatformVideo>()
.filter { it != content };
StatePlayer.instance.setQueue(newQueue, StatePlayer.TYPE_QUEUE, "Feed Queue", true, false);
})
);
}
} }
private fun detachAdapterEvents() { private fun detachAdapterEvents() {
val adapter = recyclerData.adapter as PreviewContentListAdapter? ?: return; val adapter = recyclerData.adapter as PreviewContentListAdapter? ?: return;
adapter.onContentUrlClicked.remove(this); adapter.onContentUrlClicked.remove(this);
adapter.onUrlClicked.remove(this);
adapter.onContentClicked.remove(this); adapter.onContentClicked.remove(this);
adapter.onChannelClicked.remove(this); adapter.onChannelClicked.remove(this);
adapter.onAddToClicked.remove(this); adapter.onAddToClicked.remove(this);
adapter.onAddToQueueClicked.remove(this); adapter.onAddToQueueClicked.remove(this);
adapter.onLongPress.remove(this);
} }
override fun onRestoreCachedData(cachedData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>) { override fun onRestoreCachedData(cachedData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>) {
@@ -125,11 +167,14 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
protected open fun onContentClicked(content: IPlatformContent, time: Long) { protected open fun onContentClicked(content: IPlatformContent, time: Long) {
if(content is IPlatformVideo) { if(content is IPlatformVideo) {
StatePlayer.instance.clearQueue(); if (StatePlayer.instance.hasQueue) {
if (Settings.instance.playback.shouldResumePreview(time)) StatePlayer.instance.addToQueue(content)
fragment.navigate<VideoDetailFragment>(content.withTimestamp(time)).maximizeVideoDetail(); } else {
else if (Settings.instance.playback.shouldResumePreview(time))
fragment.navigate<VideoDetailFragment>(content).maximizeVideoDetail(); fragment.navigate<VideoDetailFragment>(content.withTimestamp(time)).maximizeVideoDetail();
else
fragment.navigate<VideoDetailFragment>(content).maximizeVideoDetail();
}
} else if (content is IPlatformPlaylist) { } else if (content is IPlatformPlaylist) {
fragment.navigate<PlaylistFragment>(content); fragment.navigate<PlaylistFragment>(content);
} else if (content is IPlatformPost) { } else if (content is IPlatformPost) {
@@ -147,6 +192,9 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
else -> {}; else -> {};
} }
} }
protected open fun onUrlClicked(url: String) {
fragment.navigate<BrowserFragment>(url);
}
private fun playPreview() { private fun playPreview() {
if(feedStyle == FeedStyle.THUMBNAIL) if(feedStyle == FeedStyle.THUMBNAIL)
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.isHttpUrl
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -61,8 +62,15 @@ class ContentSearchResultsFragment : MainFragment() {
_view = null; _view = null;
} }
override fun onBackPressed(): Boolean {
if (_view?.onBackPressed() == true)
return true
return super.onBackPressed()
}
fun setPreviewsEnabled(previewsEnabled: Boolean) { fun setPreviewsEnabled(previewsEnabled: Boolean) {
_view?.setPreviewsEnabled(previewsEnabled); _view?.setPreviewsEnabled(previewsEnabled && Settings.instance.search.previewFeedItems);
} }
@SuppressLint("ViewConstructor") @SuppressLint("ViewConstructor")
@@ -76,6 +84,7 @@ class ContentSearchResultsFragment : MainFragment() {
private var _channelUrl: String? = null; private var _channelUrl: String? = null;
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>; private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar
constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) { constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
_taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query -> _taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query ->
@@ -89,9 +98,11 @@ class ContentSearchResultsFragment : MainFragment() {
}) })
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { } .success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
.exception<Throwable> { .exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load results.", it); Logger.w(TAG, "Failed to load results.", it);
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() }); UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
} }
setPreviewsEnabled(Settings.instance.search.previewFeedItems);
} }
override fun cleanup() { override fun cleanup() {
@@ -101,14 +112,12 @@ class ContentSearchResultsFragment : MainFragment() {
fun onShown(parameter: Any?, isBack: Boolean) { fun onShown(parameter: Any?, isBack: Boolean) {
if(parameter is SuggestionsFragmentData) { if(parameter is SuggestionsFragmentData) {
if(!isBack) { setQuery(parameter.query, false);
setQuery(parameter.query, false); setChannelUrl(parameter.channelUrl, false);
setChannelUrl(parameter.channelUrl, false);
fragment.topBar?.apply { fragment.topBar?.apply {
if (this is SearchTopBarFragment) { if (this is SearchTopBarFragment) {
this.setText(parameter.query); this.setText(parameter.query);
}
} }
} }
} }
@@ -145,7 +154,10 @@ class ContentSearchResultsFragment : MainFragment() {
}; };
onSearch.subscribe(this) { onSearch.subscribe(this) {
setQuery(it, true); if(it.isHttpUrl())
navigate<VideoDetailFragment>(it);
else
setQuery(it, true);
}; };
} }
} }
@@ -71,16 +71,14 @@ class CreatorSearchResultsFragment : MainFragment() {
fun onShown(parameter: Any?, isBack: Boolean) { fun onShown(parameter: Any?, isBack: Boolean) {
if(parameter is String) { if(parameter is String) {
if(!isBack) { setQuery(parameter);
setQuery(parameter);
fragment.topBar?.apply { fragment.topBar?.apply {
if (this is SearchTopBarFragment) { if (this is SearchTopBarFragment) {
setText(parameter); setText(parameter);
onSearch.subscribe(this) { onSearch.subscribe(this) {
setQuery(it); setQuery(it);
}; };
}
} }
} }
} }
@@ -6,10 +6,12 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.FrameLayout
import android.widget.Spinner import android.widget.Spinner
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.UISlideOverlays
import com.futo.platformplayer.views.adapters.SubscriptionAdapter import com.futo.platformplayer.views.adapters.SubscriptionAdapter
class CreatorsFragment : MainFragment() { class CreatorsFragment : MainFragment() {
@@ -18,13 +20,16 @@ class CreatorsFragment : MainFragment() {
override val hasBottomBar: Boolean get() = true; override val hasBottomBar: Boolean get() = true;
private var _spinnerSortBy: Spinner? = null; private var _spinnerSortBy: Spinner? = null;
private var _overlayContainer: FrameLayout? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = inflater.inflate(R.layout.fragment_creators, container, false); val view = inflater.inflate(R.layout.fragment_creators, container, false);
val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription)); val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription));
adapter.onClick.subscribe { platformUser -> navigate<ChannelFragment>(platformUser) }; adapter.onClick.subscribe { platformUser -> navigate<ChannelFragment>(platformUser) };
adapter.onSettings.subscribe { sub -> _overlayContainer?.let { UISlideOverlays.showSubscriptionOptionsOverlay(sub, it) } }
_overlayContainer = view.findViewById(R.id.overlay_container);
val spinnerSortBy: Spinner = view.findViewById(R.id.spinner_sortby); val spinnerSortBy: Spinner = view.findViewById(R.id.spinner_sortby);
spinnerSortBy.adapter = ArrayAdapter(view.context, R.layout.spinner_item_simple, resources.getStringArray(R.array.subscriptions_sortby_array)).also { spinnerSortBy.adapter = ArrayAdapter(view.context, R.layout.spinner_item_simple, resources.getStringArray(R.array.subscriptions_sortby_array)).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
@@ -48,6 +53,7 @@ class CreatorsFragment : MainFragment() {
override fun onDestroyMainView() { override fun onDestroyMainView() {
super.onDestroyMainView(); super.onDestroyMainView();
_spinnerSortBy = null; _spinnerSortBy = null;
_overlayContainer = null;
} }
companion object { companion object {
@@ -136,8 +136,8 @@ class DownloadsFragment : MainFragment() {
fun reloadUI() { fun reloadUI() {
val usage = StateDownloads.instance.getTotalUsage(true); val usage = StateDownloads.instance.getTotalUsage(true);
_usageUsed.text = "${usage.usage.toHumanBytesSize()} Used"; _usageUsed.text = "${usage.usage.toHumanBytesSize()} " + context.getString(R.string.used);
_usageAvailable.text = "${usage.available.toHumanBytesSize()} Available"; _usageAvailable.text = "${usage.available.toHumanBytesSize()} " + context.getString(R.string.available);
_usageProgress.progress = usage.percentage.toFloat(); _usageProgress.progress = usage.percentage.toFloat();
@@ -161,7 +161,7 @@ class DownloadsFragment : MainFragment() {
_listPlaylistsContainer.visibility = GONE; _listPlaylistsContainer.visibility = GONE;
else { else {
_listPlaylistsContainer.visibility = VISIBLE; _listPlaylistsContainer.visibility = VISIBLE;
_listPlaylistsMeta.text = "(${playlists.size} playlists, ${playlists.sumOf { it.playlist.videos.size }} videos)"; _listPlaylistsMeta.text = "(${playlists.size} ${context.getString(R.string.playlists).lowercase()}, ${playlists.sumOf { it.playlist.videos.size }} ${context.getString(R.string.videos).lowercase()})";
_listPlaylists.removeAllViews(); _listPlaylists.removeAllViews();
for(view in playlists.map { PlaylistDownloadItem(context, it) }) { for(view in playlists.map { PlaylistDownloadItem(context, it) }) {
@@ -176,7 +176,7 @@ class DownloadsFragment : MainFragment() {
_listDownloadedHeader.visibility = GONE; _listDownloadedHeader.visibility = GONE;
} else { } else {
_listDownloadedHeader.visibility = VISIBLE; _listDownloadedHeader.visibility = VISIBLE;
_listDownloadedMeta.text = "(${downloaded.size} videos)"; _listDownloadedMeta.text = "(${downloaded.size} ${context.getString(R.string.videos).lowercase()})";
} }
_listDownloaded.setData(downloaded); _listDownloaded.setData(downloaded);
@@ -122,6 +122,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_toolbarContentView = findViewById(R.id.container_toolbar_content); _toolbarContentView = findViewById(R.id.container_toolbar_content);
var filteredNextPageCounter = 0;
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, { _nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
if (it is IAsyncPager<*>) if (it is IAsyncPager<*>)
it.nextPageAsync(); it.nextPageAsync();
@@ -141,10 +142,18 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
val filteredResults = filterResults(it); val filteredResults = filterResults(it);
recyclerData.results.addAll(filteredResults); recyclerData.results.addAll(filteredResults);
recyclerData.resultsUnfiltered.addAll(it); recyclerData.resultsUnfiltered.addAll(it);
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size); if(filteredResults.isEmpty()) {
filteredNextPageCounter++
if(filteredNextPageCounter <= 4)
loadNextPage()
}
else {
filteredNextPageCounter = 0;
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
}
}.exception<Throwable> { }.exception<Throwable> {
Logger.w(TAG, "Failed to load next page.", it); Logger.w(TAG, "Failed to load next page.", it);
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load next page", it, { UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
loadNextPage(); loadNextPage();
}); });
//UIDialogs.showDataRetryDialog(layoutInflater, it.message, { loadNextPage() }); //UIDialogs.showDataRetryDialog(layoutInflater, it.message, { loadNextPage() });
@@ -256,7 +265,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
fragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
try { try {
if(jsVideoPager != null) if(jsVideoPager != null)
UIDialogs.toast(it, "Plugin ${jsVideoPager.getPluginConfig().name} failed:\n${kv.value.message}", false); UIDialogs.toast(it, context.getString(R.string.plugin_pluginname_failed_message).replace("{pluginName}", jsVideoPager.getPluginConfig().name).replace("{message}", 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) {
@@ -333,11 +342,11 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
parentPager.onPagerError.subscribe(this) { parentPager.onPagerError.subscribe(this) {
Logger.e(TAG, "Search pager failed: ${it.message}", it); Logger.e(TAG, "Search pager failed: ${it.message}", it);
when (it) { when (it) {
is PluginException -> UIDialogs.toast("Plugin [${it.config.name}] failed due to:\n${it.message}") is PluginException -> UIDialogs.toast("Plugin [{pluginName}] failed due to:\n{exceptionMessage}".replace("{pluginName}", it.config.name).replace("{exceptionMessage}", it.message ?: ""))
is CancellationException -> { is CancellationException -> {
//Hide cancelled toast //Hide cancelled toast
} }
else -> UIDialogs.toast("Plugin failed due to:\n${it.message}") else -> UIDialogs.toast(context.getString(R.string.plugin_failed_due_to) + "\n${it.message}")
}; };
}; };
} }
@@ -66,6 +66,13 @@ class HomeFragment : MainFragment() {
return view; return view;
} }
override fun onBackPressed(): Boolean {
if (_view?.onBackPressed() == true)
return true
return super.onBackPressed()
}
override fun onDestroyMainView() { override fun onDestroyMainView() {
super.onDestroyMainView(); super.onDestroyMainView();
@@ -78,7 +85,7 @@ class HomeFragment : MainFragment() {
} }
fun setPreviewsEnabled(previewsEnabled: Boolean) { fun setPreviewsEnabled(previewsEnabled: Boolean) {
_view?.setPreviewsEnabled(previewsEnabled); _view?.setPreviewsEnabled(previewsEnabled && Settings.instance.home.previewFeedItems);
} }
@SuppressLint("ViewConstructor") @SuppressLint("ViewConstructor")
@@ -88,6 +95,7 @@ class HomeFragment : MainFragment() {
private var _announcementsView: AnnouncementView; private var _announcementsView: AnnouncementView;
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>; private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) { constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
_announcementsView = AnnouncementView(context, null).apply { _announcementsView = AnnouncementView(context, null).apply {
@@ -101,27 +109,29 @@ class HomeFragment : MainFragment() {
.exception<ScriptCaptchaRequiredException> { } .exception<ScriptCaptchaRequiredException> { }
.exception<ScriptExecutionException> { .exception<ScriptExecutionException> {
Logger.w(ChannelFragment.TAG, "Plugin failure.", it); Logger.w(ChannelFragment.TAG, "Plugin failure.", it);
UIDialogs.showDialog(context, R.drawable.ic_error_pred, "Failed to get Home\nPlugin [${it.config.name}]", it.message, null, 0, UIDialogs.showDialog(context, R.drawable.ic_error_pred, context.getString(R.string.failed_to_get_home_plugin) + " [${it.config.name}]", it.message, null, 0,
UIDialogs.Action("Ignore", {}), UIDialogs.Action(context.getString(R.string.ignore), {}),
UIDialogs.Action("Sources", { fragment.navigate<SourcesFragment>() }, UIDialogs.ActionStyle.PRIMARY) UIDialogs.Action(context.getString(R.string.sources), { fragment.navigate<SourcesFragment>() }, UIDialogs.ActionStyle.PRIMARY)
); );
} }
.exception<ScriptImplementationException> { .exception<ScriptImplementationException> {
Logger.w(TAG, "Plugin failure.", it); Logger.w(TAG, "Plugin failure.", it);
UIDialogs.showDialog(context, R.drawable.ic_error_pred, "Failed to get Home\nPlugin [${it.config.name}]", it.message, null, 0, UIDialogs.showDialog(context, R.drawable.ic_error_pred, context.getString(R.string.failed_to_get_home_plugin) + " [${it.config.name}]", it.message, null, 0,
UIDialogs.Action("Ignore", {}), UIDialogs.Action(context.getString(R.string.ignore), {}),
UIDialogs.Action("Sources", { fragment.navigate<SourcesFragment>() }, UIDialogs.ActionStyle.PRIMARY) UIDialogs.Action(context.getString(R.string.sources), { fragment.navigate<SourcesFragment>() }, UIDialogs.ActionStyle.PRIMARY)
); );
} }
.exception<Throwable> { .exception<Throwable> {
Logger.w(TAG, "Failed to load channel.", it); Logger.w(TAG, "Failed to load channel.", it);
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to get Home", it, { UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_get_home), it, {
loadResults() loadResults()
}) { }) {
finishRefreshLayoutLoader(); finishRefreshLayoutLoader();
setLoading(false); setLoading(false);
}; };
}; };
setPreviewsEnabled(Settings.instance.home.previewFeedItems);
} }
fun onShown() { fun onShown() {
@@ -150,7 +160,7 @@ class HomeFragment : MainFragment() {
} }
override fun filterResults(contents: List<IPlatformContent>): List<IPlatformContent> { override fun filterResults(contents: List<IPlatformContent>): List<IPlatformContent> {
return contents.filter { it !is IPlatformVideo || !StateMeta.instance.isVideoHidden(it.url) }; return contents.filter { !StateMeta.instance.isVideoHidden(it.url) && !StateMeta.instance.isCreatorHidden(it.author.url) };
} }
private fun loadResults() { private fun loadResults() {
@@ -159,7 +169,7 @@ 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(), "No home available", "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}");
@@ -113,7 +113,7 @@ class ImportPlaylistsFragment : MainFragment() {
}.exceptionWithParameter<Throwable> { ex, para -> }.exceptionWithParameter<Throwable> { ex, para ->
//setLoading(false); //setLoading(false);
Logger.w(ChannelFragment.TAG, "Failed to load results.", ex); Logger.w(ChannelFragment.TAG, "Failed to load results.", ex);
UIDialogs.toast(context, "Failed to fetch\n${para}", false) UIDialogs.toast(context, context.getString(R.string.failed_to_fetch) + "\n${para}", false)
//UIDialogs.showDataRetryDialog(layoutInflater, { load(); }); //UIDialogs.showDataRetryDialog(layoutInflater, { load(); });
loadNext(); loadNext();
}; };
@@ -144,14 +144,14 @@ class ImportPlaylistsFragment : MainFragment() {
val tb = _fragment.topBar as ImportTopBarFragment?; val tb = _fragment.topBar as ImportTopBarFragment?;
tb?.let { tb?.let {
it.title = "Import Playlists"; it.title = context.getString(R.string.import_playlists);
it.onImport.subscribe(this) { it.onImport.subscribe(this) {
val playlistsToImport = _items.filter { i -> i.selected }.toList(); val playlistsToImport = _items.filter { i -> i.selected }.toList();
for (playlistToImport in playlistsToImport) { for (playlistToImport in playlistsToImport) {
StatePlaylists.instance.createOrUpdatePlaylist(playlistToImport.playlist); StatePlaylists.instance.createOrUpdatePlaylist(playlistToImport.playlist);
} }
UIDialogs.toast("${playlistsToImport.size} playlists imported."); UIDialogs.toast("${playlistsToImport.size} " + context.getString(R.string.playlists_imported));
_fragment.closeSegment(); _fragment.closeSegment();
}; };
} }
@@ -175,7 +175,7 @@ class ImportPlaylistsFragment : MainFragment() {
val itemsSelected = _items.count { i -> i.selected }; val itemsSelected = _items.count { i -> i.selected };
if (itemsSelected > 0) { if (itemsSelected > 0) {
_textSelectDeselectAll.text = context.getString(R.string.deselect_all); _textSelectDeselectAll.text = context.getString(R.string.deselect_all);
_textCounter.text = "$itemsSelected out of ${_items.size} selected"; _textCounter.text = context.getString(R.string.index_out_of_size_selected).replace("{index}", itemsSelected.toString()).replace("{size}", _items.size.toString());
(_fragment.topBar as ImportTopBarFragment?)?.setImportEnabled(true); (_fragment.topBar as ImportTopBarFragment?)?.setImportEnabled(true);
} else { } else {
_textSelectDeselectAll.text = context.getString(R.string.select_all); _textSelectDeselectAll.text = context.getString(R.string.select_all);
@@ -63,6 +63,7 @@ class ImportSubscriptionsFragment : MainFragment() {
private var _textSelectDeselectAll: TextView; private var _textSelectDeselectAll: TextView;
private var _textNothingToImport: TextView; private var _textNothingToImport: TextView;
private var _textCounter: TextView; private var _textCounter: TextView;
private var _textLoadMore: TextView;
private var _adapterView: AnyAdapterView<SelectableIPlatformChannel, ImportSubscriptionViewHolder>; private var _adapterView: AnyAdapterView<SelectableIPlatformChannel, ImportSubscriptionViewHolder>;
private var _links: List<String> = listOf(); private var _links: List<String> = listOf();
private val _items: ArrayList<SelectableIPlatformChannel> = arrayListOf(); private val _items: ArrayList<SelectableIPlatformChannel> = arrayListOf();
@@ -79,6 +80,7 @@ class ImportSubscriptionsFragment : MainFragment() {
_textNothingToImport = findViewById(R.id.nothing_to_import); _textNothingToImport = findViewById(R.id.nothing_to_import);
_textSelectDeselectAll = findViewById(R.id.text_select_deselect_all); _textSelectDeselectAll = findViewById(R.id.text_select_deselect_all);
_textCounter = findViewById(R.id.text_select_counter); _textCounter = findViewById(R.id.text_select_counter);
_textLoadMore = findViewById(R.id.text_load_more);
_spinner = findViewById(R.id.channel_loader); _spinner = findViewById(R.id.channel_loader);
_adapterView = findViewById<RecyclerView>(R.id.recycler_import).asAny( _items) { _adapterView = findViewById<RecyclerView>(R.id.recycler_import).asAny( _items) {
@@ -116,10 +118,23 @@ class ImportSubscriptionsFragment : MainFragment() {
}.exceptionWithParameter<Throwable> { ex, para -> }.exceptionWithParameter<Throwable> { ex, para ->
//setLoading(false); //setLoading(false);
Logger.w(ChannelFragment.TAG, "Failed to load results.", ex); Logger.w(ChannelFragment.TAG, "Failed to load results.", ex);
UIDialogs.toast(context, "Failed to fetch\n${para}", false) UIDialogs.toast(context, context.getString(R.string.failed_to_fetch) + "\n${para}", false)
//UIDialogs.showDataRetryDialog(layoutInflater, { load(); }); //UIDialogs.showDataRetryDialog(layoutInflater, { load(); });
loadNext(); loadNext();
}; };
_textLoadMore.setOnClickListener {
if (!_limitToastShown) {
return@setOnClickListener;
}
_textLoadMore.visibility = View.GONE;
_limitToastShown = false;
_counter = 0;
load();
};
_textLoadMore.visibility = View.GONE;
} }
fun cleanup() { fun cleanup() {
@@ -147,14 +162,14 @@ class ImportSubscriptionsFragment : MainFragment() {
val tb = _fragment.topBar as ImportTopBarFragment?; val tb = _fragment.topBar as ImportTopBarFragment?;
tb?.let { tb?.let {
it.title = "Import Subscriptions"; it.title = context.getString(R.string.import_subscriptions);
it.onImport.subscribe(this) { it.onImport.subscribe(this) {
val subscriptionsToImport = _items.filter { i -> i.selected }.toList(); val subscriptionsToImport = _items.filter { i -> i.selected }.toList();
for (subscriptionToImport in subscriptionsToImport) { for (subscriptionToImport in subscriptionsToImport) {
StateSubscriptions.instance.addSubscription(subscriptionToImport.channel); StateSubscriptions.instance.addSubscription(subscriptionToImport.channel);
} }
UIDialogs.toast("${subscriptionsToImport.size} subscriptions imported."); UIDialogs.toast("${subscriptionsToImport.size} " + context.getString(R.string.subscriptions_imported));
_fragment.closeSegment(); _fragment.closeSegment();
}; };
} }
@@ -165,7 +180,8 @@ class ImportSubscriptionsFragment : MainFragment() {
if (_counter >= MAXIMUM_BATCH_SIZE) { if (_counter >= MAXIMUM_BATCH_SIZE) {
if (!_limitToastShown) { if (!_limitToastShown) {
_limitToastShown = true; _limitToastShown = true;
UIDialogs.toast(context, "Stopped after $MAXIMUM_BATCH_SIZE to avoid rate limit, re-enter to import rest"); _textLoadMore.visibility = View.VISIBLE;
UIDialogs.toast(context, context.getString(R.string.stopped_after_requestcount_to_avoid_rate_limit_click_load_more_to_load_more).replace("{requestCount}", MAXIMUM_BATCH_SIZE.toString()));
} }
setLoading(false); setLoading(false);
@@ -187,7 +203,7 @@ class ImportSubscriptionsFragment : MainFragment() {
val itemsSelected = _items.count { i -> i.selected }; val itemsSelected = _items.count { i -> i.selected };
if (itemsSelected > 0) { if (itemsSelected > 0) {
_textSelectDeselectAll.text = context.getString(R.string.deselect_all); _textSelectDeselectAll.text = context.getString(R.string.deselect_all);
_textCounter.text = "$itemsSelected out of ${_items.size} selected"; _textCounter.text = context.getString(R.string.index_out_of_size_selected).replace("{index}", itemsSelected.toString()).replace("{size}", _items.size.toString());
(_fragment.topBar as ImportTopBarFragment?)?.setImportEnabled(true); (_fragment.topBar as ImportTopBarFragment?)?.setImportEnabled(true);
} else { } else {
_textSelectDeselectAll.text = context.getString(R.string.select_all); _textSelectDeselectAll.text = context.getString(R.string.select_all);
@@ -210,7 +226,7 @@ class ImportSubscriptionsFragment : MainFragment() {
companion object { companion object {
val TAG = "ImportSubscriptionsFragment"; val TAG = "ImportSubscriptionsFragment";
private const val MAXIMUM_BATCH_SIZE = 75; private const val MAXIMUM_BATCH_SIZE = 100;
fun newInstance() = ImportSubscriptionsFragment().apply {} fun newInstance() = ImportSubscriptionsFragment().apply {}
} }
} }

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