mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
Compare commits
138 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17b9853bb6 | |||
| 8bfb8abd20 | |||
| 9ee3f1f26e | |||
| 5dcff29d8d | |||
| 6cfbd0c8bf | |||
| 01d96cce16 | |||
| 58c376f011 | |||
| 439d339330 | |||
| 44eacc2a47 | |||
| 8135d61398 | |||
| 66208f8265 | |||
| f52251e23a | |||
| dbea93efe5 | |||
| 3bf0740bd1 | |||
| fa7f1b11f3 | |||
| ff914bbdf4 | |||
| b822078d4b | |||
| 290d2ceb50 | |||
| 8ec9025990 | |||
| c4cf856dcd | |||
| 38bb4e25d3 | |||
| 0de996d91c | |||
| 1f38c9b27d | |||
| 234f31b02d | |||
| 00e40e8cd6 | |||
| 0bc6a43dc1 | |||
| e7e0157fbc | |||
| 4cae1a41a5 | |||
| 4fa61e7f52 | |||
| f02ac796f5 | |||
| 22146a6bdc | |||
| 5285eae01d | |||
| c47ca369e4 | |||
| f0b1f62bb1 | |||
| f7aa6d006e | |||
| 6b67cd549f | |||
| fc6bf85822 | |||
| fbd9345cf8 | |||
| 63137b4c4d | |||
| e28dc7a3a6 | |||
| 6e14acc685 | |||
| ba64153f1d | |||
| 72c04e7556 | |||
| 54f37ee5b2 | |||
| 4fbb325313 | |||
| e1d3b95f73 | |||
| 8f7b4b8257 | |||
| 9d906025ea | |||
| d7f4dd65e8 | |||
| 599b119e62 | |||
| 41176464db | |||
| dd0ad19fb9 | |||
| 430625d2fb | |||
| 796cd1a776 | |||
| baa26af0c0 | |||
| ea0c27936e | |||
| 4aade35d19 | |||
| 251a5701af | |||
| 2da3116111 | |||
| 4c82fa1a4a | |||
| 7eef6eece2 | |||
| 570f32e980 | |||
| 16a0351125 | |||
| 2fa9005806 | |||
| 25527997fa | |||
| 4655d8369d | |||
| aeaaace3a4 | |||
| e6997004ff | |||
| 5e1896b7f2 | |||
| 88ca90c13a | |||
| f8ee340499 | |||
| 93f5260e20 | |||
| 34ba44ffa4 | |||
| b3a3e459a4 | |||
| f234564952 | |||
| ffa5795cc9 | |||
| 4f50c51356 | |||
| 9e9c8a0bec | |||
| 1349358d7c | |||
| 9c50f15be7 | |||
| 31e771daca | |||
| 66ce156dea | |||
| db6756bc78 | |||
| cab2581476 | |||
| 4c0be35020 | |||
| 7114201c08 | |||
| d8aecd325b | |||
| 1d18c13817 | |||
| f65eb0cd53 | |||
| 206c3884e9 | |||
| 35f9173980 | |||
| 48ab77eadc | |||
| f486513105 | |||
| f338adf033 | |||
| 74be667114 | |||
| b5a1fc92dc | |||
| 9cec1a8c49 | |||
| d4afba929b | |||
| 70939cbac6 | |||
| a3aa61df6d | |||
| e13ab5cb40 | |||
| d059947925 | |||
| d6c4b730de | |||
| 8241863170 | |||
| 31a758e4f3 | |||
| ca971a0e77 | |||
| a45a0f9a8a | |||
| c2dce52a5b | |||
| a2c63c59c5 | |||
| 7e54a2ce3d | |||
| 5b7fb2c818 | |||
| da0ac281e2 | |||
| 576b37f64c | |||
| 26c2db5023 | |||
| f344dbf35c | |||
| a04acbd4a5 | |||
| bd48aba8d3 | |||
| 12b73bb248 | |||
| c3ff897ef4 | |||
| 242728fbe7 | |||
| 14df7c8d43 | |||
| 229377bd6e | |||
| d4317ff06f | |||
| c70dbb56c8 | |||
| f9b772b729 | |||
| bbcc424393 | |||
| f433cb1280 | |||
| 9cf81ad20a | |||
| f65e293e45 | |||
| 9a08762e9e | |||
| 66dbd20a90 | |||
| 8254bcc647 | |||
| 51d0f18168 | |||
| 5dcb535c0f | |||
| b7cbeb3837 | |||
| 2067561c09 | |||
| 1ac70dba3f | |||
| f4370c1bfd |
+3
-2
@@ -4,6 +4,7 @@ variables:
|
||||
stages:
|
||||
- buildAndDeployApkUnstable
|
||||
- buildAndDeployApkStable
|
||||
- buildAndDeployPlaystore
|
||||
|
||||
buildAndDeployApkUnstable:
|
||||
stage: buildAndDeployApkUnstable
|
||||
@@ -25,8 +26,8 @@ buildAndDeployApkStable:
|
||||
- branches
|
||||
when: manual
|
||||
|
||||
buildAndDeployApkStable:
|
||||
stage: buildAndDeployApkStable
|
||||
buildAndDeployPlaystore:
|
||||
stage: buildAndDeployPlaystore
|
||||
script:
|
||||
- sh deploy-playstore.sh
|
||||
only:
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ Thank you for your interest in contributing! This document outlines how you can
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk 29
|
||||
minSdk 28
|
||||
targetSdk 33
|
||||
versionCode gitVersionCode
|
||||
versionName gitVersionName
|
||||
|
||||
+29
-13
@@ -1,13 +1,14 @@
|
||||
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 org.junit.Test
|
||||
|
||||
class EncryptionProviderTests {
|
||||
class GEncryptionProviderTests {
|
||||
@Test
|
||||
fun testEncryptDecrypt() {
|
||||
val encryptionProvider = EncryptionProvider.instance
|
||||
fun testEncryptDecryptV1() {
|
||||
val encryptionProvider = GEncryptionProviderV1.instance
|
||||
val plaintext = "This is a test string."
|
||||
|
||||
// Encrypt the plaintext
|
||||
@@ -22,8 +23,8 @@ class EncryptionProviderTests {
|
||||
|
||||
|
||||
@Test
|
||||
fun testEncryptDecryptBytes() {
|
||||
val encryptionProvider = EncryptionProvider.instance
|
||||
fun testEncryptDecryptBytesV1() {
|
||||
val encryptionProvider = GEncryptionProviderV1.instance
|
||||
val bytes = "This is a test string.".toByteArray();
|
||||
|
||||
// Encrypt the plaintext
|
||||
@@ -36,21 +37,36 @@ class EncryptionProviderTests {
|
||||
assertArrayEquals(bytes, decrypted);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testEncryptDecryptBytesPassword() {
|
||||
val encryptionProvider = EncryptionProvider.instance
|
||||
val bytes = "This is a test string.".toByteArray();
|
||||
val password = "1234".padStart(32, '9');
|
||||
fun testEncryptDecryptV0() {
|
||||
val encryptionProvider = GEncryptionProviderV0.instance
|
||||
val plaintext = "This is a test string."
|
||||
|
||||
// Encrypt the plaintext
|
||||
val ciphertext = encryptionProvider.encrypt(bytes, password)
|
||||
val ciphertext = encryptionProvider.encrypt(plaintext)
|
||||
|
||||
// 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
|
||||
assertArrayEquals(bytes, decrypted);
|
||||
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@
|
||||
android:enabled="true" />
|
||||
|
||||
<receiver android:name=".receivers.MediaControlReceiver" />
|
||||
<receiver android:name=".receivers.AudioNoisyReceiver" />
|
||||
|
||||
<activity
|
||||
android:name=".activities.MainActivity"
|
||||
@@ -91,6 +92,26 @@
|
||||
<data android:host="*" />
|
||||
<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" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
@@ -182,9 +203,8 @@
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
|
||||
|
||||
<activity
|
||||
android:name=".activities.AddSourceOptionsActivity$QRCaptureActivity"
|
||||
android:name=".activities.QRCaptureActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
</application>
|
||||
|
||||
@@ -217,6 +217,9 @@ function pluginUpdateTestPlugin(config) {
|
||||
}
|
||||
function pluginLoginTestPlugin() {
|
||||
return syncGET("/plugin/loginTestPlugin", {});
|
||||
}//captchaLoginTestPlugin
|
||||
function pluginCaptchaTestPlugin(url, html) {
|
||||
return syncPOST("/plugin/captchaTestPlugin?url=" + url, {}, html);
|
||||
}
|
||||
function pluginLogoutTestPlugin() {
|
||||
return syncGET("/plugin/logoutTestPlugin", {});
|
||||
|
||||
@@ -540,6 +540,8 @@
|
||||
<script>
|
||||
IS_TESTING = true;
|
||||
let lastScriptTag = null;
|
||||
let shouldDevLog = true;
|
||||
let shouldLoginCheck = true;
|
||||
new Vue({
|
||||
el: '#app',
|
||||
data: {
|
||||
@@ -603,7 +605,7 @@
|
||||
};
|
||||
setInterval(()=>{
|
||||
try{
|
||||
if(!this.Plugin.currentPlugin)
|
||||
if(!this.Plugin.currentPlugin || !shouldDevLog)
|
||||
return;
|
||||
|
||||
getDevLogs(this.Integration.lastLogIndex, (newLogs)=> {
|
||||
@@ -638,7 +640,8 @@
|
||||
}, 1000);
|
||||
setInterval(()=>{
|
||||
try{
|
||||
this.isTestLoggedIn();
|
||||
if(shouldLoginCheck)
|
||||
this.isTestLoggedIn();
|
||||
}catch(ex){}
|
||||
}, 2500);
|
||||
},
|
||||
@@ -681,6 +684,9 @@
|
||||
});
|
||||
}, 1000);
|
||||
},
|
||||
captchaTestPlugin() {
|
||||
captchaLoginTestPlugin();
|
||||
},
|
||||
logoutTestPlugin() {
|
||||
pluginLogoutTestPlugin();
|
||||
},
|
||||
@@ -838,6 +844,12 @@
|
||||
this.Testing.lastResultError = "";
|
||||
}
|
||||
catch(ex) {
|
||||
if(ex.plugin_type == "CaptchaRequiredException") {
|
||||
let shouldCaptcha = confirm("Do you want to request captcha?");
|
||||
if(shouldCaptcha) {
|
||||
pluginCaptchaTestPlugin(ex.url, ex.body);
|
||||
}
|
||||
}
|
||||
console.error("Failed to run test for " + req.title, ex);
|
||||
this.Testing.lastResult = ""
|
||||
if(ex.message)
|
||||
|
||||
@@ -10,7 +10,8 @@ let Type = {
|
||||
Videos: "VIDEOS",
|
||||
Streams: "STREAMS",
|
||||
Mixed: "MIXED",
|
||||
Live: "LIVE"
|
||||
Live: "LIVE",
|
||||
Subscriptions: "SUBSCRIPTIONS"
|
||||
},
|
||||
Order: {
|
||||
Chronological: "CHRONOLOGICAL"
|
||||
@@ -31,6 +32,12 @@ let Type = {
|
||||
RAW: 0,
|
||||
HTML: 1,
|
||||
MARKUP: 2
|
||||
},
|
||||
Chapter: {
|
||||
NORMAL: 0,
|
||||
|
||||
SKIPPABLE: 5,
|
||||
SKIP: 6
|
||||
}
|
||||
};
|
||||
|
||||
@@ -72,6 +79,11 @@ class CaptchaRequiredException extends Error {
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
class CriticalException extends ScriptException {
|
||||
constructor(msg) {
|
||||
super("CriticalException", msg);
|
||||
}
|
||||
}
|
||||
class UnavailableException extends ScriptException {
|
||||
constructor(msg) {
|
||||
super("UnavailableException", msg);
|
||||
@@ -148,13 +160,27 @@ class FilterCapability {
|
||||
|
||||
|
||||
class PlatformAuthorLink {
|
||||
constructor(id, name, url, thumbnail, subscribers) {
|
||||
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 (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 {
|
||||
|
||||
@@ -185,6 +185,25 @@ fun OffsetDateTime.toHumanNowDiffString(abs: Boolean = false) : String {
|
||||
|
||||
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 {
|
||||
var scaler = 1;
|
||||
|
||||
@@ -35,4 +35,8 @@ fun Protocol.ImageBundle?.selectHighestResolutionImage(): Protocol.ImageManifest
|
||||
|
||||
fun Protocol.Claim.resolveChannelUrl(): String? {
|
||||
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) })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import android.webkit.CookieManager
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.activities.*
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.cache.ChannelContentCache
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -20,6 +21,8 @@ import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
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.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -27,6 +30,7 @@ import kotlinx.serialization.*
|
||||
import kotlinx.serialization.json.*
|
||||
import java.io.File
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.Locale
|
||||
|
||||
@Serializable
|
||||
data class MenuBottomBarSetting(val id: Int, var enabled: Boolean);
|
||||
@@ -41,10 +45,8 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@Transient
|
||||
val onTabsChanged = Event0();
|
||||
|
||||
@FormField(
|
||||
"Manage Polycentric identity", FieldForm.BUTTON,
|
||||
"Manage your Polycentric identity", -2
|
||||
)
|
||||
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -5)
|
||||
@FormFieldButton(R.drawable.ic_person)
|
||||
fun managePolycentricIdentity() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
if (StatePolycentric.instance.processHandle != null) {
|
||||
@@ -55,15 +57,39 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.show_faq, FieldForm.BUTTON, R.string.get_answers_to_common_questions, -4)
|
||||
@FormFieldButton(R.drawable.ic_quiz)
|
||||
fun openFAQ() {
|
||||
try {
|
||||
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);
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@FormField(
|
||||
"Submit feedback", FieldForm.BUTTON,
|
||||
"Give feedback on the application", -1
|
||||
R.string.submit_feedback, FieldForm.BUTTON,
|
||||
R.string.give_feedback_on_the_application, -1
|
||||
)
|
||||
@FormFieldButton(R.drawable.ic_bug)
|
||||
fun submitFeedback() {
|
||||
try {
|
||||
val i = Intent(Intent.ACTION_VIEW);
|
||||
val subject = "Feedback Grayjay";
|
||||
val body = "Hey,\n\nI have some feedback on the Grayjay app.\nVersion information (version_name = ${BuildConfig.VERSION_NAME}, version_code = ${BuildConfig.VERSION_CODE}, flavor = ${BuildConfig.FLAVOR}, build_type = ${BuildConfig.BUILD_TYPE}})\n\n";
|
||||
val body = "Hey,\n\nI have some feedback on the Grayjay app.\nVersion 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";
|
||||
val data = Uri.parse("mailto:grayjay@futo.org?subject=" + Uri.encode(subject) + "&body=" + Uri.encode(body));
|
||||
i.data = data;
|
||||
|
||||
@@ -71,12 +97,10 @@ class Settings : FragmentedStorageFileJson() {
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
@FormField(
|
||||
"Manage Tabs", FieldForm.BUTTON,
|
||||
"Change tabs visible on the home screen", -1
|
||||
)
|
||||
@FormField(R.string.manage_tabs, FieldForm.BUTTON, R.string.change_tabs_visible_on_the_home_screen, -2)
|
||||
@FormFieldButton(R.drawable.ic_tabs)
|
||||
fun manageTabs() {
|
||||
try {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
@@ -87,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();
|
||||
@Serializable
|
||||
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)
|
||||
var homeFeedStyle: Int = 1;
|
||||
|
||||
@@ -101,21 +153,39 @@ class Settings : FragmentedStorageFileJson() {
|
||||
else
|
||||
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.clear_hidden, FieldForm.BUTTON, R.string.clear_hidden_description, 7)
|
||||
@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();
|
||||
@Serializable
|
||||
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)
|
||||
var searchHistory: Boolean = true;
|
||||
|
||||
|
||||
@FormField("Feed Style", FieldForm.DROPDOWN, "", 5)
|
||||
@FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 4)
|
||||
@DropdownFieldOptionsId(R.array.feed_style)
|
||||
var searchFeedStyle: Int = 1;
|
||||
|
||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
|
||||
var previewFeedItems: Boolean = true;
|
||||
|
||||
|
||||
|
||||
fun getSearchFeedStyle(): FeedStyle {
|
||||
if(searchFeedStyle == 0)
|
||||
@@ -125,11 +195,11 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField("Subscriptions", "group", "Configure how your Subscriptions works and feels", 3)
|
||||
@FormField(R.string.subscriptions, "group", R.string.configure_how_your_subscriptions_works_and_feels, 3)
|
||||
var subscriptions = SubscriptionsSettings();
|
||||
@Serializable
|
||||
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)
|
||||
var subscriptionsFeedStyle: Int = 1;
|
||||
|
||||
@@ -140,7 +210,17 @@ class Settings : FragmentedStorageFileJson() {
|
||||
return FeedStyle.THUMBNAIL;
|
||||
}
|
||||
|
||||
@FormField("Background Update", FieldForm.DROPDOWN, "Experimental background update for subscriptions cache (requires restart)", 6)
|
||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
|
||||
var previewFeedItems: Boolean = true;
|
||||
|
||||
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 6)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var fetchOnAppBoot: Boolean = true;
|
||||
|
||||
@FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 6)
|
||||
var fetchOnTabOpen: Boolean = true;
|
||||
|
||||
@FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 7)
|
||||
@DropdownFieldOptionsId(R.array.background_interval)
|
||||
var subscriptionsBackgroundUpdateInterval: Int = 0;
|
||||
|
||||
@@ -156,26 +236,43 @@ class Settings : FragmentedStorageFileJson() {
|
||||
};
|
||||
|
||||
|
||||
@FormField("Subscription Concurrency", FieldForm.DROPDOWN, "Specify how many threads are used to fetch channels (requires restart)", 7)
|
||||
@FormField(R.string.subscription_concurrency, FieldForm.DROPDOWN, R.string.specify_how_many_threads_are_used_to_fetch_channels, 8)
|
||||
@DropdownFieldOptionsId(R.array.thread_count)
|
||||
var subscriptionConcurrency: Int = 3;
|
||||
|
||||
fun getSubscriptionsConcurrency() : Int {
|
||||
return threadIndexToCount(subscriptionConcurrency);
|
||||
}
|
||||
|
||||
@FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 9)
|
||||
var showWatchMetrics: Boolean = false;
|
||||
|
||||
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 10)
|
||||
var allowPlaytimeTracking: Boolean = true;
|
||||
|
||||
|
||||
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 11)
|
||||
var alwaysReloadFromCache: Boolean = false;
|
||||
|
||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 12)
|
||||
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, 4)
|
||||
var playback = PlaybackSettings();
|
||||
@Serializable
|
||||
class PlaybackSettings {
|
||||
@FormField("Primary Language", FieldForm.DROPDOWN, "", 0)
|
||||
@DropdownFieldOptionsId(R.array.languages)
|
||||
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, 0)
|
||||
@DropdownFieldOptionsId(R.array.audio_languages)
|
||||
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)
|
||||
var defaultPlaybackSpeed: Int = 3;
|
||||
fun getDefaultPlaybackSpeed(): Float = when(defaultPlaybackSpeed) {
|
||||
@@ -191,29 +288,29 @@ class Settings : FragmentedStorageFileJson() {
|
||||
else -> 1.0f;
|
||||
};
|
||||
|
||||
@FormField("Preferred Quality", FieldForm.DROPDOWN, "", 2)
|
||||
@FormField(R.string.preferred_quality, FieldForm.DROPDOWN, -1, 2)
|
||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||
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)
|
||||
var preferredMeteredQuality: Int = 0;
|
||||
fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality);
|
||||
fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality);
|
||||
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)
|
||||
var preferredPreviewQuality: Int = 5;
|
||||
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)
|
||||
var autoRotate: Int = 2;
|
||||
|
||||
fun isAutoRotate() = autoRotate == 1 || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate());
|
||||
|
||||
@FormField("Auto-Rotate Dead Zone", FieldForm.DROPDOWN, "Auto-rotate deadzone in 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)
|
||||
var autoRotateDeadZone: Int = 0;
|
||||
|
||||
@@ -221,21 +318,17 @@ class Settings : FragmentedStorageFileJson() {
|
||||
return autoRotateDeadZone * 5;
|
||||
}
|
||||
|
||||
@FormField("Background Behavior", FieldForm.DROPDOWN, "", 6)
|
||||
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 6)
|
||||
@DropdownFieldOptionsId(R.array.player_background_behavior)
|
||||
var backgroundPlay: Int = 2;
|
||||
|
||||
fun isBackgroundContinue() = backgroundPlay == 1;
|
||||
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)
|
||||
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{
|
||||
if(resumeAfterPreview == 2)
|
||||
return true;
|
||||
@@ -243,14 +336,22 @@ class Settings : FragmentedStorageFileJson() {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 8)
|
||||
var useLiveChatWindow: Boolean = true;
|
||||
|
||||
|
||||
|
||||
@FormField(R.string.background_switch_audio, FieldForm.TOGGLE, R.string.background_switch_audio_description, 8)
|
||||
var backgroundSwitchToAudio: Boolean = true;
|
||||
}
|
||||
|
||||
@FormField("Downloads", "group", "Configure downloading of videos", 5)
|
||||
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 5)
|
||||
var downloads = Downloads();
|
||||
@Serializable
|
||||
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)
|
||||
var whenDownload: Int = 0;
|
||||
|
||||
@@ -263,21 +364,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)
|
||||
var preferredVideoQuality: Int = 4;
|
||||
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)
|
||||
var preferredAudioQuality: Int = 1;
|
||||
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)
|
||||
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)
|
||||
var byteRangeConcurrency: Int = 3;
|
||||
fun getByteRangeThreadCount(): Int {
|
||||
@@ -285,20 +386,20 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField("Browsing", "group", "Configure browsing behavior", 6)
|
||||
@FormField(R.string.browsing, "group", R.string.configure_browsing_behavior, 6)
|
||||
var browsing = Browsing();
|
||||
@Serializable
|
||||
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)
|
||||
var videoCache: Boolean = true;
|
||||
}
|
||||
|
||||
@FormField("Casting", "group", "Configure casting", 7)
|
||||
@FormField(R.string.casting, "group", R.string.configure_casting, 7)
|
||||
var casting = Casting();
|
||||
@Serializable
|
||||
class Casting {
|
||||
@FormField("Enabled", FieldForm.TOGGLE, "Enable casting", 0)
|
||||
@FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enable_casting, 0)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var enabled: Boolean = true;
|
||||
|
||||
@@ -320,24 +421,21 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
|
||||
|
||||
@FormField("Logging", FieldForm.GROUP, "", 8)
|
||||
@FormField(R.string.logging, FieldForm.GROUP, -1, 8)
|
||||
var logging = Logging();
|
||||
@Serializable
|
||||
class Logging {
|
||||
@FormField("Log Level", FieldForm.DROPDOWN, "", 0)
|
||||
@FormField(R.string.log_level, FieldForm.DROPDOWN, -1, 0)
|
||||
@DropdownFieldOptionsId(R.array.log_levels)
|
||||
var logLevel: Int = 0;
|
||||
|
||||
@FormField(
|
||||
"Submit logs", FieldForm.BUTTON,
|
||||
"Submit logs to help us narrow down issues", 1
|
||||
)
|
||||
@FormField(R.string.submit_logs, FieldForm.BUTTON, R.string.submit_logs_to_help_us_narrow_down_issues, 1)
|
||||
fun submitLogs() {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
if (!Logger.submitLogs()) {
|
||||
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) {
|
||||
@@ -349,41 +447,32 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
|
||||
|
||||
@FormField("Announcement", FieldForm.GROUP, "", 10)
|
||||
@FormField(R.string.announcement, FieldForm.GROUP, -1, 10)
|
||||
var announcementSettings = AnnouncementSettings();
|
||||
@Serializable
|
||||
class AnnouncementSettings {
|
||||
@FormField(
|
||||
"Reset announcements", FieldForm.BUTTON,
|
||||
"Reset hidden announcements", 1
|
||||
)
|
||||
@FormField(R.string.reset_announcements, FieldForm.BUTTON, R.string.reset_hidden_announcements, 1)
|
||||
fun 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.plugins, FieldForm.GROUP, -1, 11)
|
||||
@Transient
|
||||
var plugins = Plugins();
|
||||
@Serializable
|
||||
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;
|
||||
|
||||
@FormField(
|
||||
"Clear Cookies", FieldForm.BUTTON,
|
||||
"Clears in-app browser cookies, especially useful for fully logging out of plugins.", 1
|
||||
)
|
||||
@FormField(R.string.clear_cookies, FieldForm.BUTTON, R.string.clears_in_app_browser_cookies, 1)
|
||||
fun clearCookies() {
|
||||
val cookieManager: CookieManager = CookieManager.getInstance();
|
||||
cookieManager.removeAllCookies(null);
|
||||
}
|
||||
@FormField(
|
||||
"Reinstall Embedded Plugins", FieldForm.BUTTON,
|
||||
"Also removes any data related plugin like login or settings (may not clear browser cache)", 1
|
||||
)
|
||||
@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
|
||||
fun reinstallEmbedded() {
|
||||
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -391,7 +480,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
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) {
|
||||
@@ -406,7 +495,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
|
||||
|
||||
@FormField("External Storage", FieldForm.GROUP, "", 12)
|
||||
@FormField(R.string.external_storage, FieldForm.GROUP, -1, 12)
|
||||
var storage = Storage();
|
||||
@Serializable
|
||||
class Storage {
|
||||
@@ -418,34 +507,41 @@ class Settings : FragmentedStorageFileJson() {
|
||||
fun isStorageMainValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageGeneralUri());
|
||||
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() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
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() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
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, 12)
|
||||
var autoUpdate = AutoUpdate();
|
||||
@Serializable
|
||||
class AutoUpdate {
|
||||
@FormField("Check", FieldForm.DROPDOWN, "", 0)
|
||||
@FormField(R.string.check, FieldForm.DROPDOWN, -1, 0)
|
||||
@DropdownFieldOptionsId(R.array.auto_update_when_array)
|
||||
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)
|
||||
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)
|
||||
var whenDownload: Int = 0;
|
||||
|
||||
@@ -462,10 +558,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
return check == 0 && !BuildConfig.IS_PLAYSTORE_BUILD;
|
||||
}
|
||||
|
||||
@FormField(
|
||||
"Manual check", FieldForm.BUTTON,
|
||||
"Manually check for updates", 3
|
||||
)
|
||||
@FormField(R.string.manual_check, FieldForm.BUTTON, R.string.manually_check_for_updates, 3)
|
||||
fun manualCheck() {
|
||||
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
@@ -476,19 +569,17 @@ class Settings : FragmentedStorageFileJson() {
|
||||
try {
|
||||
it.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${it.packageName}")))
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
UIDialogs.toast(it, "Failed to show store.");
|
||||
UIDialogs.toast(it, it.getString(R.string.failed_to_show_store));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(
|
||||
"View changelog", FieldForm.BUTTON,
|
||||
"Review the current and past changelogs", 4
|
||||
)
|
||||
@FormField(R.string.view_changelog, FieldForm.BUTTON, R.string.review_the_current_and_past_changelogs, 4)
|
||||
fun viewChangelog() {
|
||||
UIDialogs.toast("Retrieving changelog");
|
||||
SettingsActivity.getActivity()?.let {
|
||||
UIDialogs.toast(it.getString(R.string.retrieving_changelog));
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val version = StateUpdate.instance.downloadVersionCode(ManagedHttpClient()) ?: return@launch;
|
||||
@@ -504,10 +595,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
};
|
||||
}
|
||||
|
||||
@FormField(
|
||||
"Remove Cached Version", FieldForm.BUTTON,
|
||||
"Remove the last downloaded version", 5
|
||||
)
|
||||
@FormField(R.string.remove_cached_version, FieldForm.BUTTON, R.string.remove_the_last_downloaded_version, 5)
|
||||
fun removeCachedVersion() {
|
||||
StateApp.withContext {
|
||||
val outputDirectory = File(it.filesDir, "autoupdate");
|
||||
@@ -523,7 +611,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField("Backup", FieldForm.GROUP, "", 13)
|
||||
@FormField(R.string.backup, FieldForm.GROUP, -1, 13)
|
||||
var backup = Backup();
|
||||
@Serializable
|
||||
class Backup {
|
||||
@@ -533,58 +621,84 @@ class Settings : FragmentedStorageFileJson() {
|
||||
var autoBackupPassword: String? = 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";
|
||||
|
||||
@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() {
|
||||
UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!, autoBackupPassword != null) {
|
||||
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() {
|
||||
val activity = SettingsActivity.getActivity()!!
|
||||
|
||||
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
|
||||
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() {
|
||||
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, 14)
|
||||
var payment = Payment();
|
||||
@Serializable
|
||||
class Payment {
|
||||
@FormField("Payment Status", FieldForm.READONLYTEXT, "", 1)
|
||||
val paymentStatus: String get() = if (StatePayment.instance.hasPaid) "Paid" else "Not Paid";
|
||||
@FormField(R.string.payment_status, FieldForm.READONLYTEXT, -1, 1)
|
||||
val paymentStatus: String get() = SettingsActivity.getActivity()?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown";
|
||||
|
||||
@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() {
|
||||
StatePayment.instance.clearLicenses();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField("Info", FieldForm.GROUP, "", 15)
|
||||
@FormField(R.string.other, FieldForm.GROUP, -1, 15)
|
||||
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, 16)
|
||||
var info = Info();
|
||||
@Serializable
|
||||
class Info {
|
||||
@FormField("Version Code", FieldForm.READONLYTEXT, "", 1, "code")
|
||||
@FormField(R.string.version_code, FieldForm.READONLYTEXT, -1, 1, "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;
|
||||
@FormField("Version Type", FieldForm.READONLYTEXT, "", 3)
|
||||
@FormField(R.string.version_type, FieldForm.READONLYTEXT, -1, 3)
|
||||
var versionType = BuildConfig.BUILD_TYPE;
|
||||
}
|
||||
|
||||
@@ -595,6 +709,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "Settings";
|
||||
const val URL_FAQ = "https://grayjay.app/faq.html";
|
||||
|
||||
private var _isFirst = true;
|
||||
|
||||
|
||||
@@ -2,14 +2,24 @@ package com.futo.platformplayer
|
||||
|
||||
import android.content.Context
|
||||
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.V8ValueString
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
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.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.background.BackgroundWorker
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
||||
@@ -17,6 +27,7 @@ import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.FragmentedStorageFileJson
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
@@ -27,28 +38,30 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.json.*
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.stream.IntStream.range
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@Serializable()
|
||||
class SettingsDev : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField("Developer Mode", FieldForm.TOGGLE, "", 0)
|
||||
@FormField(R.string.developer_mode, FieldForm.TOGGLE, -1, 0)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var developerMode: Boolean = false;
|
||||
|
||||
@FormField("Development Server", FieldForm.GROUP,
|
||||
"Settings related to development server, be careful as it may open your phone to security vulnerabilities", 1)
|
||||
@FormField(R.string.development_server, FieldForm.GROUP,
|
||||
R.string.settings_related_to_development_server_be_careful_as_it_may_open_your_phone_to_security_vulnerabilities, 1)
|
||||
val devServerSettings: DeveloperServerFields = DeveloperServerFields();
|
||||
@Serializable
|
||||
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)
|
||||
var devServerOnBoot: Boolean = false;
|
||||
|
||||
@FormField("Start Server", FieldForm.BUTTON,
|
||||
"Starts a DevServer on port 11337, may expose vulnerabilities.", 1)
|
||||
@FormField(R.string.start_server, FieldForm.BUTTON,
|
||||
R.string.starts_a_devServer_on_port_11337_may_expose_vulnerabilities, 1)
|
||||
fun startServer() {
|
||||
StateDeveloper.instance.runServer();
|
||||
StateApp.instance.contextOrNull?.let {
|
||||
@@ -57,45 +70,57 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField("Experimental", FieldForm.GROUP,
|
||||
"Settings related to development server, be careful as it may open your phone to security vulnerabilities", 2)
|
||||
@FormField(R.string.experimental, FieldForm.GROUP,
|
||||
R.string.settings_related_to_development_server_be_careful_as_it_may_open_your_phone_to_security_vulnerabilities, 2)
|
||||
val experimentalSettings: ExperimentalFields = ExperimentalFields();
|
||||
@Serializable
|
||||
class ExperimentalFields {
|
||||
|
||||
@FormField("Background Subscription Testing", FieldForm.TOGGLE, "", 0)
|
||||
@FormField(R.string.background_subscription_testing, FieldForm.TOGGLE, -1, 0)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var backgroundSubscriptionFetching: Boolean = false;
|
||||
}
|
||||
|
||||
@FormField("Crash Me", FieldForm.BUTTON,
|
||||
"Crashes the application on purpose", 2)
|
||||
@FormField(R.string.crash_me, FieldForm.BUTTON,
|
||||
R.string.crashes_the_application_on_purpose, 2)
|
||||
fun crashMe() {
|
||||
throw java.lang.IllegalStateException("This is an uncaught exception triggered on purpose!");
|
||||
}
|
||||
|
||||
@FormField("Delete Announcements", FieldForm.BUTTON,
|
||||
"Delete all announcements", 2)
|
||||
@FormField(R.string.delete_announcements, FieldForm.BUTTON,
|
||||
R.string.delete_all_announcements, 2)
|
||||
fun deleteAnnouncements() {
|
||||
StateAnnouncement.instance.deleteAllAnnouncements();
|
||||
}
|
||||
|
||||
@FormField("Clear Cookies", FieldForm.BUTTON,
|
||||
"Clear all cook from the CookieManager", 2)
|
||||
@FormField(R.string.clear_cookies, FieldForm.BUTTON,
|
||||
R.string.clear_all_cookies_from_the_cookieManager, 2)
|
||||
fun clearCookies() {
|
||||
val cookieManager: CookieManager = CookieManager.getInstance()
|
||||
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);
|
||||
}
|
||||
|
||||
@Contextual
|
||||
@Transient
|
||||
@FormField("V8 Benchmarks", FieldForm.GROUP,
|
||||
"Various benchmarks using the integrated V8 engine", 3)
|
||||
@FormField(R.string.v8_benchmarks, FieldForm.GROUP,
|
||||
R.string.various_benchmarks_using_the_integrated_v8_engine, 4)
|
||||
val v8Benchmarks: V8Benchmarks = V8Benchmarks();
|
||||
class V8Benchmarks {
|
||||
@FormField(
|
||||
"Test V8 Creation speed", FieldForm.BUTTON,
|
||||
"Tests V8 creation times and running", 1
|
||||
R.string.test_v8_creation_speed, FieldForm.BUTTON,
|
||||
R.string.tests_v8_creation_times_and_running, 1
|
||||
)
|
||||
fun testV8Creation() {
|
||||
var plugin: V8Plugin? = null;
|
||||
@@ -137,8 +162,8 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
}
|
||||
|
||||
@FormField(
|
||||
"Test V8 Communication speed", FieldForm.BUTTON,
|
||||
"Tests V8 communication speeds", 2
|
||||
R.string.test_v8_communication_speed, FieldForm.BUTTON,
|
||||
R.string.tests_v8_communication_speeds, 4
|
||||
)
|
||||
fun testV8RunSpeeds() {
|
||||
var plugin: V8Plugin? = null;
|
||||
@@ -182,12 +207,12 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
|
||||
@Contextual
|
||||
@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();
|
||||
class V8ScriptTests {
|
||||
@Contextual
|
||||
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() {
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -203,7 +228,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() {
|
||||
runTestPlugin(_currentPlugin) {
|
||||
var home: IPager<IPlatformContent>? = null;
|
||||
@@ -269,27 +294,36 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
|
||||
@Contextual
|
||||
@Transient
|
||||
@FormField("Other", FieldForm.GROUP, "Others...", 5)
|
||||
@FormField(R.string.other, FieldForm.GROUP, R.string.others_ellipsis, 5)
|
||||
val otherTests: OtherTests = 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() {
|
||||
StateDownloads.instance.getDownloading().forEach {
|
||||
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() {
|
||||
StateDownloads.instance.getDownloadedVideos().forEach {
|
||||
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() {
|
||||
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?) {
|
||||
val gigabuffer = ByteArray(1024 * 1024 * 128);
|
||||
var count: Long = 0;
|
||||
|
||||
@@ -100,12 +100,12 @@ class UIDialogs {
|
||||
dialog.show();
|
||||
};
|
||||
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.Action("Cancel", {}), //To nothing
|
||||
UIDialogs.Action("Override", {
|
||||
UIDialogs.showDialog(context, R.drawable.ic_move_up, context.getString(R.string.an_old_backup_is_available), context.getString(R.string.would_you_like_to_restore_this_backup), null, 0,
|
||||
UIDialogs.Action(context.getString(R.string.cancel), {}), //To nothing
|
||||
UIDialogs.Action(context.getString(R.string.override), {
|
||||
dialogAction();
|
||||
}, UIDialogs.ActionStyle.DANGEROUS),
|
||||
UIDialogs.Action("Restore", {
|
||||
UIDialogs.Action(context.getString(R.string.restore), {
|
||||
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
else {
|
||||
@@ -211,10 +211,10 @@ class UIDialogs {
|
||||
(if(ex != null ) "${ex.message}" else ""),
|
||||
if(ex is PluginException) ex.code else null,
|
||||
0,
|
||||
UIDialogs.Action("Retry", {
|
||||
UIDialogs.Action(context.getString(R.string.retry), {
|
||||
retryAction?.invoke();
|
||||
}, UIDialogs.ActionStyle.PRIMARY),
|
||||
UIDialogs.Action("Close", {
|
||||
UIDialogs.Action(context.getString(R.string.close), {
|
||||
closeAction?.invoke()
|
||||
}, UIDialogs.ActionStyle.NONE)
|
||||
);
|
||||
@@ -226,15 +226,15 @@ class UIDialogs {
|
||||
}
|
||||
|
||||
fun showDataRetryDialog(context: Context, reason: String? = null, retryAction: (() -> Unit)? = null, closeAction: (() -> Unit)? = null) {
|
||||
val retryButtonAction = Action("Retry", retryAction ?: {}, ActionStyle.PRIMARY)
|
||||
val closeButtonAction = Action("Close", closeAction ?: {}, ActionStyle.ACCENT)
|
||||
showDialog(context, R.drawable.ic_no_internet_86dp, "Data Retry", reason, null, 0, closeButtonAction, retryButtonAction)
|
||||
val retryButtonAction = Action(context.getString(R.string.retry), retryAction ?: {}, ActionStyle.PRIMARY)
|
||||
val closeButtonAction = Action(context.getString(R.string.close), closeAction ?: {}, ActionStyle.ACCENT)
|
||||
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) {
|
||||
val confirmButtonAction = Action("Confirm", action, ActionStyle.PRIMARY)
|
||||
val cancelButtonAction = Action("Cancel", cancelAction ?: {}, ActionStyle.ACCENT)
|
||||
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
||||
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
||||
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package com.futo.platformplayer
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.graphics.Color
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||
@@ -16,6 +20,7 @@ import com.futo.platformplayer.downloads.VideoLocal
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Playlist
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.states.*
|
||||
import com.futo.platformplayer.views.Loader
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
|
||||
@@ -45,6 +50,66 @@ class UISlideOverlays {
|
||||
menu.show();
|
||||
}
|
||||
|
||||
fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup) {
|
||||
val items = arrayListOf<View>();
|
||||
var menu: SlideUpMenuOverlay? = null;
|
||||
|
||||
val originalNotif = subscription.doNotifications;
|
||||
val originalLive = subscription.doFetchLive;
|
||||
val originalStream = subscription.doFetchStreams;
|
||||
val originalVideo = subscription.doFetchVideos;
|
||||
val originalPosts = subscription.doFetchPosts;
|
||||
|
||||
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()),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_live_tv, "Livestreams", "Check for livestreams", "fetchLive", {
|
||||
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
|
||||
}, false),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_play, "Streams", "Check for finished streams", "fetchStreams", {
|
||||
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchLive;
|
||||
}, false),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_play, "Videos", "Check for videos", "fetchVideos", {
|
||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchLive;
|
||||
}, false),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", {
|
||||
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchLive;
|
||||
}, false)));
|
||||
|
||||
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? {
|
||||
val items = arrayListOf<View>();
|
||||
var menu: SlideUpMenuOverlay? = null;
|
||||
@@ -64,22 +129,22 @@ class UISlideOverlays {
|
||||
val subtitleSources = video.subtitles;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if(!VideoHelper.isDownloadable(video)) {
|
||||
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;
|
||||
}
|
||||
|
||||
items.add(SlideUpMenuGroup(container.context, "Video", videoSources,
|
||||
listOf(listOf(SlideUpMenuItem(container.context, R.drawable.ic_movie, "None", "Audio Only", "none", {
|
||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
|
||||
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;
|
||||
menu?.selectOption(videoSources, "none");
|
||||
if(selectedAudio != null || !requiresAudio)
|
||||
menu?.setOk("Download");
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
}, false)) +
|
||||
videoSources
|
||||
.filter { it.isDownloadable() }
|
||||
@@ -88,7 +153,7 @@ class UISlideOverlays {
|
||||
selectedVideo = it as IVideoUrlSource;
|
||||
menu?.selectOption(videoSources, it);
|
||||
if(selectedAudio != null || !requiresAudio)
|
||||
menu?.setOk("Download");
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
}, false)
|
||||
}).flatten().toList()
|
||||
));
|
||||
@@ -100,13 +165,13 @@ class UISlideOverlays {
|
||||
|
||||
|
||||
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) }
|
||||
.map {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
|
||||
selectedAudio = it as IAudioUrlSource;
|
||||
menu?.selectOption(audioSources, it);
|
||||
menu?.setOk("Download");
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
}, false);
|
||||
}));
|
||||
val asources = audioSources;
|
||||
@@ -125,7 +190,7 @@ class UISlideOverlays {
|
||||
|
||||
//ContentResolver is required for subtitles..
|
||||
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 {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {
|
||||
if (selectedSubtitle == it) {
|
||||
@@ -139,7 +204,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) {
|
||||
menu.selectOption(videoSources, selectedVideo);
|
||||
@@ -148,7 +213,7 @@ class UISlideOverlays {
|
||||
audioSources?.let { audioSources -> menu.selectOption(audioSources, selectedAudio); };
|
||||
}
|
||||
if(selectedAudio != null || (!requiresAudio && selectedVideo != null)) {
|
||||
menu.setOk("Download");
|
||||
menu.setOk(container.context.getString(R.string.download));
|
||||
}
|
||||
|
||||
menu.onOK.subscribe {
|
||||
@@ -185,7 +250,7 @@ class UISlideOverlays {
|
||||
}
|
||||
fun showDownloadVideoOverlay(video: IPlatformVideo, container: ViewGroup, useDetails: Boolean = false) {
|
||||
val handleUnknownDownload: ()->Unit = {
|
||||
showUnknownVideoDownload("Video", container) { px, bitrate ->
|
||||
showUnknownVideoDownload(container.context.getString(R.string.video), container) { px, bitrate ->
|
||||
StateDownloads.instance.download(video, px, bitrate)
|
||||
};
|
||||
};
|
||||
@@ -195,7 +260,7 @@ class UISlideOverlays {
|
||||
val scope = StateApp.instance.scopeOrNull;
|
||||
|
||||
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) {
|
||||
try {
|
||||
val videoDetails = StatePlatform.instance.getContentDetails(video.url, false).await();
|
||||
@@ -209,7 +274,7 @@ class UISlideOverlays {
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
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();
|
||||
loader.hide(true);
|
||||
}
|
||||
@@ -220,7 +285,7 @@ class UISlideOverlays {
|
||||
}
|
||||
}
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -232,7 +297,7 @@ class UISlideOverlays {
|
||||
var targetBitrate: Long = 0;
|
||||
|
||||
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>("720P", "1280x720", 1280*720),
|
||||
Triple<String, String, Long>("1080P", "1920x1080", 1920*1080),
|
||||
@@ -240,23 +305,23 @@ class UISlideOverlays {
|
||||
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, {
|
||||
targetPxSize = it.third;
|
||||
menu?.selectOption("Video", it.third);
|
||||
}, false)
|
||||
}));
|
||||
|
||||
items.add(SlideUpMenuGroup(container.context, "Target Bitrate", "Bitrate", listOf(
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, "Low Bitrate", "", 1, {
|
||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.target_bitrate), "Bitrate", listOf(
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.low_bitrate), "", 1, {
|
||||
targetBitrate = 1;
|
||||
menu?.selectOption("Bitrate", 1);
|
||||
menu?.setOk("Download");
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
}, 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;
|
||||
menu?.selectOption("Bitrate", 9999999);
|
||||
menu?.setOk("Download");
|
||||
menu?.setOk(container.context.getString(R.string.download));
|
||||
}, false)
|
||||
)));
|
||||
|
||||
@@ -277,12 +342,12 @@ class UISlideOverlays {
|
||||
if(Settings.instance.downloads.isHighBitrateDefault()) {
|
||||
targetBitrate = 9999999;
|
||||
menu.selectOption("Bitrate", 9999999);
|
||||
menu.setOk("Download");
|
||||
menu.setOk(container.context.getString(R.string.download));
|
||||
}
|
||||
else {
|
||||
targetBitrate = 1;
|
||||
menu.selectOption("Bitrate", 1);
|
||||
menu.setOk("Download");
|
||||
menu.setOk(container.context.getString(R.string.download));
|
||||
}
|
||||
|
||||
menu.onOK.subscribe {
|
||||
@@ -304,14 +369,14 @@ class UISlideOverlays {
|
||||
return overlay;
|
||||
}
|
||||
|
||||
fun showVideoOptionsOverlay(video: IPlatformVideo, container: ViewGroup, onVideoHidden: (()->Unit)? = null): SlideUpMenuOverlay {
|
||||
fun showVideoOptionsOverlay(video: IPlatformVideo, container: ViewGroup, vararg actions: SlideUpMenuItem): SlideUpMenuOverlay {
|
||||
val items = arrayListOf<View>();
|
||||
val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist();
|
||||
|
||||
if (lastUpdated != null) {
|
||||
items.add(
|
||||
SlideUpMenuGroup(container.context, "Recently Used Playlist", "recentlyusedplaylist",
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} videos", "",
|
||||
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} " + container.context.getString(R.string.videos), "",
|
||||
{
|
||||
StatePlaylists.instance.addToPlaylist(lastUpdated.id, video);
|
||||
StateDownloads.instance.checkForOutdatedPlaylists();
|
||||
@@ -322,23 +387,28 @@ class UISlideOverlays {
|
||||
val allPlaylists = StatePlaylists.instance.getPlaylists();
|
||||
val queue = StatePlayer.instance.getQueue();
|
||||
val watchLater = StatePlaylists.instance.getWatchLater();
|
||||
items.add(SlideUpMenuGroup(container.context, "Actions", "actions",
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, "Hide", "Hide from Home", "hide",
|
||||
{ StateMeta.instance.addHiddenVideo(video.url); onVideoHidden?.invoke() }),
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download",
|
||||
{ showDownloadVideoOverlay(video, container, true); }, false)
|
||||
))
|
||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
|
||||
(listOf(
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), container.context.getString(R.string.download), {
|
||||
showDownloadVideoOverlay(video, container, true);
|
||||
}, 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(
|
||||
SlideUpMenuGroup(container.context, "Add To", "addto",
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_queue_add, "Add to Queue", "${queue.size} videos", "queue",
|
||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto",
|
||||
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); }),
|
||||
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)); })
|
||||
));
|
||||
|
||||
val playlistItems = arrayListOf<SlideUpMenuItem>();
|
||||
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);
|
||||
StateDownloads.instance.checkForOutdatedPlaylists();
|
||||
@@ -346,9 +416,9 @@ class UISlideOverlays {
|
||||
}
|
||||
|
||||
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 +430,8 @@ class UISlideOverlays {
|
||||
|
||||
if (lastUpdated != null) {
|
||||
items.add(
|
||||
SlideUpMenuGroup(container.context, "Recently Used Playlist", "recentlyusedplaylist",
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} videos", "",
|
||||
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} " + container.context.getString(R.string.videos), "",
|
||||
{
|
||||
StatePlaylists.instance.addToPlaylist(lastUpdated.id, video);
|
||||
StateDownloads.instance.checkForOutdatedPlaylists();
|
||||
@@ -373,18 +443,18 @@ class UISlideOverlays {
|
||||
val queue = StatePlayer.instance.getQueue();
|
||||
val watchLater = StatePlaylists.instance.getWatchLater();
|
||||
items.add(
|
||||
SlideUpMenuGroup(container.context, "Other", "other",
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_queue_add, "Queue", "${queue.size} videos", "queue",
|
||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other",
|
||||
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); }),
|
||||
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)); }),
|
||||
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))
|
||||
);
|
||||
|
||||
val playlistItems = arrayListOf<SlideUpMenuItem>();
|
||||
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);
|
||||
StateDownloads.instance.checkForOutdatedPlaylists();
|
||||
@@ -392,9 +462,9 @@ class UISlideOverlays {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -412,8 +482,8 @@ class UISlideOverlays {
|
||||
.map { btn -> SlideUpMenuItem(container.context, btn.iconResource, btn.text.text.toString(), "", "", {
|
||||
btn.handler?.invoke(btn);
|
||||
}, true) as View }.toTypedArray() ?: arrayOf(),
|
||||
arrayOf(SlideUpMenuItem(container.context, R.drawable.ic_pin, "Change Pins", "Decide which buttons should be pinned", "", {
|
||||
showOrderOverlay(container, "Select your pins in order", (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) {
|
||||
arrayOf(SlideUpMenuItem(container.context, R.drawable.ic_pin, container.context.getString(R.string.change_pins), container.context.getString(R.string.decide_which_buttons_should_be_pinned), "", {
|
||||
showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) {
|
||||
val selected = it
|
||||
.map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } }
|
||||
.filter { it != null }
|
||||
@@ -425,7 +495,7 @@ class UISlideOverlays {
|
||||
}, false))
|
||||
).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) {
|
||||
@@ -433,7 +503,7 @@ class UISlideOverlays {
|
||||
|
||||
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, {
|
||||
if(overlay!!.selectOption(null, it.second, true, true)) {
|
||||
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})");
|
||||
fun String.isHexColor(): Boolean {
|
||||
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()
|
||||
}
|
||||
|
||||
fun File.share(context: Context) {
|
||||
val uri = FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), this);
|
||||
|
||||
fun DocumentFile.share(context: Context) {
|
||||
val shareIntent = Intent();
|
||||
shareIntent.action = Intent.ACTION_SEND;
|
||||
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.os.Bundle
|
||||
@@ -45,6 +46,10 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
private var _config: SourcePluginConfig? = null;
|
||||
private var _script: String? = null;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
@@ -75,10 +80,10 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
_buttonInstall = findViewById(R.id.button_install);
|
||||
|
||||
_buttonBack.setOnClickListener {
|
||||
onBackPressed();
|
||||
finish();
|
||||
};
|
||||
_buttonCancel.setOnClickListener {
|
||||
onBackPressed();
|
||||
finish();
|
||||
}
|
||||
_buttonInstall.setOnClickListener {
|
||||
_config?.let {
|
||||
@@ -96,8 +101,8 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
var url = intent?.dataString;
|
||||
|
||||
if(url == null)
|
||||
UIDialogs.showDialog(this, R.drawable.ic_error, "No valid URL provided..", null, null,
|
||||
0, UIDialogs.Action("Ok", { finish() }, UIDialogs.ActionStyle.PRIMARY));
|
||||
UIDialogs.showDialog(this, R.drawable.ic_error, getString(R.string.no_valid_url_provided), null, null,
|
||||
0, UIDialogs.Action(getString(R.string.ok), { finish() }, UIDialogs.ActionStyle.PRIMARY));
|
||||
else {
|
||||
if(url.startsWith("vfuto://"))
|
||||
url = "https://" + url.substring("vfuto://".length);
|
||||
@@ -129,14 +134,14 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
Logger.e(TAG, "Failed decode config", ex);
|
||||
withContext(Dispatchers.Main) {
|
||||
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));
|
||||
};
|
||||
return@launch;
|
||||
} catch(ex: Exception) {
|
||||
Logger.e(TAG, "Failed fetch config", ex);
|
||||
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;
|
||||
}
|
||||
@@ -152,7 +157,7 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
} catch (ex: Exception) {
|
||||
Logger.e(TAG, "Failed fetch script", ex);
|
||||
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;
|
||||
}
|
||||
@@ -175,8 +180,8 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
_sourcePermissions.addView(
|
||||
SourceInfoView(this,
|
||||
R.drawable.ic_language,
|
||||
"URL Access",
|
||||
"The plugin will have access to the following domains",
|
||||
getString(R.string.url_access),
|
||||
getString(R.string.the_plugin_will_have_access_to_the_following_domains),
|
||||
config.allowUrls, true)
|
||||
)
|
||||
|
||||
@@ -184,8 +189,8 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
_sourcePermissions.addView(
|
||||
SourceInfoView(this,
|
||||
R.drawable.ic_code,
|
||||
"Eval Access",
|
||||
"The plugin will have access to eval capability (remote injection)",
|
||||
getString(R.string.eval_access),
|
||||
getString(R.string.the_plugin_will_have_access_to_eval_capability_remote_injection),
|
||||
config.allowUrls, true)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.widget.*
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
import com.journeyapps.barcodescanner.CaptureActivity
|
||||
@@ -13,6 +18,36 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
||||
|
||||
lateinit var _buttonQR: BigButton;
|
||||
lateinit var _buttonURL: BigButton;
|
||||
lateinit var _buttonPlugins: BigButton;
|
||||
|
||||
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||
scanResult?.let {
|
||||
val content = it.contents
|
||||
if (content == null) {
|
||||
UIDialogs.toast(this, getString(R.string.failed_to_scan_qr_code))
|
||||
return@let
|
||||
}
|
||||
|
||||
val url = if (content.startsWith("https://")) {
|
||||
content
|
||||
} else if (content.startsWith("grayjay://plugin/")) {
|
||||
content.substring("grayjay://plugin/".length)
|
||||
} else {
|
||||
UIDialogs.toast(this, getString(R.string.not_a_plugin_url))
|
||||
return@let;
|
||||
}
|
||||
|
||||
val intent = Intent(this, AddSourceActivity::class.java).apply {
|
||||
data = Uri.parse(url);
|
||||
};
|
||||
startActivity(intent);
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
@@ -23,6 +58,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
||||
|
||||
_buttonQR = findViewById(R.id.option_qr);
|
||||
_buttonURL = findViewById(R.id.option_url);
|
||||
_buttonPlugins = findViewById(R.id.option_plugins);
|
||||
|
||||
_buttonBack.setOnClickListener {
|
||||
finish();
|
||||
@@ -31,21 +67,17 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
||||
_buttonQR.onClick.subscribe {
|
||||
val integrator = IntentIntegrator(this);
|
||||
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
||||
integrator.setPrompt("Scan a QR Code")
|
||||
integrator.setPrompt(getString(R.string.scan_a_qr_code))
|
||||
integrator.setOrientationLocked(true);
|
||||
integrator.setCameraId(0)
|
||||
integrator.setBeepEnabled(false)
|
||||
integrator.setBarcodeImageEnabled(true)
|
||||
integrator.setCaptureActivity(QRCaptureActivity::class.java);
|
||||
integrator.initiateScan()
|
||||
_qrCodeResultLauncher.launch(integrator.createScanIntent())
|
||||
}
|
||||
|
||||
_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.others.CaptchaWebViewClient
|
||||
import com.futo.platformplayer.others.LoginWebViewClient
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
@@ -31,6 +32,10 @@ class CaptchaActivity : AppCompatActivity() {
|
||||
private lateinit var _webView: WebView;
|
||||
private lateinit var _buttonClose: Button;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_captcha);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
@@ -10,6 +12,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.logging.LogLevel
|
||||
import com.futo.platformplayer.logging.Logging
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -26,6 +29,10 @@ class ExceptionActivity : AppCompatActivity() {
|
||||
private var _file: File? = null;
|
||||
private var _submitted = false;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_exception);
|
||||
@@ -37,10 +44,11 @@ class ExceptionActivity : AppCompatActivity() {
|
||||
_buttonRestart = findViewById(R.id.button_restart);
|
||||
_buttonClose = findViewById(R.id.button_close);
|
||||
|
||||
val context = intent.getStringExtra(EXTRA_CONTEXT) ?: "Unknown Context";
|
||||
val stack = intent.getStringExtra(EXTRA_STACK) ?: "Something went wrong... missing stack trace?";
|
||||
val context = intent.getStringExtra(EXTRA_CONTEXT) ?: getString(R.string.unknown_context);
|
||||
val stack = intent.getStringExtra(EXTRA_STACK) ?: getString(R.string.something_went_wrong_missing_stack_trace);
|
||||
|
||||
val exceptionString = "Version information (version_name = ${BuildConfig.VERSION_NAME}, version_code = ${BuildConfig.VERSION_CODE}, flavor = ${BuildConfig.FLAVOR}, build_type = ${BuildConfig.BUILD_TYPE})\n\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" +
|
||||
Logging.buildLogString(LogLevel.ERROR, TAG, "Uncaught exception (\"$context\"): $stack");
|
||||
try {
|
||||
val file = File(filesDir, "log.txt");
|
||||
@@ -77,13 +85,13 @@ class ExceptionActivity : AppCompatActivity() {
|
||||
|
||||
private fun submitFile() {
|
||||
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;
|
||||
}
|
||||
|
||||
val file = _file;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -99,14 +107,14 @@ class ExceptionActivity : AppCompatActivity() {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (id == null) {
|
||||
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) {
|
||||
//Ignored
|
||||
}
|
||||
} else {
|
||||
_submitted = true;
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,10 +125,10 @@ class ExceptionActivity : AppCompatActivity() {
|
||||
val i = Intent(Intent.ACTION_SEND);
|
||||
i.type = "text/plain";
|
||||
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);
|
||||
|
||||
startActivity(Intent.createChooser(i, "Send exception to developers..."));
|
||||
startActivity(Intent.createChooser(i, getString(R.string.send_exception_to_developers)));
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
|
||||
|
||||
@@ -3,8 +3,12 @@ package com.futo.platformplayer.activities
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.webkit.ConsoleMessage
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import android.widget.ImageButton
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
@@ -13,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.logging.Logger
|
||||
import com.futo.platformplayer.others.LoginWebViewClient
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
@@ -21,13 +26,25 @@ import kotlinx.serialization.json.Json
|
||||
|
||||
class LoginActivity : AppCompatActivity() {
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_login);
|
||||
setNavigationBarColorAndIcons();
|
||||
|
||||
_textUrl = findViewById(R.id.text_url);
|
||||
_buttonClose = findViewById(R.id.button_close);
|
||||
_buttonClose.setOnClickListener {
|
||||
finish();
|
||||
}
|
||||
|
||||
|
||||
_webView = findViewById(R.id.web_view);
|
||||
_webView.settings.javaScriptEnabled = true;
|
||||
CookieManager.getInstance().setAcceptCookie(true);
|
||||
@@ -58,6 +75,8 @@ class LoginActivity : AppCompatActivity() {
|
||||
};
|
||||
var isFirstLoad = true;
|
||||
webViewClient.onPageLoaded.subscribe { view, url ->
|
||||
_textUrl.setText(url ?: "");
|
||||
|
||||
if(!isFirstLoad)
|
||||
return@subscribe;
|
||||
isFirstLoad = false;
|
||||
@@ -68,9 +87,15 @@ class LoginActivity : AppCompatActivity() {
|
||||
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
|
||||
}
|
||||
}
|
||||
//TODO: Required for some...TBD what to do with it. Clear on finish?
|
||||
_webView.settings.domStorageEnabled = true;
|
||||
|
||||
/*
|
||||
_webView.webChromeClient = object: WebChromeClient() {
|
||||
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
||||
Logger.w(TAG, "Login Console: " + consoleMessage?.message());
|
||||
return super.onConsoleMessage(consoleMessage);
|
||||
}
|
||||
}*/
|
||||
_webView.webViewClient = webViewClient;
|
||||
_webView.loadUrl(authConfig.loginUrl);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.content.pm.ActivityInfo
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.preference.PreferenceManager
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
@@ -154,6 +155,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?) {
|
||||
StateApp.instance.setGlobalContext(this, lifecycleScope);
|
||||
StateApp.instance.mainAppStarting(this);
|
||||
@@ -321,6 +327,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
fragCurrent.onOrientationChanged(it);
|
||||
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED)
|
||||
_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();
|
||||
|
||||
@@ -459,6 +474,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
Logger.i(TAG, "View Received: " + targetData);
|
||||
}
|
||||
}
|
||||
"VIDEO" -> {
|
||||
val url = intent.getStringExtra("VIDEO");
|
||||
navigate(_fragVideoDetail, url);
|
||||
}
|
||||
"TAB" -> {
|
||||
when(intent.getStringExtra("TAB")){
|
||||
"Sources" -> {
|
||||
@@ -478,13 +497,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
if(targetData.startsWith("grayjay://license/")) {
|
||||
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)
|
||||
closeSegment(fragCurrent);
|
||||
}
|
||||
else
|
||||
UIDialogs.toast("Invalid license format");
|
||||
UIDialogs.toast(getString(R.string.invalid_license_format));
|
||||
|
||||
}
|
||||
else if(targetData.startsWith("grayjay://plugin/")) {
|
||||
@@ -493,13 +512,21 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
};
|
||||
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" -> {
|
||||
if(!handleContent(targetData, intent.type)) {
|
||||
UIDialogs.showSingleButtonDialog(
|
||||
this,
|
||||
R.drawable.ic_play,
|
||||
"Unknown content format [${targetData}]",
|
||||
getString(R.string.unknown_content_format) + " [${targetData}]",
|
||||
"Ok",
|
||||
{ });
|
||||
}
|
||||
@@ -509,7 +536,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
UIDialogs.showSingleButtonDialog(
|
||||
this,
|
||||
R.drawable.ic_play,
|
||||
"Unknown file format [${targetData}]",
|
||||
getString(R.string.unknown_file_format) + " [${targetData}]",
|
||||
"Ok",
|
||||
{ });
|
||||
}
|
||||
@@ -519,7 +546,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
UIDialogs.showSingleButtonDialog(
|
||||
this,
|
||||
R.drawable.ic_play,
|
||||
"Unknown Polycentric format [${targetData}]",
|
||||
getString(R.string.unknown_polycentric_format) + " [${targetData}]",
|
||||
"Ok",
|
||||
{ });
|
||||
}
|
||||
@@ -529,7 +556,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
UIDialogs.showSingleButtonDialog(
|
||||
this,
|
||||
R.drawable.ic_play,
|
||||
"Unknown url format [${targetData}]",
|
||||
getString(R.string.unknown_url_format) + " [${targetData}]",
|
||||
"Ok",
|
||||
{ });
|
||||
}
|
||||
@@ -538,7 +565,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
UIDialogs.showGeneralErrorDialog(this, "Failed to handle file", ex);
|
||||
UIDialogs.showGeneralErrorDialog(this, getString(R.string.failed_to_handle_file), ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,6 +606,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
StateBackup.importZipBytes(this, lifecycleScope, data);
|
||||
return true;
|
||||
}
|
||||
else if(file.lowercase().endsWith(".txt") || mime == "text/plain") {
|
||||
return handleUnknownText(String(data));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
fun handleFile(file: String): Boolean {
|
||||
@@ -596,6 +626,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
StateBackup.importZipBytes(this, lifecycleScope, readSharedFile(file));
|
||||
return true;
|
||||
}
|
||||
else if(file.lowercase().endsWith(".txt")) {
|
||||
return handleUnknownText(String(readSharedFile(file)));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
fun handleReconstruction(recon: String) {
|
||||
@@ -603,7 +636,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
val store: ManagedStore<*> = when(type) {
|
||||
"Playlist" -> StatePlaylists.instance.playlistStore
|
||||
else -> {
|
||||
UIDialogs.toast("Unknown reconstruction type ${type}", false);
|
||||
UIDialogs.toast(getString(R.string.unknown_reconstruction_type) + " ${type}", false);
|
||||
return;
|
||||
};
|
||||
};
|
||||
@@ -621,6 +654,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 {
|
||||
|
||||
val context = this;
|
||||
@@ -646,7 +693,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
catch(ex: Exception) {
|
||||
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 +788,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
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
|
||||
@@ -925,5 +975,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.widget.ImageButton
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -10,6 +11,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.AnyAdapterView
|
||||
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
|
||||
import com.futo.platformplayer.views.adapters.ItemMoveCallback
|
||||
@@ -23,6 +25,10 @@ class ManageTabsActivity : AppCompatActivity() {
|
||||
private lateinit var _recyclerTabs: RecyclerView;
|
||||
private lateinit var _touchHelper: ItemTouchHelper;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_manage_tabs);
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.polycentric.core.*
|
||||
@@ -33,6 +34,10 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
private lateinit var _exportBundle: String;
|
||||
private lateinit var _textQR: TextView;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_backup);
|
||||
@@ -53,7 +58,7 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
val qrCodeBitmap = generateQRCode(_exportBundle, dimension, dimension);
|
||||
_imageQR.setImageBitmap(qrCodeBitmap);
|
||||
} 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;
|
||||
_textQR.visibility = View.INVISIBLE;
|
||||
}
|
||||
@@ -63,12 +68,12 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
type = "text/plain";
|
||||
putExtra(Intent.EXTRA_TEXT, _exportBundle);
|
||||
}
|
||||
startActivity(Intent.createChooser(shareIntent, "Share Text"));
|
||||
startActivity(Intent.createChooser(shareIntent, getString(R.string.share_text)));
|
||||
};
|
||||
|
||||
_buttonCopy.onClick.subscribe {
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
+11
-3
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.EditText
|
||||
@@ -11,6 +12,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.polycentric.core.ProcessHandle
|
||||
import com.futo.polycentric.core.Store
|
||||
@@ -28,6 +30,10 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
|
||||
|
||||
private var _creating = false;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_create_profile);
|
||||
@@ -54,7 +60,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
|
||||
try {
|
||||
val username = _profileName.text.toString();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -68,16 +74,18 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
|
||||
processHandle.setUsername(username);
|
||||
StatePolycentric.instance.setProcessHandle(processHandle);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to create profile .", e);
|
||||
Logger.e(TAG, getString(R.string.failed_to_create_profile), e);
|
||||
return@launch;
|
||||
} finally {
|
||||
_creating = false;
|
||||
}
|
||||
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
processHandle.fullyBackfillServers();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} 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) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Drawable
|
||||
@@ -15,6 +16,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.selectBestImage
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.polycentric.core.Store
|
||||
@@ -27,6 +29,10 @@ class PolycentricHomeActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonImportProfile: BigButton;
|
||||
private lateinit var _layoutButtons: LinearLayout;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
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());
|
||||
};
|
||||
profileButton.withPrimaryText(systemState.username);
|
||||
profileButton.withSecondaryText("Sign in to this identity");
|
||||
profileButton.withSecondaryText(getString(R.string.sign_in_to_this_identity));
|
||||
profileButton.onClick.subscribe {
|
||||
StatePolycentric.instance.setProcessHandle(processHandle);
|
||||
startActivity(Intent(this@PolycentricHomeActivity, PolycentricProfileActivity::class.java));
|
||||
|
||||
+31
-20
@@ -1,19 +1,23 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.polycentric.core.*
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
import com.journeyapps.barcodescanner.CaptureActivity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -27,6 +31,20 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonImportProfile: LinearLayout;
|
||||
private lateinit var _editProfile: EditText;
|
||||
|
||||
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||
scanResult?.let {
|
||||
if (it.contents != null) {
|
||||
val scannedUrl = it.contents
|
||||
import(scannedUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_import_profile);
|
||||
@@ -45,15 +63,20 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
};
|
||||
|
||||
_buttonScanProfile.setOnClickListener {
|
||||
val integrator = IntentIntegrator(this);
|
||||
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE);
|
||||
integrator.setPrompt("Scan a QR code");
|
||||
integrator.initiateScan();
|
||||
val integrator = IntentIntegrator(this)
|
||||
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
||||
integrator.setPrompt(getString(R.string.scan_a_qr_code))
|
||||
integrator.setOrientationLocked(true);
|
||||
integrator.setCameraId(0)
|
||||
integrator.setBeepEnabled(false)
|
||||
integrator.setBarcodeImageEnabled(true)
|
||||
integrator.setCaptureActivity(QRCaptureActivity::class.java);
|
||||
_qrCodeResultLauncher.launch(integrator.createScanIntent())
|
||||
};
|
||||
|
||||
_buttonImportProfile.setOnClickListener {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -66,21 +89,9 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data)
|
||||
if (result != null) {
|
||||
if (result.contents != null) {
|
||||
val scannedUrl = result.contents;
|
||||
import(scannedUrl);
|
||||
}
|
||||
} else {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun import(url: String) {
|
||||
if (!url.startsWith("polycentric://")) {
|
||||
UIDialogs.toast(this, "Not a valid URL");
|
||||
UIDialogs.toast(this, getString(R.string.not_a_valid_url));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,7 +107,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
|
||||
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
|
||||
if (existingProcessSecret != null) {
|
||||
UIDialogs.toast(this, "This profile is already imported");
|
||||
UIDialogs.toast(this, getString(R.string.this_profile_is_already_imported));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -119,7 +130,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
finish();
|
||||
} catch (e: Throwable) {
|
||||
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}'");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-10
@@ -2,6 +2,7 @@ package com.futo.platformplayer.activities
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
@@ -16,6 +17,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.dialogs.CommentDialog
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -28,6 +30,7 @@ import com.futo.polycentric.core.Store
|
||||
import com.futo.polycentric.core.Synchronization
|
||||
import com.futo.polycentric.core.SystemState
|
||||
import com.futo.polycentric.core.toURLInfoDataLink
|
||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||
import com.github.dhaval2404.imagepicker.ImagePicker
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -46,6 +49,10 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
private lateinit var _imagePolycentric: ImageView;
|
||||
private var _avatarUri: Uri? = null;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_profile);
|
||||
@@ -72,7 +79,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(this@PolycentricProfileActivity, "Failed to backfill client");
|
||||
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_backfill_client));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,10 +108,10 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
_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;
|
||||
if (processHandle == null) {
|
||||
UIDialogs.toast(this, "No process handle set");
|
||||
UIDialogs.toast(this, getString(R.string.no_process_handle_set));
|
||||
return@showConfirmationDialog;
|
||||
}
|
||||
|
||||
@@ -122,13 +129,13 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
var hasChanges = false;
|
||||
val username = _editName.text.toString();
|
||||
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;
|
||||
}
|
||||
|
||||
val processHandle = StatePolycentric.instance.processHandle;
|
||||
if (processHandle == null) {
|
||||
UIDialogs.toast(this@PolycentricProfileActivity, "Process handle unset");
|
||||
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.process_handle_unset));
|
||||
return@launch;
|
||||
}
|
||||
|
||||
@@ -143,7 +150,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
val bytes = readBytesFromUri(applicationContext.contentResolver, avatarUri);
|
||||
if (bytes == null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(this@PolycentricProfileActivity, "Failed to read image");
|
||||
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_read_image));
|
||||
}
|
||||
|
||||
return@launch;
|
||||
@@ -186,14 +193,16 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
|
||||
if (hasChanges) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
processHandle.fullyBackfillServers();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
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) {
|
||||
Logger.w(TAG, "Failed to synchronize changes", e);
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(this@PolycentricProfileActivity, "Failed to synchronize changes");
|
||||
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_synchronize_changes));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,7 +228,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
val avatar = systemState.avatar.selectBestImage(dp_80 * dp_80);
|
||||
|
||||
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)
|
||||
.crossfade()
|
||||
.into(_imagePolycentric)
|
||||
@@ -235,7 +244,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
} else if (resultCode == ImagePicker.RESULT_ERROR) {
|
||||
UIDialogs.toast(this, ImagePicker.getError(data));
|
||||
} 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
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
@@ -7,12 +8,17 @@ import android.widget.ImageButton
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
|
||||
class PolycentricWhyActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonVideo: BigButton;
|
||||
private lateinit var _buttonTechnical: BigButton;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
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
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
@@ -13,6 +14,7 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.Loader
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.ReadOnlyTextField
|
||||
@@ -28,6 +30,10 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_settings);
|
||||
@@ -43,6 +49,11 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
|
||||
_form.setObjectValues();
|
||||
Settings.instance.save();
|
||||
|
||||
if(field.descriptor?.id == "app_language") {
|
||||
Logger.i("SettingsActivity", "App language change detected, propogating to shared preferences");
|
||||
StateApp.instance.setLocaleSetting(this, Settings.instance.language.getAppLanguageLocaleString());
|
||||
}
|
||||
};
|
||||
_buttonBack.setOnClickListener {
|
||||
finish();
|
||||
@@ -52,17 +63,6 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
startActivity(Intent(this, DeveloperActivity::class.java));
|
||||
}
|
||||
|
||||
var devCounter = 0;
|
||||
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
|
||||
devCounter++;
|
||||
if(devCounter > 5) {
|
||||
devCounter = 0;
|
||||
SettingsDev.instance.developerMode = true;
|
||||
SettingsDev.instance.save();
|
||||
updateDevMode();
|
||||
UIDialogs.toast(this, "You are now in developer mode");
|
||||
}
|
||||
};
|
||||
_lastActivity = this;
|
||||
|
||||
reloadSettings();
|
||||
@@ -72,6 +72,18 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
_loader.start();
|
||||
_form.fromObject(lifecycleScope, Settings.instance) {
|
||||
_loader.stop();
|
||||
|
||||
var devCounter = 0;
|
||||
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
|
||||
devCounter++;
|
||||
if(devCounter > 5) {
|
||||
devCounter = 0;
|
||||
SettingsDev.instance.developerMode = true;
|
||||
SettingsDev.instance.save();
|
||||
updateDevMode();
|
||||
UIDialogs.toast(this, getString(R.string.you_are_now_in_developer_mode));
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.futo.platformplayer.logging.Logger
|
||||
import okhttp3.Call
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
@@ -28,7 +29,11 @@ open class ManagedHttpClient {
|
||||
|
||||
constructor(builder: OkHttpClient.Builder = OkHttpClient.Builder()) {
|
||||
_builderTemplate = builder;
|
||||
client = builder.build();
|
||||
client = builder.addNetworkInterceptor { chain ->
|
||||
val request = beforeRequest(chain.request());
|
||||
val response = afterRequest(chain.proceed(request));
|
||||
return@addNetworkInterceptor response;
|
||||
}.build();
|
||||
}
|
||||
|
||||
open fun clone(): ManagedHttpClient {
|
||||
@@ -116,7 +121,7 @@ open class ManagedHttpClient {
|
||||
fun execute(request : Request) : Response {
|
||||
ensureNotMainThread();
|
||||
|
||||
beforeRequest(request);
|
||||
//beforeRequest(request);
|
||||
|
||||
Logger.v(TAG, "HTTP Request [${request.method}] ${request.url} - [${if(request.body != null) request.body.size else 0}]");
|
||||
|
||||
@@ -156,23 +161,16 @@ open class ManagedHttpClient {
|
||||
if(true)
|
||||
Logger.v(TAG, "HTTP Response [${request.method}] ${request.url} - [${time}ms]");
|
||||
|
||||
afterRequest(request, resp);
|
||||
//afterRequest(request, resp);
|
||||
return resp;
|
||||
}
|
||||
|
||||
//Set Listeners
|
||||
fun setOnBeforeRequest(listener : (Request)->Unit) {
|
||||
this.onBeforeRequest = listener;
|
||||
open fun beforeRequest(request: okhttp3.Request): okhttp3.Request {
|
||||
return request;
|
||||
}
|
||||
fun setOnAfterRequest(listener : (Request, Response)->Unit) {
|
||||
this.onAfterRequest = listener;
|
||||
}
|
||||
|
||||
open fun beforeRequest(request: Request) {
|
||||
onBeforeRequest?.invoke(request);
|
||||
}
|
||||
open fun afterRequest(request: Request, resp: Response) {
|
||||
onAfterRequest?.invoke(request, resp);
|
||||
open fun afterRequest(resp: okhttp3.Response): okhttp3.Response {
|
||||
return resp;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -190,7 +190,9 @@ class HttpContext : AutoCloseable {
|
||||
do {
|
||||
read = readContentBytes(buffer, buffer.size);
|
||||
writer.write(buffer, 0, read);
|
||||
} while(read > 0);
|
||||
} while(read > 0);// && _stream.ready());
|
||||
//if(!_stream.ready())
|
||||
// _totalRead = contentLength;
|
||||
return writer.toString();
|
||||
}
|
||||
inline fun <reified T> readContentJson() : T {
|
||||
|
||||
@@ -200,7 +200,7 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
handler(req);
|
||||
|
||||
requestsTotal++;
|
||||
if(req.keepAlive) {
|
||||
if(req.keepAlive){// && requestReader.ready()) {
|
||||
keepAlive = true;
|
||||
if(req.keepAliveMax > 0)
|
||||
requestsMax = req.keepAliveMax;
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media
|
||||
import androidx.collection.LruCache
|
||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
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.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
@@ -49,6 +50,7 @@ class CachedPlatformClient : IPlatformClient {
|
||||
return result;
|
||||
}
|
||||
|
||||
override fun getContentChapters(url: String): List<IChapter> = _client.getContentChapters(url);
|
||||
override fun getPlaybackTracker(url: String): IPlaybackTracker? = _client.getPlaybackTracker(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.ResultCapabilities
|
||||
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.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
@@ -100,6 +101,8 @@ interface IPlatformClient {
|
||||
*/
|
||||
fun getContentDetails(url: String): IPlatformContentDetails;
|
||||
|
||||
fun getContentChapters(url: String): List<IChapter>;
|
||||
|
||||
/**
|
||||
* Gets the playback tracker for a piece of content
|
||||
*/
|
||||
|
||||
@@ -15,7 +15,8 @@ data class PlatformClientCapabilities(
|
||||
val hasGetSearchCapabilities: Boolean = false,
|
||||
val hasGetChannelCapabilities: Boolean = false,
|
||||
val hasGetLiveEvents: Boolean = false,
|
||||
val hasGetLiveChatWindow: Boolean = false
|
||||
val hasGetLiveChatWindow: Boolean = false,
|
||||
val hasGetContentChapters: Boolean = false
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
|
||||
class PlatformClientPool {
|
||||
private val _parent: JSClient;
|
||||
@@ -51,6 +52,11 @@ class PlatformClientPool {
|
||||
if(reserved == null && _pool.size < capacity) {
|
||||
Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})");
|
||||
reserved = _parent.getCopy();
|
||||
|
||||
reserved?.onCaptchaException?.subscribe { client, ex ->
|
||||
StateApp.instance.handleCaptchaException(client, ex);
|
||||
};
|
||||
|
||||
reserved?.initialize();
|
||||
_pool[reserved!!] = _poolCounter;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import com.futo.platformplayer.getOrThrow
|
||||
* A link to a channel, often with its own name and thumbnail
|
||||
*/
|
||||
@kotlinx.serialization.Serializable
|
||||
class PlatformAuthorLink {
|
||||
open class PlatformAuthorLink {
|
||||
val id: PlatformID;
|
||||
val name: String;
|
||||
val url: String;
|
||||
@@ -28,6 +28,9 @@ class PlatformAuthorLink {
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorLink {
|
||||
if(value.has("membershipUrl"))
|
||||
return PlatformAuthorMembershipLink.fromV8(config, value);
|
||||
|
||||
val context = "AuthorLink"
|
||||
return PlatformAuthorLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)),
|
||||
value.getOrThrow(config ,"name", context),
|
||||
|
||||
+33
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,9 @@ class ResultCapabilities(
|
||||
const val TYPE_VIDEOS = "VIDEOS";
|
||||
const val TYPE_STREAMS = "STREAMS";
|
||||
const val TYPE_LIVE = "LIVE";
|
||||
const val TYPE_POSTS = "POSTS";
|
||||
const val TYPE_MIXED = "MIXED";
|
||||
const val TYPE_SUBSCRIPTIONS = "SUBSCRIPTIONS";
|
||||
|
||||
const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL";
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ class Thumbnails {
|
||||
fun getLQThumbnail() : String? {
|
||||
return sources.firstOrNull()?.url;
|
||||
}
|
||||
fun getMinimumThumbnail(quality: Int): String? {
|
||||
return sources.firstOrNull { it.quality >= quality }?.url ?: getHQThumbnail();
|
||||
}
|
||||
|
||||
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: Int;
|
||||
val timeEnd: Int;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -39,4 +39,8 @@ class PolycentricPlatformComment : IPlatformComment {
|
||||
fun cloneWithUpdatedReplyCount(replyCount: Int?): PolycentricPlatformComment {
|
||||
return PolycentricPlatformComment(contextUrl, author, message, rating, date, reference, replyCount);
|
||||
}
|
||||
|
||||
companion object {
|
||||
val MAX_COMMENT_SIZE = 2000
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -30,7 +30,7 @@ open class SerializedPlatformNestedContent(
|
||||
override val contentProvider: String?,
|
||||
override val contentThumbnails: Thumbnails
|
||||
) : IPlatformNestedContent, SerializedPlatformContent {
|
||||
final override val contentType: ContentType get() = ContentType.MEDIA;
|
||||
final override val contentType: ContentType get() = ContentType.NESTED_VIDEO;
|
||||
|
||||
override val contentPlugin: String? = StatePlatform.instance.getContentClientOrNull(contentUrl)?.id;
|
||||
override val contentSupported: Boolean get() = contentPlugin != null;
|
||||
|
||||
@@ -7,6 +7,7 @@ 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.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import java.util.*
|
||||
|
||||
class DevJSClient : JSClient {
|
||||
@@ -24,6 +25,10 @@ class DevJSClient : JSClient {
|
||||
_auth = auth;
|
||||
_captcha = captcha;
|
||||
this.devID = devID ?: UUID.randomUUID().toString().substring(0, 5);
|
||||
|
||||
onCaptchaException.subscribe { client, captcha ->
|
||||
StateApp.instance.handleCaptchaException(client, captcha);
|
||||
}
|
||||
}
|
||||
//TODO: Misisng auth/captcha pass on purpose?
|
||||
constructor(context: Context, descriptor: SourcePluginDescriptor, script: String, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, savedState: String? = null, devID: String? = null): super(context, descriptor, savedState, script) {
|
||||
@@ -31,6 +36,10 @@ class DevJSClient : JSClient {
|
||||
_auth = auth;
|
||||
_captcha = captcha;
|
||||
this.devID = devID ?: UUID.randomUUID().toString().substring(0, 5);
|
||||
|
||||
onCaptchaException.subscribe { client, captcha ->
|
||||
StateApp.instance.handleCaptchaException(client, captcha);
|
||||
}
|
||||
}
|
||||
|
||||
fun setCaptcha(captcha: SourceCaptchaData? = null) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.primitive.V8ValueBoolean
|
||||
import com.caoccao.javet.values.primitive.V8ValueInteger
|
||||
import com.caoccao.javet.values.primitive.V8ValueNull
|
||||
import com.caoccao.javet.values.primitive.V8ValueString
|
||||
import com.caoccao.javet.values.reference.V8ValueArray
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
@@ -14,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.ResultCapabilities
|
||||
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.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
@@ -23,6 +25,7 @@ import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.*
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.*
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
@@ -89,6 +92,19 @@ open class JSClient : IPlatformClient {
|
||||
val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: 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 onCaptchaException = Event2<JSClient, ScriptCaptchaRequiredException>();
|
||||
|
||||
@@ -179,6 +195,7 @@ open class JSClient : IPlatformClient {
|
||||
hasGetChannelCapabilities = plugin.executeBoolean("!!source.getChannelCapabilities") ?: false,
|
||||
hasGetLiveEvents = plugin.executeBoolean("!!source.getLiveEvents") ?: false,
|
||||
hasGetLiveChatWindow = plugin.executeBoolean("!!source.getLiveChatWindow") ?: false,
|
||||
hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false,
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -412,6 +429,17 @@ open class JSClient : IPlatformClient {
|
||||
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
|
||||
@JSDocs(15, "source.getPlaybackTracker(url)", "Gets a playback tracker for given content url")
|
||||
@JSDocsParameter("url", "A content url (this platform)")
|
||||
@@ -431,8 +459,11 @@ open class JSClient : IPlatformClient {
|
||||
@JSDocsParameter("url", "A content url (this platform)")
|
||||
override fun getComments(url: String): IPager<IPlatformComment> = isBusyWith {
|
||||
ensureEnabled();
|
||||
return@isBusyWith JSCommentPager(config, plugin,
|
||||
plugin.executeTyped("source.getComments(${Json.encodeToString(url)})"));
|
||||
val pager = plugin.executeTyped<V8Value>("source.getComments(${Json.encodeToString(url)})");
|
||||
if (pager !is V8ValueObject) { //TODO: Maybe solve this better
|
||||
return@isBusyWith EmptyPager<IPlatformComment>();
|
||||
}
|
||||
return@isBusyWith JSCommentPager(config, plugin, pager);
|
||||
}
|
||||
@JSDocs(17, "source.getSubComments(comment)", "Gets replies for a given comment")
|
||||
@JSDocsParameter("comment", "Comment object that was returned by getComments")
|
||||
@@ -553,7 +584,7 @@ open class JSClient : IPlatformClient {
|
||||
if(it.containsKey(claimType)) {
|
||||
val templates = it[claimType];
|
||||
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)) {
|
||||
return templates[value]!!.replace("{{CLAIMVALUE}}", values[value]!!);
|
||||
}
|
||||
@@ -563,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 {
|
||||
try {
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
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.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
|
||||
data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) {
|
||||
override fun toString(): String {
|
||||
return "(headers: '$headers', cookieString: '$cookieMap')";
|
||||
}
|
||||
|
||||
fun toEncrypted(): String{
|
||||
return EncryptionProvider.instance.encrypt(serialize());
|
||||
return SourceEncrypted.fromDecrypted { serialize() }.toJson();
|
||||
}
|
||||
|
||||
private fun serialize(): String {
|
||||
@@ -25,20 +22,10 @@ data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? =
|
||||
val TAG = "SourceAuth";
|
||||
|
||||
fun fromEncrypted(encrypted: String?): SourceAuth? {
|
||||
if(encrypted == null)
|
||||
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;
|
||||
}
|
||||
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
|
||||
}
|
||||
|
||||
fun deserialize(str: String): SourceAuth {
|
||||
private fun deserialize(str: String): SourceAuth {
|
||||
val data = Json.decodeFromString<SerializedAuth>(str);
|
||||
return SourceAuth(data.cookieMap, data.headers);
|
||||
}
|
||||
|
||||
+3
-15
@@ -1,7 +1,5 @@
|
||||
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.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
@@ -13,7 +11,7 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
|
||||
}
|
||||
|
||||
fun toEncrypted(): String{
|
||||
return EncryptionProvider.instance.encrypt(serialize());
|
||||
return SourceEncrypted.fromDecrypted { serialize() }.toJson();
|
||||
}
|
||||
|
||||
private fun serialize(): String {
|
||||
@@ -21,20 +19,10 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = "SourceAuth";
|
||||
val TAG = "SourceCaptchaData";
|
||||
|
||||
fun fromEncrypted(encrypted: String?): SourceCaptchaData? {
|
||||
if(encrypted == null)
|
||||
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;
|
||||
}
|
||||
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -41,9 +41,12 @@ class SourcePluginConfig(
|
||||
val constants: HashMap<String, String> = hashMapOf(),
|
||||
|
||||
//TODO: These should be vals...but prob for serialization reasons cannot be changed.
|
||||
var platformUrl: String? = null,
|
||||
var subscriptionRateLimit: Int? = null,
|
||||
var enableInSearch: Boolean = true,
|
||||
var enableInHome: Boolean = true,
|
||||
var supportedClaimTypes: List<Int> = listOf()
|
||||
var supportedClaimTypes: List<Int> = listOf(),
|
||||
var primaryClaimFieldType: Int? = null
|
||||
) : IV8PluginConfig {
|
||||
|
||||
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
|
||||
@@ -141,7 +144,10 @@ class SourcePluginConfig(
|
||||
val description: String,
|
||||
val type: String,
|
||||
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
|
||||
val variableOrName: String get() = variable ?: name;
|
||||
|
||||
+28
-3
@@ -1,7 +1,9 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js
|
||||
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
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.FormField
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -66,18 +68,41 @@ class SourcePluginDescriptor {
|
||||
@Serializable
|
||||
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();
|
||||
@Serializable
|
||||
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;
|
||||
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
@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) {
|
||||
|
||||
+43
-27
@@ -31,8 +31,12 @@ class JSHttpClient : ManagedHttpClient {
|
||||
_currentCookieMap.put(domainCookies.key, HashMap(domainCookies.value));
|
||||
}
|
||||
if(!captcha?.cookieMap.isNullOrEmpty()) {
|
||||
for(domainCookies in captcha!!.cookieMap!!)
|
||||
_currentCookieMap.put(domainCookies.key, HashMap(domainCookies.value));
|
||||
for(domainCookies in captcha!!.cookieMap!!) {
|
||||
if(_currentCookieMap.containsKey(domainCookies.key))
|
||||
_currentCookieMap[domainCookies.key]?.putAll(domainCookies.value);
|
||||
else
|
||||
_currentCookieMap.put(domainCookies.key, HashMap(domainCookies.value));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -46,14 +50,18 @@ class JSHttpClient : ManagedHttpClient {
|
||||
return newClient;
|
||||
}
|
||||
|
||||
override fun beforeRequest(request: Request) {
|
||||
val domain = Uri.parse(request.url).host!!.lowercase();
|
||||
|
||||
override fun beforeRequest(request: okhttp3.Request): okhttp3.Request {
|
||||
val domain = request.url.host.lowercase();
|
||||
val auth = _auth;
|
||||
|
||||
val newBuilder = if(auth != null || doApplyCookies)
|
||||
request.newBuilder();
|
||||
else
|
||||
null;
|
||||
if (auth != null) {
|
||||
//TODO: Possibly add doApplyHeaders
|
||||
for (header in auth.headers.filter { domain.matchesDomain(it.key) }.flatMap { it.value.entries })
|
||||
request.headers[header.key] = header.value;
|
||||
newBuilder?.header(header.key, header.value);
|
||||
}
|
||||
|
||||
if(doApplyCookies) {
|
||||
@@ -68,34 +76,37 @@ class JSHttpClient : ManagedHttpClient {
|
||||
|
||||
if(cookiesToApply.size > 0) {
|
||||
val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; ");
|
||||
request.headers["Cookie"] = cookieString;
|
||||
|
||||
val existingCookies = request.headers["Cookie"];
|
||||
if(!existingCookies.isNullOrEmpty())
|
||||
newBuilder?.header("Cookie", existingCookies.trim(';') + "; " + cookieString);
|
||||
else
|
||||
newBuilder?.header("Cookie", cookieString);
|
||||
}
|
||||
//printTestCode(request.url, request.body, auth.headers, cookieString, request.headers.filter { !auth.headers.containsKey(it.key) });
|
||||
}
|
||||
}
|
||||
|
||||
_jsClient?.validateUrlOrThrow(request.url);
|
||||
super.beforeRequest(request)
|
||||
_jsClient?.validateUrlOrThrow(request.url.toString());
|
||||
return newBuilder?.let { it.build() } ?: request;
|
||||
}
|
||||
|
||||
override fun afterRequest(request: Request, resp: Response) {
|
||||
super.afterRequest(request, resp)
|
||||
|
||||
override fun afterRequest(resp: okhttp3.Response): okhttp3.Response {
|
||||
if(doUpdateCookies) {
|
||||
val domain = Uri.parse(request.url).host!!.lowercase();
|
||||
val domainParts = domain!!.split(".");
|
||||
val domain = resp.request.url.host.lowercase();
|
||||
val domainParts = domain.split(".");
|
||||
val defaultCookieDomain =
|
||||
"." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||
for (header in resp.headers) {
|
||||
if ((_auth != null || _currentCookieMap.isNotEmpty()) && header.key.lowercase() == "set-cookie") {
|
||||
val newCookies = cookieStringToMap(header.value);
|
||||
for (cookie in newCookies) {
|
||||
val endIndex = cookie.value.indexOf(";");
|
||||
var cookieValue = cookie.value;
|
||||
if ((_auth != null || _currentCookieMap.isNotEmpty()) && header.first.lowercase() == "set-cookie") {
|
||||
//val newCookies = cookieStringToMap(header.second.split("; "));
|
||||
val cookie = cookieStringToPair(header.second);
|
||||
//for (cookie in newCookies) {
|
||||
var cookieValue = cookie.second;
|
||||
var domainToUse = domain;
|
||||
|
||||
if (endIndex > 0) {
|
||||
val cookieParts = cookie.value.split(";");
|
||||
if (!cookie.first.isNullOrEmpty() && !cookie.second.isNullOrEmpty()) {
|
||||
val cookieParts = cookie.second.split(";");
|
||||
if (cookieParts.size == 0)
|
||||
continue;
|
||||
cookieValue = cookieParts[0].trim();
|
||||
@@ -121,24 +132,29 @@ class JSHttpClient : ManagedHttpClient {
|
||||
_currentCookieMap!!.put(domainToUse, newMap)
|
||||
newMap;
|
||||
}
|
||||
if(cookieMap.containsKey(cookie.key) || doAllowNewCookies)
|
||||
cookieMap.put(cookie.key, cookieValue);
|
||||
}
|
||||
if(cookieMap.containsKey(cookie.first) || doAllowNewCookies)
|
||||
cookieMap.put(cookie.first, cookieValue);
|
||||
//}
|
||||
}
|
||||
}
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
|
||||
private fun cookieStringToMap(parts: List<String>): Map<String, String> {
|
||||
val map = hashMapOf<String, String>();
|
||||
for(cookie in parts) {
|
||||
val cookieKey = cookie.substring(0, cookie.indexOf("="));
|
||||
val cookieVal = cookie.substring(cookie.indexOf("=") + 1);
|
||||
map.put(cookieKey.trim(), cookieVal.trim());
|
||||
val pair = cookieStringToPair(cookie)
|
||||
map.put(pair.first, pair.second);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
private fun cookieStringToPair(cookie: String): Pair<String, String> {
|
||||
val cookieKey = cookie.substring(0, cookie.indexOf("="));
|
||||
val cookieVal = cookie.substring(cookie.indexOf("=") + 1);
|
||||
return Pair(cookieKey.trim(), cookieVal.trim());
|
||||
}
|
||||
|
||||
//Prints out code for test reproduction..
|
||||
fun printTestCode(url: String, body: ByteArray?, headers: Map<String, String>, cookieString: String, allHeaders: Map<String, String>? = null) {
|
||||
|
||||
@@ -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: Int;
|
||||
override val timeEnd: Int;
|
||||
|
||||
constructor(name: String, timeStart: Int, timeEnd: Int, 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<Int>(config, "timeStart", context);
|
||||
val timeEnd = obj.getOrThrow<Int>(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);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.warnIfMainThread
|
||||
|
||||
@@ -27,7 +28,7 @@ abstract class JSPager<T> : IPager<T> {
|
||||
this.pager = pager;
|
||||
this.config = config;
|
||||
|
||||
_hasMorePages = pager.getOrThrow(config, "hasMore", "Pager");
|
||||
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
|
||||
getResults();
|
||||
}
|
||||
|
||||
@@ -45,7 +46,7 @@ abstract class JSPager<T> : IPager<T> {
|
||||
pager = plugin.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") {
|
||||
pager.invoke("nextPage", arrayOf<Any>());
|
||||
};
|
||||
_hasMorePages = pager.getOrThrow(config, "hasMore", "Pager");
|
||||
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
|
||||
_resultChanged = true;
|
||||
/*
|
||||
try {
|
||||
|
||||
+6
-2
@@ -1,6 +1,7 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.primitive.V8ValueNull
|
||||
import com.caoccao.javet.values.reference.V8ValueArray
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
@@ -99,8 +100,11 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
return getCommentsJS(client);
|
||||
}
|
||||
|
||||
private fun getCommentsJS(client: JSClient): JSCommentPager {
|
||||
val commentPager = _content.invoke<V8ValueObject>("getComments", arrayOf<Any>());
|
||||
private fun getCommentsJS(client: JSClient): IPager<IPlatformComment>? {
|
||||
val commentPager = _content.invoke<V8Value>("getComments", arrayOf<Any>());
|
||||
if (commentPager !is V8ValueObject) //TODO: Maybe handle this better?
|
||||
return null;
|
||||
|
||||
return JSCommentPager(_pluginConfig, client.getUnderlyingPlugin(), commentPager);
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ class DedupContentPager : IPager<IPlatformContent>, IAsyncPager<IPlatformContent
|
||||
_currentResults = dedupResults(_basePager.getResults());
|
||||
}
|
||||
|
||||
override fun hasMorePages(): Boolean = _basePager.hasMorePages();
|
||||
override fun hasMorePages(): Boolean =
|
||||
_basePager.hasMorePages();
|
||||
override fun nextPage() {
|
||||
_basePager.nextPage()
|
||||
_currentResults = dedupResults(_basePager.getResults());
|
||||
@@ -74,7 +75,12 @@ class DedupContentPager : IPager<IPlatformContent>, IAsyncPager<IPlatformContent
|
||||
return toReturn;
|
||||
}
|
||||
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 {
|
||||
return combineHashCodes(listOf(item.name.hashCode(), item.datetime?.hashCode()));
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@ import java.util.stream.IntStream
|
||||
* A PlatformContent MultiPager that orders the results of a page based on the datetime of a content item
|
||||
*/
|
||||
class MultiChronoContentPager : MultiPager<IPlatformContent> {
|
||||
constructor(pagers : Array<IPager<IPlatformContent>>, allowFailure: Boolean = false) : super(pagers.map { it }.toList(), allowFailure) {}
|
||||
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
|
||||
override fun selectItemIndex(options: Array<SelectionOption<IPlatformContent>>): Int {
|
||||
|
||||
+33
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -16,7 +16,7 @@ abstract class MultiPager<T> : IPager<T> {
|
||||
protected val _subSinglePagers : MutableList<SingleItemPager<T>>;
|
||||
protected val _failedPagers: ArrayList<IPager<T>> = arrayListOf();
|
||||
|
||||
private val _pageSize : Int = 9;
|
||||
private var _pageSize : Int = 9;
|
||||
|
||||
private var _didInitialize = false;
|
||||
|
||||
@@ -27,7 +27,8 @@ abstract class MultiPager<T> : IPager<T> {
|
||||
|
||||
val totalPagers: Int get() = _pagers.size;
|
||||
|
||||
constructor(pagers : List<IPager<T>>, allowFailure: Boolean = false) {
|
||||
constructor(pagers : List<IPager<T>>, allowFailure: Boolean = false, pageSize: Int = 9) {
|
||||
this._pageSize = pageSize;
|
||||
this.allowFailure = allowFailure;
|
||||
_pagers = pagers.toMutableList();
|
||||
_subSinglePagers = _pagers.map { SingleItemPager(it) }.toMutableList();
|
||||
|
||||
+18
-10
@@ -66,17 +66,25 @@ abstract class MultiRefreshPager<T>: IRefreshPager<T>, IPager<T> {
|
||||
override fun getResults(): List<T> = synchronized(_pagersReusable){ _currentPager.getResults() };
|
||||
|
||||
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 ?: "";
|
||||
_currentPager = PlaceholderPager(5) {
|
||||
return@PlaceholderPager PlatformContentPlaceholder(pluginId, error)
|
||||
} as IPager<T>;
|
||||
onPagerChanged.emit(_currentPager);
|
||||
}
|
||||
return;
|
||||
}
|
||||
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")
|
||||
_pagersReusable.add(pagerToAdd.asReusable());
|
||||
|
||||
|
||||
+19
@@ -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) {
|
||||
val startPos = fillDeferredUntil(_currentResultPos);
|
||||
if(!_pager.hasMorePages()) {
|
||||
Logger.i("SingleAsyncItemPager", "end of async page reached");
|
||||
completeRemainder { it?.complete(null) };
|
||||
}
|
||||
if(_isRequesting)
|
||||
|
||||
@@ -4,13 +4,22 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.media.MediaSession2Service.MediaNotification
|
||||
import androidx.concurrent.futures.CallbackToFutureAdapter
|
||||
import androidx.concurrent.futures.ResolvableFuture
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ListenableWorker
|
||||
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.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.cache.ChannelContentCache
|
||||
import com.futo.platformplayer.getNowDiffSeconds
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
@@ -27,10 +36,10 @@ import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
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) {
|
||||
override suspend fun doWork(): Result {
|
||||
if(StateApp.instance.isMainActive) {
|
||||
if(StateApp.instance.isMainActive && !inputData.getBoolean("bypassMainCheck", false)) {
|
||||
Logger.i("BackgroundWorker", "CANCELLED");
|
||||
return Result.success();
|
||||
}
|
||||
@@ -83,8 +92,11 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
|
||||
|
||||
val newSubChanges = hashSetOf<Subscription>();
|
||||
val newItems = mutableListOf<IPlatformContent>();
|
||||
|
||||
val now = OffsetDateTime.now();
|
||||
val contentNotifs = mutableListOf<Pair<Subscription, IPlatformContent>>();
|
||||
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}");
|
||||
|
||||
synchronized(manager) {
|
||||
@@ -97,21 +109,76 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
|
||||
}
|
||||
}, { sub, content ->
|
||||
synchronized(newSubChanges) {
|
||||
if(!newSubChanges.contains(sub))
|
||||
if(!newSubChanges.contains(sub)) {
|
||||
newSubChanges.add(sub);
|
||||
if(sub.doNotifications && content.datetime?.let { it < now } == true)
|
||||
contentNotifs.add(Pair(sub, 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);
|
||||
|
||||
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);
|
||||
val thumbnail = if(contentNotif.second is IPlatformVideo) (contentNotif.second as IPlatformVideo).thumbnails.getHQThumbnail()
|
||||
else null;
|
||||
if(thumbnail != null)
|
||||
Glide.with(appContext).asBitmap()
|
||||
.load(thumbnail)
|
||||
.into(object: CustomTarget<Bitmap>() {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, resource);
|
||||
}
|
||||
override fun onLoadCleared(placeholder: Drawable?) {}
|
||||
override fun onLoadFailed(errorDrawable: Drawable?) {
|
||||
notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, null);
|
||||
}
|
||||
})
|
||||
else
|
||||
notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, null);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e("BackgroundWorker", "Failed to create notif", ex);
|
||||
}
|
||||
}
|
||||
/*
|
||||
manager.notify(13, NotificationCompat.Builder(appContext, notificationChannel.id)
|
||||
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
|
||||
.setContentTitle("Grayjay")
|
||||
.setContentText("${newItems.size} new content from ${newSubChanges.size} creators")
|
||||
.setSilent(true)
|
||||
.setChannelId(notificationChannel.id).build());
|
||||
.setChannelId(notificationChannel.id).build());*/
|
||||
}
|
||||
|
||||
fun notifyNewContent(manager: NotificationManager, notificationChannel: NotificationChannel, id: Int, sub: Subscription, content: IPlatformContent, thumbnail: Bitmap? = null) {
|
||||
val notifBuilder = NotificationCompat.Builder(appContext, notificationChannel.id)
|
||||
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
|
||||
.setContentTitle("New by [${sub.channel.name}]")
|
||||
.setContentText("${content.name}")
|
||||
.setSilent(true)
|
||||
.setContentIntent(PendingIntent.getActivity(this.appContext, 0, MainActivity.getVideoIntent(this.appContext, content.url),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
.setChannelId(notificationChannel.id);
|
||||
if(thumbnail != null) {
|
||||
//notifBuilder.setLargeIcon(thumbnail);
|
||||
notifBuilder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(thumbnail).bigLargeIcon(null as Bitmap?));
|
||||
}
|
||||
manager.notify(id, notifBuilder.build());
|
||||
}
|
||||
}
|
||||
@@ -12,19 +12,52 @@ import com.futo.platformplayer.serializers.PlatformContentSerializer
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.futo.platformplayer.toSafeFileName
|
||||
import com.futo.polycentric.core.toUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.OffsetDateTime
|
||||
import kotlin.streams.toList
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
class ChannelContentCache {
|
||||
private val _targetCacheSize = 3000;
|
||||
val _channelCacheDir = FragmentedStorage.getOrCreateDirectory("channelCache");
|
||||
val _channelContents = HashMap(_channelCacheDir.listFiles()
|
||||
.filter { it.isDirectory }
|
||||
.associate { Pair(it.name, FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, it.name, PlatformContentSerializer())
|
||||
.withoutBackup()
|
||||
.load()) });
|
||||
val _channelContents: HashMap<String, ManagedStore<SerializedPlatformContent>>;
|
||||
init {
|
||||
val allFiles = _channelCacheDir.listFiles() ?: arrayOf();
|
||||
val initializeTime = measureTimeMillis {
|
||||
_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 getChannelCachePager(channelUrl: String): PlatformContentPager {
|
||||
val validID = channelUrl.toSafeFileName();
|
||||
@@ -38,7 +71,9 @@ class ChannelContentCache {
|
||||
return PlatformContentPager(items, Math.min(150, items.size));
|
||||
}
|
||||
fun getSubscriptionCachePager(): DedupContentPager {
|
||||
Logger.i(TAG, "Subscriptions CachePager get subscriptions");
|
||||
val subs = StateSubscriptions.instance.getSubscriptions();
|
||||
Logger.i(TAG, "Subscriptions CachePager polycentric urls");
|
||||
val allUrls = subs.map {
|
||||
val otherUrls = PolycentricCache.instance.getCachedProfile(it.channel.url)?.profile?.ownedClaims?.mapNotNull { c -> c.claim.resolveChannelUrl() } ?: listOf();
|
||||
if(!otherUrls.contains(it.channel.url))
|
||||
@@ -46,6 +81,7 @@ class ChannelContentCache {
|
||||
else
|
||||
return@map otherUrls;
|
||||
}.flatten().distinct();
|
||||
Logger.i(TAG, "Subscriptions CachePager compiling");
|
||||
val validSubIds = allUrls.map { it.toSafeFileName() }.toHashSet();
|
||||
|
||||
val validStores = _channelContents
|
||||
@@ -55,10 +91,14 @@ class ChannelContentCache {
|
||||
val items = validStores.flatMap { it.getItems() }
|
||||
.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) };
|
||||
}
|
||||
fun cacheContent(content: IPlatformContent, doUpdate: Boolean = false): Boolean {
|
||||
@@ -66,14 +106,14 @@ class ChannelContentCache {
|
||||
return false;
|
||||
|
||||
val channelId = content.author.url.toSafeFileName();
|
||||
val store = synchronized(_channelContents) {
|
||||
var channelStore = _channelContents.get(channelId);
|
||||
if(channelStore == null) {
|
||||
Logger.i(TAG, "New Subscription Cache for channel ${content.author.name}");
|
||||
channelStore = FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, channelId, PlatformContentSerializer()).load();
|
||||
_channelContents.put(channelId, channelStore);
|
||||
val store = getContentStore(channelId).let {
|
||||
if(it == null) {
|
||||
Logger.i(TAG, "New Channel Cache for channel ${content.author.name}");
|
||||
val store = FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, channelId, PlatformContentSerializer()).load();
|
||||
_channelContents.put(channelId, store);
|
||||
return@let store;
|
||||
}
|
||||
return@synchronized channelStore;
|
||||
else return@let it;
|
||||
}
|
||||
val serialized = SerializedPlatformContent.fromContent(content);
|
||||
val existing = store.findItems { it.url == content.url };
|
||||
@@ -88,6 +128,17 @@ class ChannelContentCache {
|
||||
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 {
|
||||
private val TAG = "ChannelCache";
|
||||
|
||||
@@ -95,10 +146,11 @@ class ChannelContentCache {
|
||||
private var _instance: ChannelContentCache? = null;
|
||||
val instance: ChannelContentCache get() {
|
||||
synchronized(_lock) {
|
||||
if(_instance == null)
|
||||
if(_instance == null) {
|
||||
_instance = ChannelContentCache();
|
||||
return _instance!!;
|
||||
}
|
||||
}
|
||||
return _instance!!;
|
||||
}
|
||||
|
||||
fun cachePagerResults(scope: CoroutineScope, pager: IPager<IPlatformContent>, onNewCacheHit: ((IPlatformContent)->Unit)? = null): IPager<IPlatformContent> {
|
||||
@@ -114,7 +166,7 @@ class ChannelContentCache {
|
||||
Logger.i(TAG, "Caching ${results.size} subscription initial results [${pager.hashCode()}]");
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val newCacheItems = instance.cacheVideos(results);
|
||||
val newCacheItems = instance.cacheContents(results);
|
||||
if(onNewCacheItem != null)
|
||||
newCacheItems.forEach { onNewCacheItem!!(it) }
|
||||
} catch (e: Throwable) {
|
||||
@@ -134,7 +186,7 @@ class ChannelContentCache {
|
||||
Logger.i(TAG, "Caching ${results.size} subscription results");
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val newCacheItems = instance.cacheVideos(results);
|
||||
val newCacheItems = instance.cacheContents(results);
|
||||
if(onNewCacheItem != null)
|
||||
newCacheItems.forEach { onNewCacheItem!!(it) }
|
||||
} catch (e: Throwable) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.builders.DashBuilder
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
@@ -352,16 +353,25 @@ class StateCasting {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (videoSource is IVideoUrlSource) {
|
||||
if (videoSource is IVideoUrlSource)
|
||||
ad.loadVideo("BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble());
|
||||
} else if (audioSource is IAudioUrlSource) {
|
||||
else if(videoSource is IHLSManifestSource)
|
||||
ad.loadVideo("BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble());
|
||||
else if (audioSource is IAudioUrlSource)
|
||||
ad.loadVideo("BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble());
|
||||
} else if (videoSource is LocalVideoSource) {
|
||||
else if(audioSource is IHLSManifestAudioSource)
|
||||
ad.loadVideo("BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble());
|
||||
else if (videoSource is LocalVideoSource)
|
||||
castLocalVideo(video, videoSource, resumePosition);
|
||||
} else if (audioSource is LocalAudioSource) {
|
||||
else if (audioSource is LocalAudioSource)
|
||||
castLocalAudio(video, audioSource, resumePosition);
|
||||
} else {
|
||||
throw Exception("Unhandled source type videoSource=$videoSource audioSource=$audioSource subtitleSource=$subtitleSource");
|
||||
else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.futo.platformplayer.developer
|
||||
|
||||
import android.content.Context
|
||||
import com.futo.platformplayer.activities.CaptchaActivity
|
||||
import com.futo.platformplayer.activities.LoginActivity
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.http.server.HttpContext
|
||||
@@ -201,6 +202,28 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
context.respondCode(500, (ex::class.simpleName + ":" + ex.message) ?: "", "text/plain")
|
||||
}
|
||||
}
|
||||
@HttpPOST("/plugin/captchaTestPlugin")
|
||||
fun pluginCaptchaTestPlugin(context: HttpContext) {
|
||||
val config = _testPlugin?.config as SourcePluginConfig;
|
||||
val url = context.query.get("url")
|
||||
val html = context.readContentString();
|
||||
try {
|
||||
val captchaConfig = config.captcha;
|
||||
if(captchaConfig == null) {
|
||||
context.respondCode(403, "This plugin doesn't support captcha");
|
||||
return;
|
||||
}
|
||||
CaptchaActivity.showCaptcha(StateApp.instance.context, config, url, html) {
|
||||
_testPluginVariables.clear();
|
||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, it), JSHttpClient(null, null, it));
|
||||
|
||||
};
|
||||
context.respondCode(200, "Captcha started");
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
context.respondCode(500, (ex::class.simpleName + ":" + ex.message) ?: "", "text/plain")
|
||||
}
|
||||
}
|
||||
@HttpGET("/plugin/loginTestPlugin")
|
||||
fun pluginLoginTestPlugin(context: HttpContext) {
|
||||
val config = _testPlugin?.config as SourcePluginConfig;
|
||||
@@ -264,7 +287,6 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
@HttpPOST("/plugin/remoteCall")
|
||||
fun pluginRemoteCall(context: HttpContext) {
|
||||
try {
|
||||
val parameters = context.readContentString();
|
||||
val objId = context.query.get("id")
|
||||
val method = context.query.get("method")
|
||||
|
||||
@@ -276,12 +298,15 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
context.respondCode(400, "Missing method");
|
||||
return;
|
||||
}
|
||||
if(method != "isLoggedIn")
|
||||
Logger.i(TAG, "Remote Call [${objId}].${method}(...)");
|
||||
|
||||
val parameters = context.readContentString(); //TODO: Temporary
|
||||
|
||||
val remoteObj = getRemoteObject(objId);
|
||||
val paras = JsonParser.parseString(parameters);
|
||||
if(!paras.isJsonArray)
|
||||
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 json = wrapRemoteResult(callResult, false);
|
||||
context.respondCode(200, json, "application/json");
|
||||
|
||||
@@ -2,12 +2,17 @@ package com.futo.platformplayer.dialogs
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.*
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
@@ -32,6 +37,8 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
|
||||
private lateinit var _buttonCancel: MaterialButton;
|
||||
private lateinit var _editComment: EditText;
|
||||
private lateinit var _inputMethodManager: InputMethodManager;
|
||||
private lateinit var _textCharacterCount: TextView;
|
||||
private lateinit var _textCharacterCountMax: TextView;
|
||||
|
||||
val onCommentAdded = Event1<IPlatformComment>();
|
||||
|
||||
@@ -42,6 +49,26 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
|
||||
_buttonCancel = findViewById(R.id.button_cancel);
|
||||
_buttonCreate = findViewById(R.id.button_create);
|
||||
_editComment = findViewById(R.id.edit_comment);
|
||||
_textCharacterCount = findViewById(R.id.character_count);
|
||||
_textCharacterCountMax = findViewById(R.id.character_count_max);
|
||||
|
||||
_editComment.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) = Unit
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
_textCharacterCount.text = count.toString();
|
||||
|
||||
if (count > PolycentricPlatformComment.MAX_COMMENT_SIZE) {
|
||||
_textCharacterCount.setTextColor(Color.RED);
|
||||
_textCharacterCountMax.setTextColor(Color.RED);
|
||||
_buttonCreate.alpha = 0.4f;
|
||||
} else {
|
||||
_textCharacterCount.setTextColor(Color.WHITE);
|
||||
_textCharacterCountMax.setTextColor(Color.WHITE);
|
||||
_buttonCreate.alpha = 1.0f;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
|
||||
|
||||
@@ -53,13 +80,20 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
|
||||
_buttonCreate.setOnClickListener {
|
||||
clearFocus();
|
||||
|
||||
if (_editComment.text.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) {
|
||||
UIDialogs.toast(context, "Comment should be less than 5000 characters");
|
||||
return@setOnClickListener;
|
||||
}
|
||||
|
||||
val comment = _editComment.text.toString();
|
||||
val processHandle = StatePolycentric.instance.processHandle!!
|
||||
val eventPointer = processHandle.post(comment, null, ref)
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
processHandle.fullyBackfillServers()
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers.", e);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
package com.futo.platformplayer.downloads
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.arthenica.ffmpegkit.*
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.*
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.toHumanBitrate
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.*
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.coroutines.resumeWithException
|
||||
@@ -43,7 +48,7 @@ class VideoExport {
|
||||
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");
|
||||
|
||||
val v = videoSource;
|
||||
@@ -55,34 +60,47 @@ class VideoExport {
|
||||
if (a != null) sourceCount++;
|
||||
if (s != null) sourceCount++;
|
||||
|
||||
var outputFile: File? = null;
|
||||
val moviesRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES);
|
||||
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();
|
||||
|
||||
val outputFile: DocumentFile?;
|
||||
val downloadRoot = StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set");
|
||||
if (sourceCount > 1) {
|
||||
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.");
|
||||
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;
|
||||
} else if (v != null) {
|
||||
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.");
|
||||
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;
|
||||
} else if (a != null) {
|
||||
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.");
|
||||
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;
|
||||
} else {
|
||||
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) {
|
||||
var inputStream: FileInputStream? = null
|
||||
var outputStream: FileOutputStream? = null
|
||||
|
||||
try {
|
||||
val srcFile = File(fromPath)
|
||||
@@ -190,17 +207,7 @@ class VideoExport {
|
||||
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)
|
||||
outputStream = FileOutputStream(dstFile)
|
||||
|
||||
val buffer = ByteArray(bufferSize)
|
||||
val totalBytes = srcFile.length()
|
||||
@@ -221,7 +228,6 @@ class VideoExport {
|
||||
throw IOException("Error occurred while copying file: ${e.message}", e)
|
||||
} finally {
|
||||
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;
|
||||
}
|
||||
}
|
||||
+13
-16
@@ -8,9 +8,8 @@ import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
class EncryptionProvider {
|
||||
class GEncryptionProviderV0 {
|
||||
private val _keyStore: KeyStore;
|
||||
private val secretKey: Key? get() = _keyStore.getKey(KEY_ALIAS, null);
|
||||
|
||||
@@ -25,45 +24,43 @@ class EncryptionProvider {
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setRandomizedEncryptionRequired(false)
|
||||
.build());
|
||||
|
||||
|
||||
keyGenerator.generateKey();
|
||||
}
|
||||
}
|
||||
|
||||
fun encrypt(decrypted: String, password: String? = null): String {
|
||||
val encodedBytes = encrypt(decrypted.toByteArray(), password);
|
||||
fun encrypt(decrypted: String): String {
|
||||
val encodedBytes = encrypt(decrypted.toByteArray());
|
||||
val encrypted = Base64.encodeToString(encodedBytes, Base64.DEFAULT);
|
||||
return encrypted;
|
||||
}
|
||||
fun encrypt(decrypted: ByteArray, password: String? = null): ByteArray {
|
||||
fun encrypt(decrypted: ByteArray): ByteArray {
|
||||
val c: Cipher = Cipher.getInstance(AES_MODE);
|
||||
val keyToUse = if(password == null) secretKey else SecretKeySpec(password.toByteArray(), "AES");
|
||||
c.init(Cipher.ENCRYPT_MODE, keyToUse, GCMParameterSpec(128, FIXED_IV));
|
||||
c.init(Cipher.ENCRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
|
||||
val encodedBytes: ByteArray = c.doFinal(decrypted);
|
||||
return encodedBytes;
|
||||
}
|
||||
|
||||
fun decrypt(encrypted: String, password: String? = null): String {
|
||||
fun decrypt(encrypted: String): String {
|
||||
val c = Cipher.getInstance(AES_MODE);
|
||||
val keyToUse = if(password == null) secretKey else SecretKeySpec(password.toByteArray(), "AES");
|
||||
c.init(Cipher.DECRYPT_MODE, keyToUse, GCMParameterSpec(128, FIXED_IV));
|
||||
c.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
|
||||
val decrypted = String(c.doFinal(Base64.decode(encrypted, Base64.DEFAULT)));
|
||||
return decrypted;
|
||||
}
|
||||
fun decrypt(encrypted: ByteArray, password: String? = null): ByteArray {
|
||||
fun decrypt(encrypted: ByteArray): ByteArray {
|
||||
val c = Cipher.getInstance(AES_MODE);
|
||||
val keyToUse = if(password == null) secretKey else SecretKeySpec(password.toByteArray(), "AES");
|
||||
c.init(Cipher.DECRYPT_MODE, keyToUse, GCMParameterSpec(128, FIXED_IV));
|
||||
c.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
|
||||
return c.doFinal(encrypted);
|
||||
}
|
||||
|
||||
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 const val AndroidKeyStore = "AndroidKeyStore";
|
||||
private const val KEY_ALIAS = "FUTOMedia_Key";
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -298,6 +298,7 @@ class V8Plugin {
|
||||
private fun throwExceptionFromV8(config: IV8PluginConfig, pluginType: String, msg: String, innerEx: Exception? = null, stack: String? = null, code: String? = null) {
|
||||
when(pluginType) {
|
||||
"ScriptException" -> throw ScriptException(config, msg, innerEx, stack, code);
|
||||
"CriticalException" -> throw ScriptCriticalException(config, msg, innerEx, stack, code);
|
||||
"AgeException" -> throw ScriptAgeException(config, msg, innerEx, stack, code);
|
||||
"UnavailableException" -> throw ScriptUnavailableException(config, msg, innerEx, stack, code);
|
||||
"ScriptExecutionException" -> throw ScriptExecutionException(config, msg, innerEx, stack, code);
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.futo.platformplayer.engine.exceptions
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
open class ScriptCriticalException(config: IV8PluginConfig, error: String, ex: Exception? = null, stack: String? = null, code: String? = null) : ScriptException(config, error, ex, stack, code) {
|
||||
|
||||
|
||||
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : ScriptException {
|
||||
return ScriptCriticalException(config, obj.getOrThrow(config, "message", "ScriptCriticalException"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.futo.platformplayer.exceptions
|
||||
|
||||
class RateLimitException : Throwable {
|
||||
val pluginIds: List<String>;
|
||||
|
||||
constructor(pluginIds: List<String>): super() {
|
||||
this.pluginIds = pluginIds ?: listOf();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.futo.platformplayer.exceptions
|
||||
|
||||
import java.lang.Exception
|
||||
|
||||
class UnsupportedCastException(msg: String) : Exception(msg) {
|
||||
}
|
||||
+2
-2
@@ -77,7 +77,7 @@ class ChannelAboutFragment : Fragment, IChannelTabFragment {
|
||||
};
|
||||
_textName?.text = channel.name;
|
||||
|
||||
val metadata = "${channel.subscribers.toHumanNumber()} subscribers";
|
||||
val metadata = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} " + (context?.getString(R.string.subscribers)?.lowercase() ?: "") else "";
|
||||
_textMetadata?.text = metadata;
|
||||
_lastChannel = channel;
|
||||
setLinks(channel.links, channel.name);
|
||||
@@ -91,7 +91,7 @@ class ChannelAboutFragment : Fragment, IChannelTabFragment {
|
||||
l.removeAllViews();
|
||||
|
||||
if (links.isNotEmpty()) {
|
||||
_textFindOn?.text = "Find $name on";
|
||||
_textFindOn?.text = getString(R.string.find_name_on).replace("{name}", name);
|
||||
_textFindOn?.visibility = View.VISIBLE;
|
||||
|
||||
for (pair in links) {
|
||||
|
||||
+17
-8
@@ -25,6 +25,7 @@ import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.api.media.structures.IRefreshPager
|
||||
import com.futo.platformplayer.api.media.structures.IReplacerPager
|
||||
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.Event2
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
@@ -33,6 +34,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.FeedView
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.adapters.PreviewContentListAdapter
|
||||
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
||||
@@ -74,9 +76,14 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
||||
|
||||
private val _taskLoadVideos = TaskHandler<IPlatformChannel, IPager<IPlatformContent>>({lifecycleScope}, {
|
||||
return@TaskHandler getContentPager(it);
|
||||
}).success {
|
||||
}).success { livePager ->
|
||||
setLoading(false);
|
||||
setPager(it);
|
||||
|
||||
val pager = if(_channel?.let { StateSubscriptions.instance.isSubscribed(it) } == true)
|
||||
ChannelContentCache.cachePagerResults(lifecycleScope, livePager);
|
||||
else livePager;
|
||||
|
||||
setPager(pager);
|
||||
}
|
||||
.exception<ScriptCaptchaRequiredException> { }
|
||||
.exception<Throwable> {
|
||||
@@ -248,7 +255,6 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
||||
|
||||
if(_pager is IReplacerPager<*>)
|
||||
(_pager as IReplacerPager<*>).onReplaced.remove(this);
|
||||
|
||||
if(pager is IReplacerPager<*>) {
|
||||
pager.onReplaced.subscribe(this) { oldItem, newItem ->
|
||||
if(_pager != pager)
|
||||
@@ -257,11 +263,14 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
||||
if(_pager !is IPager<IPlatformContent>)
|
||||
return@subscribe;
|
||||
|
||||
val toReplaceIndex = _results.indexOfFirst { it == newItem };
|
||||
if(toReplaceIndex >= 0) {
|
||||
_results[toReplaceIndex] = newItem as IPlatformContent;
|
||||
_adapterResults?.let {
|
||||
it.notifyItemChanged(it.childToParentPosition(toReplaceIndex));
|
||||
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
val toReplaceIndex = _results.indexOfFirst { it == oldItem };
|
||||
if (toReplaceIndex >= 0) {
|
||||
_results[toReplaceIndex] = newItem as IPlatformContent;
|
||||
_adapterResults?.let {
|
||||
it.notifyItemChanged(it.childToParentPosition(toReplaceIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ class ChannelListFragment : Fragment, IChannelTabFragment {
|
||||
}.exception<ScriptCaptchaRequiredException> { }
|
||||
.exceptionWithParameter<Throwable> { ex, para ->
|
||||
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();
|
||||
};
|
||||
|
||||
|
||||
+20
-33
@@ -1,21 +1,20 @@
|
||||
package com.futo.platformplayer.fragment.channel.tab
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.platformplayer.views.SupportView
|
||||
|
||||
|
||||
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 _lastPolycentricProfile: PolycentricProfile? = null;
|
||||
@@ -24,51 +23,39 @@ class ChannelMonetizationFragment : Fragment, IChannelTabFragment {
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
val view = inflater.inflate(R.layout.fragment_channel_monetization, container, false);
|
||||
_buttonStore = view.findViewById(R.id.button_store);
|
||||
|
||||
_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);
|
||||
}
|
||||
}
|
||||
};
|
||||
_supportView = view.findViewById(R.id.support);
|
||||
_textMonetization = view.findViewById(R.id.text_monetization);
|
||||
|
||||
_lastChannel?.also {
|
||||
setChannel(it);
|
||||
};
|
||||
|
||||
_lastPolycentricProfile?.also {
|
||||
setPolycentricProfile(it, animate = false);
|
||||
}
|
||||
|
||||
_supportView?.visibility = View.GONE;
|
||||
_textMonetization?.visibility = View.GONE;
|
||||
setPolycentricProfile(_lastPolycentricProfile, animate = false);
|
||||
return view;
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView();
|
||||
_buttonStore = null;
|
||||
_supportView = null;
|
||||
_textMonetization = null;
|
||||
}
|
||||
|
||||
override fun setChannel(channel: IPlatformChannel) {
|
||||
_lastChannel = channel;
|
||||
_buttonStore?.visibility = View.GONE;
|
||||
}
|
||||
|
||||
fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) {
|
||||
_lastPolycentricProfile = polycentricProfile;
|
||||
|
||||
if (polycentricProfile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (polycentricProfile.systemState.store.isNotEmpty()) {
|
||||
_buttonStore?.visibility = View.VISIBLE;
|
||||
_lastPolycentricProfile = polycentricProfile
|
||||
if (polycentricProfile != null) {
|
||||
_supportView?.setPolycentricProfile(polycentricProfile, animate)
|
||||
_supportView?.visibility = View.VISIBLE
|
||||
_textMonetization?.visibility = View.GONE
|
||||
} else {
|
||||
_supportView?.setPolycentricProfile(null, animate)
|
||||
_supportView?.visibility = View.GONE
|
||||
_textMonetization?.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-2
@@ -220,6 +220,13 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
buttons.removeAt(buyIndex)
|
||||
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) {
|
||||
val button = MenuButton(context, data, _fragment, true);
|
||||
@@ -245,8 +252,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
val defs = currentButtonDefinitions?.toMutableList() ?: return
|
||||
val metrics = StateApp.instance.displayMetrics ?: resources.displayMetrics;
|
||||
_buttonsVisible = floor(metrics.widthPixels.toDouble() / 65.dp(resources).toDouble()).roundToInt();
|
||||
if (_buttonsVisible - 2 >= defs.size) {
|
||||
updateBottomMenuButtons(defs.slice(IntRange(0, defs.size - 1)).toMutableList(), false);
|
||||
if (_buttonsVisible - 1 >= defs.size) {
|
||||
updateBottomMenuButtons(defs.toMutableList(), false);
|
||||
} else {
|
||||
updateBottomMenuButtons(defs.slice(IntRange(0, _buttonsVisible - 2)).toMutableList(), true);
|
||||
updateMoreButtons(defs.slice(IntRange(_buttonsVisible - 1, defs.size - 1)).toMutableList());
|
||||
@@ -289,6 +296,9 @@ class MenuBottomBarFragment : MainActivityFragment() {
|
||||
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(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
|
||||
|
||||
|
||||
+16
-12
@@ -10,6 +10,7 @@ import android.widget.TextView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.futopay.PaymentConfigurations
|
||||
import com.futo.futopay.PaymentManager
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -59,18 +60,21 @@ class BuyFragment : MainFragment() {
|
||||
|
||||
_paymentManager = PaymentManager(StatePayment.instance, fragment, _overlayPaying) { success, purchaseId, exception ->
|
||||
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));
|
||||
_fragment.close(true);
|
||||
}
|
||||
else {
|
||||
UIDialogs.showGeneralErrorDialog(context, "Payment failed", exception);
|
||||
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.payment_failed), exception);
|
||||
}
|
||||
}
|
||||
|
||||
_buttonBuy.setOnClickListener {
|
||||
buy();
|
||||
}
|
||||
if(!BuildConfig.IS_PLAYSTORE_BUILD)
|
||||
_buttonBuy.setOnClickListener {
|
||||
buy();
|
||||
}
|
||||
else
|
||||
_buttonBuy.visibility = View.GONE;
|
||||
_buttonPaid.setOnClickListener {
|
||||
paid();
|
||||
}
|
||||
@@ -87,7 +91,7 @@ class BuyFragment : MainFragment() {
|
||||
val price = prices[currency.id]!!;
|
||||
val priceDecimal = (price.toDouble() / 100);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,12 +107,12 @@ class BuyFragment : MainFragment() {
|
||||
}
|
||||
|
||||
private fun paid() {
|
||||
val licenseInput = SlideUpMenuTextInput(context, "License");
|
||||
val productLicenseDialog = SlideUpMenuOverlay(context, findViewById<FrameLayout>(R.id.overlay_paid), "Enter license key", "Ok", true, licenseInput);
|
||||
val licenseInput = SlideUpMenuTextInput(context, context.getString(R.string.license));
|
||||
val productLicenseDialog = SlideUpMenuOverlay(context, findViewById<FrameLayout>(R.id.overlay_paid), context.getString(R.string.enter_license_key), context.getString(R.string.ok), true, licenseInput);
|
||||
productLicenseDialog.onOK.subscribe {
|
||||
val licenseText = licenseInput.text;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -123,19 +127,19 @@ class BuyFragment : MainFragment() {
|
||||
licenseInput.clear();
|
||||
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);
|
||||
}
|
||||
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) {
|
||||
Logger.e("BuyFragment", "Failed to activate key", ex);
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.showGeneralErrorDialog(context, "Failed to activate key", ex);
|
||||
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_activate_key), ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+107
-56
@@ -9,6 +9,7 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
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.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||
import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter
|
||||
@@ -100,6 +102,7 @@ class ChannelFragment : MainFragment() {
|
||||
private var _viewPager: ViewPager2;
|
||||
private var _tabLayoutMediator: TabLayoutMediator;
|
||||
private var _buttonSubscribe: SubscribeButton;
|
||||
private var _buttonSubscriptionSettings: ImageButton;
|
||||
|
||||
private var _overlayContainer: FrameLayout;
|
||||
private var _overlay_loading: LinearLayout;
|
||||
@@ -141,7 +144,7 @@ class ChannelFragment : MainFragment() {
|
||||
|
||||
UIDialogs.showDialog(context,
|
||||
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,
|
||||
UIDialogs.Action("Back", {
|
||||
fragment.close(true);
|
||||
@@ -160,10 +163,25 @@ class ChannelFragment : MainFragment() {
|
||||
_creatorThumbnail = findViewById(R.id.creator_thumbnail);
|
||||
_imageBanner = findViewById(R.id.image_channel_banner);
|
||||
_buttonSubscribe = findViewById(R.id.button_subscribe);
|
||||
_buttonSubscriptionSettings = findViewById(R.id.button_sub_settings);
|
||||
_overlay_loading = findViewById(R.id.channel_loading_overlay);
|
||||
_overlay_loading_spinner = findViewById(R.id.channel_loader);
|
||||
_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)
|
||||
viewPager.isSaveEnabled = false;
|
||||
viewPager.registerOnPageChangeCallback(_onPageChangeCallback);
|
||||
@@ -246,28 +264,46 @@ class ChannelFragment : MainFragment() {
|
||||
|
||||
if (parameter is String) {
|
||||
_buttonSubscribe.setSubscribeChannel(parameter);
|
||||
_textChannel.text = "";
|
||||
_textChannelSub.text = "";
|
||||
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
|
||||
setPolycentricProfileOr(parameter) {
|
||||
_textChannel.text = "";
|
||||
_textChannelSub.text = "";
|
||||
_creatorThumbnail.setThumbnail(null, true);
|
||||
Glide.with(_imageBanner)
|
||||
.clear(_imageBanner);
|
||||
};
|
||||
|
||||
_url = parameter;
|
||||
loadChannel();
|
||||
} else if (parameter is SerializedChannel) {
|
||||
showChannel(parameter);
|
||||
_url = parameter.url;
|
||||
_creatorThumbnail.setThumbnail(parameter.url, false);
|
||||
loadChannel();
|
||||
} else if (parameter is IPlatformChannel)
|
||||
showChannel(parameter);
|
||||
else if (parameter is PlatformAuthorLink) {
|
||||
_textChannel.text = parameter.name;
|
||||
_textChannelSub.text = "";
|
||||
_creatorThumbnail.setThumbnail(parameter.url, false);
|
||||
setPolycentricProfileOr(parameter.url) {
|
||||
_textChannel.text = parameter.name;
|
||||
_textChannelSub.text = "";
|
||||
_creatorThumbnail.setThumbnail(parameter.thumbnail, true);
|
||||
Glide.with(_imageBanner)
|
||||
.clear(_imageBanner);
|
||||
|
||||
_taskLoadPolycentricProfile.run(parameter.id);
|
||||
};
|
||||
|
||||
_url = parameter.url;
|
||||
loadChannel();
|
||||
} else if (parameter is Subscription) {
|
||||
_textChannel.text = parameter.channel.name;
|
||||
_textChannelSub.text = "";
|
||||
_creatorThumbnail.setThumbnail(parameter.channel.thumbnail, false);
|
||||
setPolycentricProfileOr(parameter.channel.url) {
|
||||
_textChannel.text = parameter.channel.name;
|
||||
_textChannelSub.text = "";
|
||||
_creatorThumbnail.setThumbnail(parameter.channel.thumbnail, true);
|
||||
Glide.with(_imageBanner)
|
||||
.clear(_imageBanner);
|
||||
|
||||
_taskLoadPolycentricProfile.run(parameter.channel.id);
|
||||
};
|
||||
|
||||
_url = parameter.channel.url;
|
||||
loadChannel();
|
||||
@@ -327,19 +363,19 @@ class ChannelFragment : MainFragment() {
|
||||
_fragment.topBar?.onShown(channel);
|
||||
|
||||
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) {
|
||||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
StatePlaylists.instance.createPlaylistFromChannel(channel) { page ->
|
||||
_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) {
|
||||
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) {
|
||||
@@ -350,24 +386,22 @@ class ChannelFragment : MainFragment() {
|
||||
});
|
||||
});
|
||||
|
||||
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url);
|
||||
if (plugin != null && plugin.capabilities.hasSearchChannelContents) {
|
||||
buttons.add(Pair(R.drawable.ic_search) {
|
||||
_fragment.navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO, channel.url));
|
||||
});
|
||||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url);
|
||||
withContext(Dispatchers.Main) {
|
||||
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);
|
||||
_textChannel.text = channel.name;
|
||||
_textChannelSub.text = "${channel.subscribers.toHumanNumber()} subscribers";
|
||||
|
||||
_creatorThumbnail.setThumbnail(channel.thumbnail, true);
|
||||
Glide.with(_imageBanner)
|
||||
.load(channel.banner)
|
||||
.crossfade()
|
||||
.into(_imageBanner)
|
||||
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
|
||||
_textChannelSub.text = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} " + context.getString(R.string.subscribers).lowercase() else "";
|
||||
|
||||
//TODO: Find a better way to access the adapter fragments..
|
||||
|
||||
@@ -381,51 +415,68 @@ class ChannelFragment : MainFragment() {
|
||||
|
||||
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) {
|
||||
setPolycentricProfile(cachedProfile, animate = false);
|
||||
} else {
|
||||
setPolycentricProfile(null, animate = false);
|
||||
_taskLoadPolycentricProfile.run(channel.id);
|
||||
or();
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
|
||||
Log.i(TAG, "setPolycentricProfile(cachedPolycentricProfile = $cachedPolycentricProfile, animate = $animate)")
|
||||
|
||||
val polycentricProfile = cachedPolycentricProfile?.profile;
|
||||
if (polycentricProfile != null) {
|
||||
_fragment.topBar?.onShown(polycentricProfile);
|
||||
val dp_35 = 35.dp(resources)
|
||||
val profile = cachedPolycentricProfile?.profile;
|
||||
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())
|
||||
_textChannel.text = polycentricProfile.systemState.username;
|
||||
if (avatar != null) {
|
||||
_creatorThumbnail.setThumbnail(avatar, animate);
|
||||
} else {
|
||||
_creatorThumbnail.setThumbnail(channel?.thumbnail, animate);
|
||||
_creatorThumbnail.setHarborAvailable(profile != null, animate);
|
||||
}
|
||||
|
||||
val dp_35 = 35.dp(resources)
|
||||
val avatar = polycentricProfile.systemState.avatar?.selectBestImage(dp_35 * dp_35)
|
||||
?.let { it.toURLInfoSystemLinkUrl(polycentricProfile.system.toProto(), it.process, polycentricProfile.systemState.servers.toList()) };
|
||||
val banner = profile?.systemState?.banner?.selectHighestResolutionImage()
|
||||
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) };
|
||||
|
||||
if (avatar != null) {
|
||||
_creatorThumbnail.setThumbnail(avatar, true);
|
||||
} else {
|
||||
_creatorThumbnail.setHarborAvailable(true, true);
|
||||
}
|
||||
if (banner != null) {
|
||||
Glide.with(_imageBanner)
|
||||
.load(banner)
|
||||
.crossfade()
|
||||
.into(_imageBanner);
|
||||
} else {
|
||||
Glide.with(_imageBanner)
|
||||
.load(channel?.banner)
|
||||
.crossfade()
|
||||
.into(_imageBanner);
|
||||
}
|
||||
|
||||
val banner = polycentricProfile.systemState.banner?.selectHighestResolutionImage()
|
||||
?.let { it.toURLInfoSystemLinkUrl(polycentricProfile.system.toProto(), it.process, polycentricProfile.systemState.servers.toList()) };
|
||||
|
||||
if (banner != null) {
|
||||
Glide.with(_imageBanner)
|
||||
.load(banner)
|
||||
.crossfade()
|
||||
.into(_imageBanner);
|
||||
}
|
||||
if (profile != null) {
|
||||
_fragment.topBar?.onShown(profile);
|
||||
_textChannel.text = profile.systemState.username;
|
||||
}
|
||||
|
||||
(_viewPager.adapter as ChannelViewPagerAdapter?)?.let {
|
||||
it.getFragment<ChannelAboutFragment>().setPolycentricProfile(polycentricProfile, animate);
|
||||
it.getFragment<ChannelMonetizationFragment>().setPolycentricProfile(polycentricProfile, animate);
|
||||
it.getFragment<ChannelListFragment>().setPolycentricProfile(polycentricProfile, animate);
|
||||
it.getFragment<ChannelContentsFragment>().setPolycentricProfile(polycentricProfile, animate);
|
||||
it.getFragment<ChannelAboutFragment>().setPolycentricProfile(profile, animate);
|
||||
it.getFragment<ChannelMonetizationFragment>().setPolycentricProfile(profile, animate);
|
||||
it.getFragment<ChannelListFragment>().setPolycentricProfile(profile, animate);
|
||||
it.getFragment<ChannelContentsFragment>().setPolycentricProfile(profile, animate);
|
||||
//TODO: Call on other tabs as needed
|
||||
}
|
||||
}
|
||||
|
||||
+21
-9
@@ -15,7 +15,9 @@ import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.structures.*
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateMeta
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.video.PlayerManager
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.adapters.PreviewContentListAdapter
|
||||
@@ -24,6 +26,7 @@ import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
|
||||
import com.futo.platformplayer.views.adapters.InsertedViewHolder
|
||||
import com.futo.platformplayer.views.adapters.PreviewNestedVideoViewHolder
|
||||
import com.futo.platformplayer.views.adapters.PreviewVideoViewHolder
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||
import kotlin.math.floor
|
||||
|
||||
abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent, IPlatformContent, IPager<IPlatformContent>, ContentPreviewViewHolder> where TFragment : MainFragment {
|
||||
@@ -69,22 +72,31 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
||||
//TODO: Reconstruct search video from detail if search is null
|
||||
_overlayContainer.let {
|
||||
if(content is IPlatformVideo)
|
||||
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));
|
||||
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);
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
adapter.onAddToQueueClicked.subscribe(this) {
|
||||
if(it is IPlatformVideo) {
|
||||
StatePlayer.instance.addToQueue(it);
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
+14
-10
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
||||
import com.futo.platformplayer.isHttpUrl
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -62,7 +63,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
}
|
||||
|
||||
fun setPreviewsEnabled(previewsEnabled: Boolean) {
|
||||
_view?.setPreviewsEnabled(previewsEnabled);
|
||||
_view?.setPreviewsEnabled(previewsEnabled && Settings.instance.search.previewFeedItems);
|
||||
}
|
||||
|
||||
@SuppressLint("ViewConstructor")
|
||||
@@ -89,9 +90,11 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
})
|
||||
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
|
||||
.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() });
|
||||
}
|
||||
|
||||
setPreviewsEnabled(Settings.instance.search.previewFeedItems);
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
@@ -101,14 +104,12 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
|
||||
fun onShown(parameter: Any?, isBack: Boolean) {
|
||||
if(parameter is SuggestionsFragmentData) {
|
||||
if(!isBack) {
|
||||
setQuery(parameter.query, false);
|
||||
setChannelUrl(parameter.channelUrl, false);
|
||||
setQuery(parameter.query, false);
|
||||
setChannelUrl(parameter.channelUrl, false);
|
||||
|
||||
fragment.topBar?.apply {
|
||||
if (this is SearchTopBarFragment) {
|
||||
this.setText(parameter.query);
|
||||
}
|
||||
fragment.topBar?.apply {
|
||||
if (this is SearchTopBarFragment) {
|
||||
this.setText(parameter.query);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,7 +146,10 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
};
|
||||
|
||||
onSearch.subscribe(this) {
|
||||
setQuery(it, true);
|
||||
if(it.isHttpUrl())
|
||||
navigate<VideoDetailFragment>(it);
|
||||
else
|
||||
setQuery(it, true);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+7
-9
@@ -71,16 +71,14 @@ class CreatorSearchResultsFragment : MainFragment() {
|
||||
|
||||
fun onShown(parameter: Any?, isBack: Boolean) {
|
||||
if(parameter is String) {
|
||||
if(!isBack) {
|
||||
setQuery(parameter);
|
||||
setQuery(parameter);
|
||||
|
||||
fragment.topBar?.apply {
|
||||
if (this is SearchTopBarFragment) {
|
||||
setText(parameter);
|
||||
onSearch.subscribe(this) {
|
||||
setQuery(it);
|
||||
};
|
||||
}
|
||||
fragment.topBar?.apply {
|
||||
if (this is SearchTopBarFragment) {
|
||||
setText(parameter);
|
||||
onSearch.subscribe(this) {
|
||||
setQuery(it);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -6,10 +6,12 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.Spinner
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.views.adapters.SubscriptionAdapter
|
||||
|
||||
class CreatorsFragment : MainFragment() {
|
||||
@@ -18,13 +20,16 @@ class CreatorsFragment : MainFragment() {
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var _spinnerSortBy: Spinner? = null;
|
||||
private var _overlayContainer: FrameLayout? = null;
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = inflater.inflate(R.layout.fragment_creators, container, false);
|
||||
|
||||
val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription));
|
||||
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);
|
||||
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);
|
||||
@@ -48,6 +53,7 @@ class CreatorsFragment : MainFragment() {
|
||||
override fun onDestroyMainView() {
|
||||
super.onDestroyMainView();
|
||||
_spinnerSortBy = null;
|
||||
_overlayContainer = null;
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
+4
-4
@@ -136,8 +136,8 @@ class DownloadsFragment : MainFragment() {
|
||||
|
||||
fun reloadUI() {
|
||||
val usage = StateDownloads.instance.getTotalUsage(true);
|
||||
_usageUsed.text = "${usage.usage.toHumanBytesSize()} Used";
|
||||
_usageAvailable.text = "${usage.available.toHumanBytesSize()} Available";
|
||||
_usageUsed.text = "${usage.usage.toHumanBytesSize()} " + context.getString(R.string.used);
|
||||
_usageAvailable.text = "${usage.available.toHumanBytesSize()} " + context.getString(R.string.available);
|
||||
_usageProgress.progress = usage.percentage.toFloat();
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ class DownloadsFragment : MainFragment() {
|
||||
_listPlaylistsContainer.visibility = GONE;
|
||||
else {
|
||||
_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();
|
||||
for(view in playlists.map { PlaylistDownloadItem(context, it) }) {
|
||||
@@ -176,7 +176,7 @@ class DownloadsFragment : MainFragment() {
|
||||
_listDownloadedHeader.visibility = GONE;
|
||||
} else {
|
||||
_listDownloadedHeader.visibility = VISIBLE;
|
||||
_listDownloadedMeta.text = "(${downloaded.size} videos)";
|
||||
_listDownloadedMeta.text = "(${downloaded.size} ${context.getString(R.string.videos).lowercase()})";
|
||||
}
|
||||
|
||||
_listDownloaded.setData(downloaded);
|
||||
|
||||
@@ -39,6 +39,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
private val _spinnerSortBy: Spinner;
|
||||
private val _containerSortBy: LinearLayout;
|
||||
private val _tagsView: TagsView;
|
||||
private val _textCentered: TextView;
|
||||
|
||||
protected val _toolbarContentView: LinearLayout;
|
||||
|
||||
@@ -68,6 +69,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
this.fragment = fragment;
|
||||
inflater.inflate(R.layout.fragment_feed, this);
|
||||
|
||||
_textCentered = findViewById(R.id.text_centered);
|
||||
_progress_bar = findViewById(R.id.progress_bar);
|
||||
_progress_bar.inactiveColor = Color.TRANSPARENT;
|
||||
|
||||
@@ -120,6 +122,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
|
||||
_toolbarContentView = findViewById(R.id.container_toolbar_content);
|
||||
|
||||
var filteredNextPageCounter = 0;
|
||||
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
|
||||
if (it is IAsyncPager<*>)
|
||||
it.nextPageAsync();
|
||||
@@ -139,10 +142,18 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
val filteredResults = filterResults(it);
|
||||
recyclerData.results.addAll(filteredResults);
|
||||
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> {
|
||||
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();
|
||||
});
|
||||
//UIDialogs.showDataRetryDialog(layoutInflater, it.message, { loadNextPage() });
|
||||
@@ -169,6 +180,10 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
_recyclerResults.addOnScrollListener(_scrollListener);
|
||||
}
|
||||
|
||||
protected fun setTextCentered(text: String?) {
|
||||
_textCentered.text = text;
|
||||
}
|
||||
|
||||
fun onResume() {
|
||||
//Reload the pager if the plugin was killed
|
||||
val pager = recyclerData.pager;
|
||||
@@ -250,7 +265,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
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
|
||||
UIDialogs.toast(it, kv.value.message ?: "", false);
|
||||
} catch (e: Throwable) {
|
||||
@@ -327,11 +342,11 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
parentPager.onPagerError.subscribe(this) {
|
||||
Logger.e(TAG, "Search pager failed: ${it.message}", 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 -> {
|
||||
//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}")
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+12
-10
@@ -78,7 +78,7 @@ class HomeFragment : MainFragment() {
|
||||
}
|
||||
|
||||
fun setPreviewsEnabled(previewsEnabled: Boolean) {
|
||||
_view?.setPreviewsEnabled(previewsEnabled);
|
||||
_view?.setPreviewsEnabled(previewsEnabled && Settings.instance.home.previewFeedItems);
|
||||
}
|
||||
|
||||
@SuppressLint("ViewConstructor")
|
||||
@@ -101,27 +101,29 @@ class HomeFragment : MainFragment() {
|
||||
.exception<ScriptCaptchaRequiredException> { }
|
||||
.exception<ScriptExecutionException> {
|
||||
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.Action("Ignore", {}),
|
||||
UIDialogs.Action("Sources", { fragment.navigate<SourcesFragment>() }, UIDialogs.ActionStyle.PRIMARY)
|
||||
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(context.getString(R.string.ignore), {}),
|
||||
UIDialogs.Action(context.getString(R.string.sources), { fragment.navigate<SourcesFragment>() }, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
}
|
||||
.exception<ScriptImplementationException> {
|
||||
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.Action("Ignore", {}),
|
||||
UIDialogs.Action("Sources", { fragment.navigate<SourcesFragment>() }, UIDialogs.ActionStyle.PRIMARY)
|
||||
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(context.getString(R.string.ignore), {}),
|
||||
UIDialogs.Action(context.getString(R.string.sources), { fragment.navigate<SourcesFragment>() }, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
}
|
||||
.exception<Throwable> {
|
||||
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()
|
||||
}) {
|
||||
finishRefreshLayoutLoader();
|
||||
setLoading(false);
|
||||
};
|
||||
};
|
||||
|
||||
setPreviewsEnabled(Settings.instance.home.previewFeedItems);
|
||||
}
|
||||
|
||||
fun onShown() {
|
||||
@@ -150,7 +152,7 @@ class HomeFragment : MainFragment() {
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -159,7 +161,7 @@ class HomeFragment : MainFragment() {
|
||||
}
|
||||
private fun loadedResult(pager : IPager<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}");
|
||||
|
||||
+4
-4
@@ -113,7 +113,7 @@ class ImportPlaylistsFragment : MainFragment() {
|
||||
}.exceptionWithParameter<Throwable> { ex, para ->
|
||||
//setLoading(false);
|
||||
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(); });
|
||||
loadNext();
|
||||
};
|
||||
@@ -144,14 +144,14 @@ class ImportPlaylistsFragment : MainFragment() {
|
||||
|
||||
val tb = _fragment.topBar as ImportTopBarFragment?;
|
||||
tb?.let {
|
||||
it.title = "Import Playlists";
|
||||
it.title = context.getString(R.string.import_playlists);
|
||||
it.onImport.subscribe(this) {
|
||||
val playlistsToImport = _items.filter { i -> i.selected }.toList();
|
||||
for (playlistToImport in playlistsToImport) {
|
||||
StatePlaylists.instance.createOrUpdatePlaylist(playlistToImport.playlist);
|
||||
}
|
||||
|
||||
UIDialogs.toast("${playlistsToImport.size} playlists imported.");
|
||||
UIDialogs.toast("${playlistsToImport.size} " + context.getString(R.string.playlists_imported));
|
||||
_fragment.closeSegment();
|
||||
};
|
||||
}
|
||||
@@ -175,7 +175,7 @@ class ImportPlaylistsFragment : MainFragment() {
|
||||
val itemsSelected = _items.count { i -> i.selected };
|
||||
if (itemsSelected > 0) {
|
||||
_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);
|
||||
} else {
|
||||
_textSelectDeselectAll.text = context.getString(R.string.select_all);
|
||||
|
||||
+35
-4
@@ -63,12 +63,15 @@ class ImportSubscriptionsFragment : MainFragment() {
|
||||
private var _textSelectDeselectAll: TextView;
|
||||
private var _textNothingToImport: TextView;
|
||||
private var _textCounter: TextView;
|
||||
private var _textLoadMore: TextView;
|
||||
private var _adapterView: AnyAdapterView<SelectableIPlatformChannel, ImportSubscriptionViewHolder>;
|
||||
private var _links: List<String> = listOf();
|
||||
private val _items: ArrayList<SelectableIPlatformChannel> = arrayListOf();
|
||||
private var _currentLoadIndex = 0;
|
||||
|
||||
private var _taskLoadChannel: TaskHandler<String, IPlatformChannel>;
|
||||
private var _counter: Int = 0;
|
||||
private var _limitToastShown = false;
|
||||
|
||||
constructor(fragment: ImportSubscriptionsFragment, inflater: LayoutInflater) : super(inflater.context) {
|
||||
_fragment = fragment;
|
||||
@@ -77,6 +80,7 @@ class ImportSubscriptionsFragment : MainFragment() {
|
||||
_textNothingToImport = findViewById(R.id.nothing_to_import);
|
||||
_textSelectDeselectAll = findViewById(R.id.text_select_deselect_all);
|
||||
_textCounter = findViewById(R.id.text_select_counter);
|
||||
_textLoadMore = findViewById(R.id.text_load_more);
|
||||
_spinner = findViewById(R.id.channel_loader);
|
||||
|
||||
_adapterView = findViewById<RecyclerView>(R.id.recycler_import).asAny( _items) {
|
||||
@@ -104,6 +108,7 @@ class ImportSubscriptionsFragment : MainFragment() {
|
||||
setLoading(false);
|
||||
|
||||
_taskLoadChannel = TaskHandler<String, IPlatformChannel>({_fragment.lifecycleScope}, { link ->
|
||||
_counter++;
|
||||
val channel: IPlatformChannel = StatePlatform.instance.getChannelLive(link, false);
|
||||
return@TaskHandler channel;
|
||||
}).success {
|
||||
@@ -113,10 +118,23 @@ class ImportSubscriptionsFragment : MainFragment() {
|
||||
}.exceptionWithParameter<Throwable> { ex, para ->
|
||||
//setLoading(false);
|
||||
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(); });
|
||||
loadNext();
|
||||
};
|
||||
|
||||
_textLoadMore.setOnClickListener {
|
||||
if (!_limitToastShown) {
|
||||
return@setOnClickListener;
|
||||
}
|
||||
|
||||
_textLoadMore.visibility = View.GONE;
|
||||
_limitToastShown = false;
|
||||
_counter = 0;
|
||||
load();
|
||||
};
|
||||
|
||||
_textLoadMore.visibility = View.GONE;
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
@@ -124,6 +142,8 @@ class ImportSubscriptionsFragment : MainFragment() {
|
||||
}
|
||||
|
||||
fun onShown(parameter: Any ?, isBack: Boolean) {
|
||||
_counter = 0;
|
||||
_limitToastShown = false;
|
||||
updateSelected();
|
||||
|
||||
val itemsRemoved = _items.size;
|
||||
@@ -142,14 +162,14 @@ class ImportSubscriptionsFragment : MainFragment() {
|
||||
|
||||
val tb = _fragment.topBar as ImportTopBarFragment?;
|
||||
tb?.let {
|
||||
it.title = "Import Subscriptions";
|
||||
it.title = context.getString(R.string.import_subscriptions);
|
||||
it.onImport.subscribe(this) {
|
||||
val subscriptionsToImport = _items.filter { i -> i.selected }.toList();
|
||||
for (subscriptionToImport in subscriptionsToImport) {
|
||||
StateSubscriptions.instance.addSubscription(subscriptionToImport.channel);
|
||||
}
|
||||
|
||||
UIDialogs.toast("${subscriptionsToImport.size} subscriptions imported.");
|
||||
UIDialogs.toast("${subscriptionsToImport.size} " + context.getString(R.string.subscriptions_imported));
|
||||
_fragment.closeSegment();
|
||||
};
|
||||
}
|
||||
@@ -157,6 +177,16 @@ class ImportSubscriptionsFragment : MainFragment() {
|
||||
|
||||
private fun load() {
|
||||
setLoading(true);
|
||||
if (_counter >= MAXIMUM_BATCH_SIZE) {
|
||||
if (!_limitToastShown) {
|
||||
_limitToastShown = true;
|
||||
_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);
|
||||
return;
|
||||
}
|
||||
_taskLoadChannel.run(_links[_currentLoadIndex]);
|
||||
}
|
||||
|
||||
@@ -173,7 +203,7 @@ class ImportSubscriptionsFragment : MainFragment() {
|
||||
val itemsSelected = _items.count { i -> i.selected };
|
||||
if (itemsSelected > 0) {
|
||||
_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);
|
||||
} else {
|
||||
_textSelectDeselectAll.text = context.getString(R.string.select_all);
|
||||
@@ -196,6 +226,7 @@ class ImportSubscriptionsFragment : MainFragment() {
|
||||
|
||||
companion object {
|
||||
val TAG = "ImportSubscriptionsFragment";
|
||||
private const val MAXIMUM_BATCH_SIZE = 100;
|
||||
fun newInstance() = ImportSubscriptionsFragment().apply {}
|
||||
}
|
||||
}
|
||||
+10
-10
@@ -80,8 +80,8 @@ class PlaylistFragment : MainFragment() {
|
||||
constructor(fragment: PlaylistFragment, inflater: LayoutInflater) : super(inflater) {
|
||||
_fragment = fragment;
|
||||
|
||||
val nameInput = SlideUpMenuTextInput(context, "Name");
|
||||
val editPlaylistOverlay = SlideUpMenuOverlay(context, overlayContainer, "Edit playlist", "Ok", false, nameInput);
|
||||
val nameInput = SlideUpMenuTextInput(context, context.getString(R.string.name));
|
||||
val editPlaylistOverlay = SlideUpMenuOverlay(context, overlayContainer, context.getString(R.string.edit_playlist), context.getString(R.string.ok), false, nameInput);
|
||||
|
||||
_buttonDownload.visibility = View.VISIBLE;
|
||||
editPlaylistOverlay.onOK.subscribe {
|
||||
@@ -113,14 +113,14 @@ class PlaylistFragment : MainFragment() {
|
||||
val playlist = _playlist ?: return@setOnShare;
|
||||
val reconstruction = StatePlaylists.instance.playlistStore.getReconstructionString(playlist);
|
||||
|
||||
UISlideOverlays.showOverlay(overlayContainer, "Playlist [${playlist.name}]", null, {},
|
||||
SlideUpMenuItem(context, R.drawable.ic_list, "Share as Text", "Share as a list of video urls", 1, {
|
||||
UISlideOverlays.showOverlay(overlayContainer, context.getString(R.string.playlist) + " [${playlist.name}]", null, {},
|
||||
SlideUpMenuItem(context, R.drawable.ic_list, context.getString(R.string.share_as_text), context.getString(R.string.share_as_a_list_of_video_urls), 1, {
|
||||
_fragment.startActivity(ShareCompat.IntentBuilder(context)
|
||||
.setType("text/plain")
|
||||
.setText(reconstruction)
|
||||
.intent);
|
||||
}),
|
||||
SlideUpMenuItem(context, R.drawable.ic_move_up, "Share as Import", "Share as a import file for Grayjay", 2, {
|
||||
SlideUpMenuItem(context, R.drawable.ic_move_up, context.getString(R.string.share_as_import), context.getString(R.string.share_as_a_import_file_for_grayjay), 2, {
|
||||
val shareUri = StatePlaylists.instance.createPlaylistShareJsonUri(context, playlist);
|
||||
_fragment.startActivity(ShareCompat.IntentBuilder(context)
|
||||
.setType("application/json")
|
||||
@@ -146,7 +146,7 @@ class PlaylistFragment : MainFragment() {
|
||||
.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load playlist.", it);
|
||||
val c = context ?: return@exception;
|
||||
UIDialogs.showGeneralRetryErrorDialog(c, "Failed to load playlist", it, ::fetchPlaylist);
|
||||
UIDialogs.showGeneralRetryErrorDialog(c, context.getString(R.string.failed_to_load_playlist), it, ::fetchPlaylist);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ class PlaylistFragment : MainFragment() {
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(arrayListOf(Pair(R.drawable.ic_copy) {
|
||||
val remotePlaylist = _remotePlaylist;
|
||||
if (remotePlaylist == null) {
|
||||
UIDialogs.toast("Please wait for playlist to finish loading");
|
||||
UIDialogs.toast(context.getString(R.string.please_wait_for_playlist_to_finish_loading));
|
||||
return@Pair;
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ class PlaylistFragment : MainFragment() {
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
setLoading(false);
|
||||
UIDialogs.toast("Playlist copied as local playlist");
|
||||
UIDialogs.toast(context.getString(R.string.playlist_copied_as_local_playlist));
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -284,7 +284,7 @@ class PlaylistFragment : MainFragment() {
|
||||
_buttonDownload.setImageResource(R.drawable.ic_loader_animated);
|
||||
_buttonDownload.drawable.assume<Animatable, Unit> { it.start() };
|
||||
_buttonDownload.setOnClickListener {
|
||||
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete the downloaded videos?", {
|
||||
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
|
||||
StateDownloads.instance.deleteCachedPlaylist(playlist.id);
|
||||
});
|
||||
}
|
||||
@@ -292,7 +292,7 @@ class PlaylistFragment : MainFragment() {
|
||||
else if(isDownloaded) {
|
||||
_buttonDownload.setImageResource(R.drawable.ic_download_off);
|
||||
_buttonDownload.setOnClickListener {
|
||||
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete the downloaded videos?", {
|
||||
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), {
|
||||
StateDownloads.instance.deleteCachedPlaylist(playlist.id);
|
||||
});
|
||||
}
|
||||
|
||||
+7
-9
@@ -73,16 +73,14 @@ class PlaylistSearchResultsFragment : MainFragment() {
|
||||
|
||||
fun onShown(parameter: Any?, isBack: Boolean) {
|
||||
if(parameter is String) {
|
||||
if(!isBack) {
|
||||
setQuery(parameter);
|
||||
setQuery(parameter);
|
||||
|
||||
fragment.topBar?.apply {
|
||||
if (this is SearchTopBarFragment) {
|
||||
setText(parameter);
|
||||
onSearch.subscribe(this) {
|
||||
setQuery(it);
|
||||
};
|
||||
}
|
||||
fragment.topBar?.apply {
|
||||
if (this is SearchTopBarFragment) {
|
||||
setText(parameter);
|
||||
onSearch.subscribe(this) {
|
||||
setQuery(it);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -92,8 +92,8 @@ class PlaylistsFragment : MainFragment() {
|
||||
recyclerPlaylists.adapter = _adapterPlaylist;
|
||||
recyclerPlaylists.layoutManager = LinearLayoutManager(context);
|
||||
|
||||
val nameInput = SlideUpMenuTextInput(context, "Name");
|
||||
val addPlaylistOverlay = SlideUpMenuOverlay(context, findViewById<FrameLayout>(R.id.overlay_create_playlist), "Create new playlist", "Ok", false, nameInput);
|
||||
val nameInput = SlideUpMenuTextInput(context, context.getString(R.string.name));
|
||||
val addPlaylistOverlay = SlideUpMenuOverlay(context, findViewById<FrameLayout>(R.id.overlay_create_playlist), context.getString(R.string.create_new_playlist), context.getString(R.string.ok), false, nameInput);
|
||||
|
||||
_adapterPlaylist.onClick.subscribe { p -> _fragment.navigate<PlaylistFragment>(p); };
|
||||
_adapterPlaylist.onPlay.subscribe { p ->
|
||||
@@ -130,7 +130,7 @@ class PlaylistsFragment : MainFragment() {
|
||||
_appBar = findViewById(R.id.app_bar);
|
||||
_layoutWatchlist = findViewById(R.id.layout_watchlist);
|
||||
|
||||
findViewById<TextView>(R.id.text_view_all).setOnClickListener { _fragment.navigate<WatchLaterFragment>("Watch Later"); };
|
||||
findViewById<TextView>(R.id.text_view_all).setOnClickListener { _fragment.navigate<WatchLaterFragment>(context.getString(R.string.watch_later)); };
|
||||
StatePlaylists.instance.onWatchLaterChanged.subscribe(this) {
|
||||
updateWatchLater();
|
||||
};
|
||||
|
||||
+21
-13
@@ -20,6 +20,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||
@@ -154,13 +155,13 @@ class PostDetailFragment : MainFragment {
|
||||
{
|
||||
val result = StatePlatform.instance.getContentDetails(it).await();
|
||||
if(result !is IPlatformPostDetails)
|
||||
throw IllegalStateException("Expected media content, found ${result.contentType}");
|
||||
throw IllegalStateException(context.getString(R.string.expected_media_content_found) + " ${result.contentType}");
|
||||
return@TaskHandler result;
|
||||
})
|
||||
.success { setPostDetails(it) }
|
||||
.exception<Throwable> {
|
||||
Logger.w(ChannelFragment.TAG, "Failed to load post.", it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load post", it, ::fetchPost);
|
||||
Logger.w(ChannelFragment.TAG, context.getString(R.string.failed_to_load_post), it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost);
|
||||
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
|
||||
|
||||
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>(StateApp.instance.scopeGetter, { PolycentricCache.instance.getProfileAsync(it) })
|
||||
@@ -210,6 +211,11 @@ class PostDetailFragment : MainFragment {
|
||||
|
||||
_repliesOverlay = findViewById(R.id.replies_overlay);
|
||||
|
||||
_buttonSubscribe.onSubscribed.subscribe {
|
||||
//TODO: add overlay to layout
|
||||
//UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
|
||||
};
|
||||
|
||||
val layoutTop: LinearLayout = findViewById(R.id.layout_top);
|
||||
root.removeView(layoutTop);
|
||||
_commentsList.setPrependedView(layoutTop);
|
||||
@@ -222,7 +228,7 @@ class PostDetailFragment : MainFragment {
|
||||
val replyCount = c.replyCount ?: 0;
|
||||
var metadata = "";
|
||||
if (replyCount > 0) {
|
||||
metadata += "$replyCount replies";
|
||||
metadata += "$replyCount " + context.getString(R.string.replies);
|
||||
}
|
||||
|
||||
if (c is PolycentricPlatformComment) {
|
||||
@@ -346,24 +352,26 @@ class PostDetailFragment : MainFragment {
|
||||
|
||||
_rating.visibility = VISIBLE;
|
||||
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
|
||||
_rating.onLikeDislikeUpdated.subscribe(this) { processHandle, newHasLiked, newHasDisliked ->
|
||||
if (newHasLiked) {
|
||||
processHandle.opinion(ref, Opinion.like);
|
||||
} else if (newHasDisliked) {
|
||||
processHandle.opinion(ref, Opinion.dislike);
|
||||
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
|
||||
if (args.hasLiked) {
|
||||
args.processHandle.opinion(ref, Opinion.like);
|
||||
} else if (args.hasDisliked) {
|
||||
args.processHandle.opinion(ref, Opinion.dislike);
|
||||
} else {
|
||||
processHandle.opinion(ref, Opinion.neutral);
|
||||
args.processHandle.opinion(ref, Opinion.neutral);
|
||||
}
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
processHandle.fullyBackfillServers();
|
||||
Logger.i(TAG, "Started backfill");
|
||||
args.processHandle.fullyBackfillServers();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers", e)
|
||||
}
|
||||
}
|
||||
|
||||
StatePolycentric.instance.updateLikeMap(ref, newHasLiked, newHasDisliked)
|
||||
StatePolycentric.instance.updateLikeMap(ref, args.hasLiked, args.hasDisliked)
|
||||
};
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@@ -601,7 +609,7 @@ class PostDetailFragment : MainFragment {
|
||||
val subscribers = value?.author?.subscribers;
|
||||
if(subscribers != null && subscribers > 0) {
|
||||
_channelMeta.visibility = View.VISIBLE;
|
||||
_channelMeta.text = value.author.subscribers!!.toHumanNumber() + " subscribers";
|
||||
_channelMeta.text = if((value.author.subscribers ?: 0) > 0) value.author.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers) else "";
|
||||
} else {
|
||||
_channelMeta.visibility = View.GONE;
|
||||
_channelMeta.text = "";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user