mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 13:02:39 +02:00
Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+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
|
||||
|
||||
|
||||
+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", {});
|
||||
|
||||
@@ -681,6 +681,9 @@
|
||||
});
|
||||
}, 1000);
|
||||
},
|
||||
captchaTestPlugin() {
|
||||
captchaLoginTestPlugin();
|
||||
},
|
||||
logoutTestPlugin() {
|
||||
pluginLogoutTestPlugin();
|
||||
},
|
||||
@@ -838,6 +841,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)
|
||||
|
||||
@@ -31,6 +31,12 @@ let Type = {
|
||||
RAW: 0,
|
||||
HTML: 1,
|
||||
MARKUP: 2
|
||||
},
|
||||
Chapter: {
|
||||
NORMAL: 0,
|
||||
|
||||
SKIPPABLE: 5,
|
||||
SKIP: 6
|
||||
}
|
||||
};
|
||||
|
||||
@@ -72,6 +78,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 +159,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.webkit.CookieManager
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.activities.*
|
||||
@@ -21,6 +20,7 @@ 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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -28,6 +28,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);
|
||||
@@ -43,9 +44,10 @@ class Settings : FragmentedStorageFileJson() {
|
||||
val onTabsChanged = Event0();
|
||||
|
||||
@FormField(
|
||||
"Manage Polycentric identity", FieldForm.BUTTON,
|
||||
"Manage your Polycentric identity", -2
|
||||
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) {
|
||||
@@ -57,9 +59,38 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
|
||||
@FormField(
|
||||
"Submit feedback", FieldForm.BUTTON,
|
||||
"Give feedback on the application", -1
|
||||
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(
|
||||
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);
|
||||
@@ -73,12 +104,13 @@ class Settings : FragmentedStorageFileJson() {
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
@FormField(
|
||||
"Manage Tabs", FieldForm.BUTTON,
|
||||
"Change tabs visible on the home screen", -1
|
||||
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 {
|
||||
@@ -89,11 +121,39 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField("Home", "group", "Configure how your Home tab works and feels", 1)
|
||||
|
||||
|
||||
@FormField(R.string.home, "group", R.string.configure_how_your_home_tab_works_and_feels, 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;
|
||||
|
||||
@@ -103,21 +163,28 @@ 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("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)
|
||||
@@ -127,11 +194,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;
|
||||
|
||||
@@ -142,11 +209,17 @@ class Settings : FragmentedStorageFileJson() {
|
||||
return FeedStyle.THUMBNAIL;
|
||||
}
|
||||
|
||||
@FormField("Fetch on app boot", FieldForm.TOGGLE, "Shortly after opening the app, start fetching subscriptions.", 6)
|
||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
|
||||
var previewFeedItems: Boolean = true;
|
||||
|
||||
@FormField(R.string.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("Background Update", FieldForm.DROPDOWN, "Experimental background update for subscriptions cache (requires restart)", 7)
|
||||
@FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 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;
|
||||
|
||||
@@ -162,26 +235,36 @@ class Settings : FragmentedStorageFileJson() {
|
||||
};
|
||||
|
||||
|
||||
@FormField("Subscription Concurrency", FieldForm.DROPDOWN, "Specify how many threads are used to fetch channels (requires restart)", 8)
|
||||
@FormField(R.string.subscription_concurrency, FieldForm.DROPDOWN, R.string.specify_how_many_threads_are_used_to_fetch_channels, 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("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) {
|
||||
@@ -197,29 +280,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, "This prevents the device from rotating within the given amount of degrees.", 5)
|
||||
@FormField(R.string.auto_rotate_dead_zone, FieldForm.DROPDOWN, R.string.this_prevents_the_device_from_rotating_within_the_given_amount_of_degrees, 5)
|
||||
@DropdownFieldOptionsId(R.array.auto_rotate_dead_zone)
|
||||
var autoRotateDeadZone: Int = 0;
|
||||
|
||||
@@ -227,21 +310,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;
|
||||
@@ -249,14 +328,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;
|
||||
|
||||
@@ -269,21 +356,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 {
|
||||
@@ -291,20 +378,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;
|
||||
|
||||
@@ -326,24 +413,24 @@ 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
|
||||
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) {
|
||||
@@ -355,40 +442,40 @@ 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
|
||||
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
|
||||
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
|
||||
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) {
|
||||
@@ -397,7 +484,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) {
|
||||
@@ -412,7 +499,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 {
|
||||
@@ -424,13 +511,13 @@ 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);
|
||||
@@ -439,19 +526,19 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
|
||||
|
||||
@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;
|
||||
|
||||
@@ -469,8 +556,8 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
|
||||
@FormField(
|
||||
"Manual check", FieldForm.BUTTON,
|
||||
"Manually check for updates", 3
|
||||
R.string.manual_check, FieldForm.BUTTON,
|
||||
R.string.manually_check_for_updates, 3
|
||||
)
|
||||
fun manualCheck() {
|
||||
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||
@@ -482,19 +569,20 @@ 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
|
||||
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;
|
||||
@@ -511,8 +599,8 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
|
||||
@FormField(
|
||||
"Remove Cached Version", FieldForm.BUTTON,
|
||||
"Remove the last downloaded version", 5
|
||||
R.string.remove_cached_version, FieldForm.BUTTON,
|
||||
R.string.remove_the_last_downloaded_version, 5
|
||||
)
|
||||
fun removeCachedVersion() {
|
||||
StateApp.withContext {
|
||||
@@ -529,7 +617,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
@FormField("Backup", FieldForm.GROUP, "", 13)
|
||||
@FormField(R.string.backup, FieldForm.GROUP, -1, 13)
|
||||
var backup = Backup();
|
||||
@Serializable
|
||||
class Backup {
|
||||
@@ -539,58 +627,75 @@ 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.info, FieldForm.GROUP, -1, 15)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -601,6 +706,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,23 @@ 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))
|
||||
+ 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 +411,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 +425,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 +438,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 +457,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 +477,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 +490,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 +498,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);
|
||||
|
||||
@@ -96,8 +96,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 +129,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 +152,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 +175,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 +184,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)
|
||||
)
|
||||
|
||||
|
||||
@@ -16,13 +16,14 @@ 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, "Failed to scan QR code")
|
||||
UIDialogs.toast(this, getString(R.string.failed_to_scan_qr_code))
|
||||
return@let
|
||||
}
|
||||
|
||||
@@ -31,7 +32,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
||||
} else if (content.startsWith("grayjay://plugin/")) {
|
||||
content.substring("grayjay://plugin/".length)
|
||||
} else {
|
||||
UIDialogs.toast(this, "Not a plugin URL")
|
||||
UIDialogs.toast(this, getString(R.string.not_a_plugin_url))
|
||||
return@let;
|
||||
}
|
||||
|
||||
@@ -51,6 +52,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();
|
||||
@@ -59,7 +61,7 @@ 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)
|
||||
@@ -69,12 +71,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
_buttonURL.onClick.subscribe {
|
||||
UIDialogs.toast(this, "Not implemented yet..");
|
||||
UIDialogs.toast(this, getString(R.string.not_implemented_yet));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class QRCaptureActivity: CaptureActivity() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,8 @@ 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" +
|
||||
"Device information (brand= ${Build.BRAND}, manufacturer = ${Build.MANUFACTURER}, device = ${Build.DEVICE}, version-sdk = ${Build.VERSION.SDK_INT}, version-os = ${Build.VERSION.BASE_OS})\n\n" +
|
||||
@@ -79,13 +79,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;
|
||||
}
|
||||
|
||||
@@ -101,14 +101,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,10 +119,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
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ 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.*
|
||||
@@ -23,6 +25,8 @@ 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 onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -30,6 +34,13 @@ class LoginActivity : AppCompatActivity() {
|
||||
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);
|
||||
@@ -60,6 +71,8 @@ class LoginActivity : AppCompatActivity() {
|
||||
};
|
||||
var isFirstLoad = true;
|
||||
webViewClient.onPageLoaded.subscribe { view, url ->
|
||||
_textUrl.setText(url ?: "");
|
||||
|
||||
if(!isFirstLoad)
|
||||
return@subscribe;
|
||||
isFirstLoad = false;
|
||||
|
||||
@@ -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);
|
||||
@@ -459,6 +465,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 +488,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 +503,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 +527,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 +537,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 +547,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 +556,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 +597,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 +617,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 +627,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 +645,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 +684,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 +779,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 +966,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,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 +63,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);
|
||||
};
|
||||
}
|
||||
|
||||
+5
-3
@@ -54,7 +54,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 +68,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) {
|
||||
|
||||
@@ -47,7 +47,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));
|
||||
|
||||
+5
-9
@@ -59,7 +59,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
_buttonScanProfile.setOnClickListener {
|
||||
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)
|
||||
@@ -70,7 +70,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
|
||||
_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;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -101,7 +101,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;
|
||||
}
|
||||
|
||||
@@ -124,15 +124,11 @@ 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}'");
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PolycentricImportProfileActivity";
|
||||
}
|
||||
|
||||
class QRCaptureActivity: CaptureActivity() {
|
||||
|
||||
}
|
||||
}
|
||||
+14
-10
@@ -16,6 +16,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 +29,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
|
||||
@@ -72,7 +74,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 +103,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 +124,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 +145,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 +188,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 +223,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 +239,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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -70,7 +81,7 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
SettingsDev.instance.developerMode = true;
|
||||
SettingsDev.instance.save();
|
||||
updateDevMode();
|
||||
UIDialogs.toast(this, "You are now in developer mode");
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -41,10 +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);
|
||||
@@ -142,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);
|
||||
}
|
||||
}
|
||||
@@ -75,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()));
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ import java.util.stream.IntStream
|
||||
*/
|
||||
class MultiChronoContentPager : MultiPager<IPlatformContent> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
+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,21 @@ 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.getNowDiffSeconds
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
@@ -27,10 +35,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 +91,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 +108,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,44 @@ 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 getChannelCachePager(channelUrl: String): PlatformContentPager {
|
||||
val validID = channelUrl.toSafeFileName();
|
||||
@@ -38,7 +63,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 +73,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 +83,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 +98,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 +120,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 +138,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 +158,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 +178,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;
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -90,7 +91,9 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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,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 = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} subscribers" else "";
|
||||
val metadata = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} " + (context?.getString(R.string.subscribers)?.lowercase() ?: "") else "";
|
||||
_textMetadata?.text = metadata;
|
||||
_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
|
||||
|
||||
|
||||
+15
-11
@@ -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();
|
||||
}
|
||||
@@ -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 = if(channel.subscribers > 0) "${channel.subscribers.toHumanNumber()} subscribers" else "";
|
||||
|
||||
_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}")
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+11
-9
@@ -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() {
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
+13
-5
@@ -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) {
|
||||
@@ -357,7 +363,9 @@ class PostDetailFragment : MainFragment {
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
args.processHandle.fullyBackfillServers();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers", e)
|
||||
}
|
||||
@@ -601,7 +609,7 @@ class PostDetailFragment : MainFragment {
|
||||
val subscribers = value?.author?.subscribers;
|
||||
if(subscribers != null && subscribers > 0) {
|
||||
_channelMeta.visibility = View.VISIBLE;
|
||||
_channelMeta.text = if((value.author?.subscribers ?: 0) > 0) value.author.subscribers!!.toHumanNumber() + " subscribers" else "";
|
||||
_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 = "";
|
||||
|
||||
+70
-26
@@ -63,6 +63,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
|
||||
private val _sourceHeader: SourceHeaderView;
|
||||
private val _sourceButtons: LinearLayout;
|
||||
private val _sourceAdvancedButtons: LinearLayout;
|
||||
private val _layoutLoader: FrameLayout;
|
||||
private val _imageSpinner: ImageView;
|
||||
|
||||
@@ -82,6 +83,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
this.fragment = fragment;
|
||||
_sourceHeader = findViewById(R.id.source_header);
|
||||
_sourceButtons = findViewById(R.id.source_buttons);
|
||||
_sourceAdvancedButtons = findViewById(R.id.advanced_source_buttons);
|
||||
_settingsAppForm = findViewById(R.id.source_app_setings);
|
||||
_settingsForm = findViewById(R.id.source_settings);
|
||||
_layoutLoader = findViewById(R.id.layout_loader);
|
||||
@@ -107,7 +109,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
StatePlugins.instance.setPluginSettings(id, _settings!!);
|
||||
reloadSource(id);
|
||||
|
||||
UIDialogs.toast("Plugin settings saved", false);
|
||||
UIDialogs.toast(context.getString(R.string.plugin_settings_saved), false);
|
||||
}
|
||||
if(_settingsAppChanged) {
|
||||
_settingsAppForm.setObjectValues();
|
||||
@@ -144,8 +146,8 @@ class SourceDetailFragment : MainFragment() {
|
||||
try {
|
||||
_settings = settingValues;
|
||||
_settingsForm.fromPluginSettings(
|
||||
settings, settingValues, "Plugin settings",
|
||||
"These settings are defined by the plugin"
|
||||
settings, settingValues, context.getString(R.string.plugin_settings),
|
||||
context.getString(R.string.these_settings_are_defined_by_the_plugin)
|
||||
);
|
||||
_settingsForm.onChanged.clear();
|
||||
_settingsForm.onChanged.subscribe { field, value ->
|
||||
@@ -158,7 +160,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to load source", ex);
|
||||
UIDialogs.toast("Failed to loast source");
|
||||
UIDialogs.toast(context.getString(R.string.failed_to_load_source));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,8 +206,8 @@ class SourceDetailFragment : MainFragment() {
|
||||
val isEnabled = StatePlatform.instance.isClientEnabled(source);
|
||||
|
||||
groups.add(
|
||||
BigButtonGroup(c, "Update",
|
||||
BigButton(c, "Check for updates", "Checks for new versions of the source", R.drawable.ic_update) {
|
||||
BigButtonGroup(c, context.getString(R.string.update),
|
||||
BigButton(c, context.getString(R.string.check_for_updates), context.getString(R.string.checks_for_new_versions_of_the_source), R.drawable.ic_update) {
|
||||
checkForUpdatesSource();
|
||||
}
|
||||
)
|
||||
@@ -213,9 +215,16 @@ class SourceDetailFragment : MainFragment() {
|
||||
|
||||
if (source.isLoggedIn) {
|
||||
groups.add(
|
||||
BigButtonGroup(c, "Authentication",
|
||||
BigButton(c, "Logout", "Sign out of the platform", R.drawable.ic_logout) {
|
||||
BigButtonGroup(c, context.getString(R.string.authentication),
|
||||
BigButton(c, context.getString(R.string.logout), context.getString(R.string.sign_out_of_the_platform), R.drawable.ic_logout) {
|
||||
logoutSource();
|
||||
},
|
||||
BigButton(c, "Logout without Clear", "Logout but keep the browser cookies.\nThis allows for quick re-logging.", R.drawable.ic_logout) {
|
||||
logoutSource(false);
|
||||
}.apply {
|
||||
this.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
|
||||
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -223,7 +232,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
val migrationButtons = mutableListOf<BigButton>();
|
||||
if (isEnabled && source.capabilities.hasGetUserSubscriptions) {
|
||||
migrationButtons.add(
|
||||
BigButton(c, "Import Subscriptions", "Import your subscriptions from this source", R.drawable.ic_subscriptions) {
|
||||
BigButton(c, context.getString(R.string.import_subscriptions), context.getString(R.string.import_your_subscriptions_from_this_source), R.drawable.ic_subscriptions) {
|
||||
Logger.i(TAG, "Import subscriptions clicked.");
|
||||
importSubscriptionsSource();
|
||||
}
|
||||
@@ -231,7 +240,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
}
|
||||
|
||||
if (isEnabled && source.capabilities.hasGetUserPlaylists && source.capabilities.hasGetPlaylist) {
|
||||
val bigButton = BigButton(c, "Import Playlists", "Import your playlists from this source", R.drawable.ic_playlist) {
|
||||
val bigButton = BigButton(c, context.getString(R.string.import_playlists), context.getString(R.string.import_your_playlists_from_this_source), R.drawable.ic_playlist) {
|
||||
Logger.i(TAG, "Import playlists clicked.");
|
||||
importPlaylistsSource();
|
||||
};
|
||||
@@ -244,13 +253,13 @@ class SourceDetailFragment : MainFragment() {
|
||||
}
|
||||
|
||||
if (migrationButtons.size > 0) {
|
||||
groups.add(BigButtonGroup(c, "Migration", *migrationButtons.toTypedArray()));
|
||||
groups.add(BigButtonGroup(c, context.getString(R.string.migration), *migrationButtons.toTypedArray()));
|
||||
}
|
||||
} else {
|
||||
if(config.authentication != null) {
|
||||
groups.add(
|
||||
BigButtonGroup(c, "Authentication",
|
||||
BigButton(c, "Login", "Sign into the platform of this source", R.drawable.ic_login) {
|
||||
BigButtonGroup(c, context.getString(R.string.authentication),
|
||||
BigButton(c, context.getString(R.string.login), context.getString(R.string.sign_into_the_platform_of_this_source), R.drawable.ic_login) {
|
||||
loginSource();
|
||||
}
|
||||
)
|
||||
@@ -258,17 +267,52 @@ class SourceDetailFragment : MainFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
val clientIfExists = StatePlugins.instance.getPlugin(config.id);
|
||||
groups.add(
|
||||
BigButtonGroup(c, "Management",
|
||||
BigButton(c, "Uninstall", "Removes the plugin from the app", R.drawable.ic_block) {
|
||||
BigButtonGroup(c, context.getString(R.string.management),
|
||||
BigButton(c, context.getString(R.string.uninstall), context.getString(R.string.removes_the_plugin_from_the_app), R.drawable.ic_block) {
|
||||
uninstallSource();
|
||||
}.withBackground(R.drawable.background_big_button_red)
|
||||
}.withBackground(R.drawable.background_big_button_red).apply {
|
||||
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
||||
};
|
||||
},
|
||||
if(clientIfExists?.captchaEncrypted != null)
|
||||
BigButton(c, context.getString(R.string.delete_captcha), context.getString(R.string.deletes_stored_captcha_answer_for_this_plugin), R.drawable.ic_block) {
|
||||
clientIfExists.updateCaptcha(null);
|
||||
}.apply {
|
||||
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
||||
};
|
||||
}.withBackground(R.drawable.background_big_button_red)
|
||||
else null
|
||||
)
|
||||
)
|
||||
|
||||
for (group in groups) {
|
||||
_sourceButtons.addView(group);
|
||||
}
|
||||
|
||||
val isEmbedded = StatePlugins.instance.getEmbeddedSources(context).any { it.key == config.id };
|
||||
val advancedButtons = BigButtonGroup(c, "Advanced",
|
||||
BigButton(c, "Edit Code", "Modify the source of this plugin", R.drawable.ic_code) {
|
||||
|
||||
}.apply {
|
||||
this.alpha = 0.5f;
|
||||
},
|
||||
if(isEmbedded) BigButton(c, "Reinstall", "Modify the source of this plugin", R.drawable.ic_refresh) {
|
||||
StatePlugins.instance.updateEmbeddedPlugins(context, listOf(config.id), true);
|
||||
reloadSource(config.id);
|
||||
UIDialogs.toast(context, "Embedded plugin reinstalled, may require refresh");
|
||||
}.apply {
|
||||
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
||||
};
|
||||
} else null
|
||||
)
|
||||
|
||||
_sourceAdvancedButtons.removeAllViews();
|
||||
_sourceAdvancedButtons.addView(advancedButtons);
|
||||
}
|
||||
|
||||
|
||||
@@ -284,7 +328,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
reloadSource(config.id);
|
||||
};
|
||||
}
|
||||
private fun logoutSource() {
|
||||
private fun logoutSource(clear: Boolean = true) {
|
||||
val config = _config ?: return;
|
||||
|
||||
StatePlugins.instance.setPluginAuth(config.id, null);
|
||||
@@ -292,7 +336,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
|
||||
|
||||
//TODO: Maybe add a dialog option..
|
||||
if(Settings.instance.plugins.clearCookiesOnLogout) {
|
||||
if(Settings.instance.plugins.clearCookiesOnLogout && clear) {
|
||||
val cookieManager: CookieManager = CookieManager.getInstance();
|
||||
cookieManager.removeAllCookies(null);
|
||||
}
|
||||
@@ -315,7 +359,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
withContext(Dispatchers.Main) {
|
||||
context?.let { UIDialogs.showGeneralErrorDialog(it, "Failed to retrieve playlists.", e) }
|
||||
context?.let { UIDialogs.showGeneralErrorDialog(it, it.getString(R.string.failed_to_retrieve_playlists), e) }
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -340,14 +384,14 @@ class SourceDetailFragment : MainFragment() {
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val subscriptions = source.getUserSubscriptions().distinct();
|
||||
Logger.i(TAG, "${subscriptions.size} user subscriptions retrieved.");
|
||||
Logger.i(TAG, context.getString(R.string.subscriptioncount_user_subscriptions_retrieved).replace("{subscriptionCount}", subscriptions.size.toString()));
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
fragment.navigate<ImportSubscriptionsFragment>(subscriptions);
|
||||
}
|
||||
} catch(e: Throwable) {
|
||||
withContext(Dispatchers.Main) {
|
||||
context?.let { UIDialogs.showGeneralErrorDialog(it, "Failed to retrieve subscriptions.", e) }
|
||||
context?.let { UIDialogs.showGeneralErrorDialog(it, context.getString(R.string.failed_to_retrieve_subscriptions), e) }
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -361,7 +405,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
val config = _config ?: return;
|
||||
val source = StatePlatform.instance.getClient(config.id);
|
||||
|
||||
UIDialogs.showConfirmationDialog(context, "Are you sure you want to uninstall ${source.name}", {
|
||||
UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_uninstall) + " ${source.name}", {
|
||||
StatePlugins.instance.deletePlugin(source.id);
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
@@ -372,7 +416,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(context, "Uninstalled ${source.name}");
|
||||
UIDialogs.toast(context, context.getString(R.string.uninstalled) + " ${source.name}");
|
||||
fragment.closeSegment();
|
||||
}
|
||||
}
|
||||
@@ -391,7 +435,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
|
||||
if (!response.isOk || response.body == null) {
|
||||
Logger.w(TAG, "Failed to check for updates (sourceUrl=${sourceUrl}, response.isOk=${response.isOk}, response.body=${response.body}).");
|
||||
withContext(Dispatchers.Main) { UIDialogs.toast("Failed to check for updates"); };
|
||||
withContext(Dispatchers.Main) { UIDialogs.toast(context.getString(R.string.failed_to_check_for_updates)); };
|
||||
return@launch;
|
||||
}
|
||||
|
||||
@@ -401,7 +445,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
val config = SourcePluginConfig.fromJson(configJson);
|
||||
if (config.version <= c.version) {
|
||||
Logger.i(TAG, "Plugin is up to date.");
|
||||
withContext(Dispatchers.Main) { UIDialogs.toast("Plugin is fully up to date"); };
|
||||
withContext(Dispatchers.Main) { UIDialogs.toast(context.getString(R.string.plugin_is_fully_up_to_date)); };
|
||||
return@launch;
|
||||
}
|
||||
|
||||
@@ -416,7 +460,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
Logger.i(TAG, "Started add source activity.");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to check for updates.", e);
|
||||
withContext(Dispatchers.Main) { UIDialogs.toast("Failed to check for updates"); };
|
||||
withContext(Dispatchers.Main) { UIDialogs.toast(context.getString(R.string.failed_to_check_for_updates)); };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+56
-22
@@ -13,6 +13,7 @@ import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.cache.ChannelContentCache
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
@@ -80,12 +81,13 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
}
|
||||
|
||||
fun setPreviewsEnabled(previewsEnabled: Boolean) {
|
||||
_view?.setPreviewsEnabled(previewsEnabled);
|
||||
_view?.setPreviewsEnabled(previewsEnabled && Settings.instance.subscriptions.previewFeedItems);
|
||||
}
|
||||
|
||||
@SuppressLint("ViewConstructor")
|
||||
class SubscriptionsFeedView : ContentFeedView<SubscriptionsFeedFragment> {
|
||||
constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
||||
Logger.i(TAG, "SubscriptionsFeedFragment constructor()");
|
||||
StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.subscribe(this) { progress, total ->
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
@@ -106,16 +108,23 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
};
|
||||
|
||||
initializeToolbarContent();
|
||||
|
||||
setPreviewsEnabled(Settings.instance.subscriptions.previewFeedItems);
|
||||
}
|
||||
|
||||
fun onShown() {
|
||||
Logger.i(TAG, "SubscriptionsFeedFragment onShown()");
|
||||
val currentProgress = StateSubscriptions.instance.getGlobalSubscriptionProgress();
|
||||
setProgress(currentProgress.first, currentProgress.second);
|
||||
|
||||
if(recyclerData.loadedFeedStyle != feedStyle ||
|
||||
recyclerData.lastLoad.getNowDiffSeconds() > 60 ) {
|
||||
recyclerData.lastLoad = OffsetDateTime.now();
|
||||
loadResults();
|
||||
|
||||
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5 && Settings.instance.subscriptions.fetchOnTabOpen)
|
||||
loadResults(false);
|
||||
else if(recyclerData.results.size == 0)
|
||||
loadCache();
|
||||
}
|
||||
|
||||
val announcementsView = _announcementsView;
|
||||
@@ -169,12 +178,13 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
private val _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({StateApp.instance.scope}, { withRefresh ->
|
||||
if(!_bypassRateLimit) {
|
||||
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
|
||||
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.config.subscriptionRateLimit}" }.joinToString("\n");
|
||||
val rateLimitPlugins = subRequestCounts.filter { clientCount -> clientCount.key.config.subscriptionRateLimit?.let { rateLimit -> clientCount.value > rateLimit } == true }
|
||||
Logger.w(TAG, "Refreshing subscriptions with requests:\n" + reqCountStr);
|
||||
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n");
|
||||
val rateLimitPlugins = subRequestCounts.filter { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true }
|
||||
Logger.w(TAG, "Trying to refreshing subscriptions with requests:\n" + reqCountStr);
|
||||
if(rateLimitPlugins.any())
|
||||
throw RateLimitException(rateLimitPlugins.map { it.key.id });
|
||||
}
|
||||
_bypassRateLimit = false;
|
||||
val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(StateApp.instance.scope, withRefresh);
|
||||
|
||||
val currentExs = StateSubscriptions.instance.globalSubscriptionExceptions;
|
||||
@@ -183,7 +193,15 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
|
||||
return@TaskHandler resp;
|
||||
})
|
||||
.success { loadedResult(it); }
|
||||
.success {
|
||||
if(!Settings.instance.subscriptions.alwaysReloadFromCache)
|
||||
loadedResult(it);
|
||||
else {
|
||||
finishRefreshLayoutLoader();
|
||||
setLoading(false);
|
||||
loadCache();
|
||||
}
|
||||
} //TODO: Remove
|
||||
.exception<RateLimitException> {
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val subs = StateSubscriptions.instance.getSubscriptions();
|
||||
@@ -194,11 +212,10 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.showDialog(context, R.drawable.ic_security_pred,
|
||||
"Rate Limit Warning", "This is a temporary measure to prevent people from hitting rate limit until we have better support for lots of subscriptions." +
|
||||
"\n\nYou have too many subscriptions for the following plugins:\n",
|
||||
subsByLimited.map { "${it.first.config.name}: ${it.second.size} Subscriptions" } .joinToString("\n"), 0, UIDialogs.Action("Refresh Anyway", {
|
||||
context.getString(R.string.rate_limit_warning), context.getString(R.string.this_is_a_temporary_measure_to_prevent_people_from_hitting_rate_limit_until_we_have_better_support_for_lots_of_subscriptions) + context.getString(R.string.you_have_too_many_subscriptions_for_the_following_plugins),
|
||||
subsByLimited.map { it.first.config.name + ": " + it.second.size + " " + context.getString(R.string.subscriptions) } .joinToString("\n"), 0, UIDialogs.Action("Refresh Anyway", {
|
||||
_bypassRateLimit = true;
|
||||
loadResults();
|
||||
loadResults(true);
|
||||
}, UIDialogs.ActionStyle.DANGEROUS_TEXT),
|
||||
UIDialogs.Action("OK", {
|
||||
finishRefreshLayoutLoader();
|
||||
@@ -210,7 +227,7 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
.exception<Throwable> {
|
||||
Logger.w(ChannelFragment.TAG, "Failed to load channel.", it);
|
||||
if(it !is CancellationException)
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults(true) });
|
||||
else {
|
||||
finishRefreshLayoutLoader();
|
||||
setLoading(false);
|
||||
@@ -225,10 +242,10 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
|
||||
synchronized(_filterLock) {
|
||||
_subscriptionBar?.setToggles(
|
||||
SubscriptionBar.Toggle("Videos", _filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), it); },
|
||||
SubscriptionBar.Toggle("Posts", _filterSettings.allowContentTypes.contains(ContentType.POST)) { toggleFilterContentType(ContentType.POST, it); },
|
||||
SubscriptionBar.Toggle("Live", _filterSettings.allowLive) { _filterSettings.allowLive = it; _filterSettings.save(); loadResults(false); },
|
||||
SubscriptionBar.Toggle("Planned", _filterSettings.allowPlanned) { _filterSettings.allowPlanned = it; _filterSettings.save(); loadResults(false); }
|
||||
SubscriptionBar.Toggle(context.getString(R.string.videos), _filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), it); },
|
||||
SubscriptionBar.Toggle(context.getString(R.string.posts), _filterSettings.allowContentTypes.contains(ContentType.POST)) { toggleFilterContentType(ContentType.POST, it); },
|
||||
SubscriptionBar.Toggle(context.getString(R.string.live), _filterSettings.allowLive) { _filterSettings.allowLive = it; _filterSettings.save(); loadResults(false); },
|
||||
SubscriptionBar.Toggle(context.getString(R.string.planned), _filterSettings.allowPlanned) { _filterSettings.allowPlanned = it; _filterSettings.save(); loadResults(false); }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -247,7 +264,10 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
else null;
|
||||
_filterSettings.save();
|
||||
};
|
||||
loadResults(false)
|
||||
if(Settings.instance.subscriptions.fetchOnTabOpen) //TODO: Do this different, temporary workaround
|
||||
loadResults(false);
|
||||
else
|
||||
loadCache();
|
||||
}
|
||||
|
||||
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
|
||||
@@ -275,25 +295,39 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
loadResults(true);
|
||||
}
|
||||
|
||||
|
||||
private fun loadCache() {
|
||||
Logger.i(TAG, "Subscriptions load cache");
|
||||
val cachePager = ChannelContentCache.instance.getSubscriptionCachePager();
|
||||
val results = cachePager.getResults();
|
||||
Logger.i(TAG, "Subscriptions show cache (${results.size})");
|
||||
setTextCentered(if (results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
|
||||
setPager(cachePager);
|
||||
}
|
||||
private fun loadResults(withRefetch: Boolean = false) {
|
||||
setLoading(true);
|
||||
Logger.i(TAG, "Subscriptions load");
|
||||
if(recyclerData.results.size == 0) {
|
||||
val cachePager = ChannelContentCache.instance.getSubscriptionCachePager();
|
||||
Logger.i(TAG, "Subscription show cache (${cachePager.getResults().size})");
|
||||
setPager(cachePager);
|
||||
loadCache();
|
||||
} else {
|
||||
setTextCentered(null);
|
||||
}
|
||||
_taskGetPager.run(withRefetch);
|
||||
}
|
||||
|
||||
override fun onRestoreCachedData(cachedData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>) {
|
||||
super.onRestoreCachedData(cachedData);
|
||||
setTextCentered(if (cachedData.results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
|
||||
}
|
||||
private fun loadedResult(pager: IPager<IPlatformContent>) {
|
||||
Logger.i(TAG, "Subscriptions new pager loaded");
|
||||
Logger.i(TAG, "Subscriptions new pager loaded (${pager.getResults().size})");
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
finishRefreshLayoutLoader();
|
||||
setLoading(false);
|
||||
setPager(pager);
|
||||
setTextCentered(if (pager.getResults().isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to finish loading", e)
|
||||
}
|
||||
@@ -320,7 +354,7 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
if (toShow is PluginException)
|
||||
UIDialogs.toast(
|
||||
it,
|
||||
"Plugin [${toShow.config.name}] (${channel}) failed:\n${toShow.message}"
|
||||
context.getString(R.string.plugin_pluginname_failed_message).replace("{pluginName}", toShow.config.name).replace("{message}", toShow.message ?: "")
|
||||
);
|
||||
else
|
||||
UIDialogs.toast(it, ex.message ?: "");
|
||||
@@ -334,7 +368,7 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
.map { it!! }
|
||||
.toList();
|
||||
for(distinctPluginFail in failedPlugins)
|
||||
UIDialogs.toast(it, "Plugin [${distinctPluginFail.config.name}] failed:\n${distinctPluginFail.message}");
|
||||
UIDialogs.toast(it, context.getString(R.string.plugin_pluginname_failed_message).replace("{pluginName}", distinctPluginFail.config.name).replace("{message}", distinctPluginFail.message ?: ""));
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to handle exceptions", e)
|
||||
|
||||
+4
-1
@@ -117,7 +117,10 @@ class SuggestionsFragment : MainFragment {
|
||||
} else if (_searchType == SearchType.PLAYLIST) {
|
||||
navigate<PlaylistSearchResultsFragment>(it);
|
||||
} else {
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));
|
||||
if(it.isHttpUrl())
|
||||
navigate<VideoDetailFragment>(it);
|
||||
else
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+211
-77
@@ -12,7 +12,6 @@ import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.Icon
|
||||
import android.net.Uri
|
||||
import android.provider.Browser
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.text.Spanned
|
||||
import android.util.AttributeSet
|
||||
@@ -23,7 +22,6 @@ import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.view.WindowManager
|
||||
import android.widget.*
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
@@ -38,6 +36,9 @@ import com.futo.platformplayer.api.media.LiveChatManager
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
|
||||
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
|
||||
import com.futo.platformplayer.api.media.models.chapters.ChapterType
|
||||
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
@@ -50,6 +51,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
@@ -62,13 +64,16 @@ import com.futo.platformplayer.engine.exceptions.ScriptAgeException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.receivers.MediaControlReceiver
|
||||
import com.futo.platformplayer.states.*
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringArrayStorage
|
||||
import com.futo.platformplayer.views.MonetizationView
|
||||
import com.futo.platformplayer.views.behavior.TouchInterceptFrameLayout
|
||||
import com.futo.platformplayer.views.casting.CastView
|
||||
import com.futo.platformplayer.views.comments.AddCommentView
|
||||
@@ -78,6 +83,7 @@ import com.futo.platformplayer.views.overlays.DescriptionOverlay
|
||||
import com.futo.platformplayer.views.overlays.LiveChatOverlay
|
||||
import com.futo.platformplayer.views.overlays.QueueEditorOverlay
|
||||
import com.futo.platformplayer.views.overlays.RepliesOverlay
|
||||
import com.futo.platformplayer.views.overlays.SupportOverlay
|
||||
import com.futo.platformplayer.views.overlays.slideup.*
|
||||
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
|
||||
import com.futo.platformplayer.views.pills.RoundButton
|
||||
@@ -94,7 +100,6 @@ import com.google.android.exoplayer2.Format
|
||||
import com.google.android.exoplayer2.ui.PlayerControlView
|
||||
import com.google.android.exoplayer2.ui.TimeBar
|
||||
import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException
|
||||
import com.google.common.base.Stopwatch
|
||||
import com.google.protobuf.ByteString
|
||||
import kotlinx.coroutines.*
|
||||
import userpackage.Protocol
|
||||
@@ -172,6 +177,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
private val _addCommentView: AddCommentView;
|
||||
private val _toggleCommentType: Toggle;
|
||||
|
||||
private val _layoutSkip: LinearLayout;
|
||||
private val _textSkip: TextView;
|
||||
private val _textResume: TextView;
|
||||
private val _layoutResume: LinearLayout;
|
||||
private var _jobHideResume: Job? = null;
|
||||
@@ -189,6 +196,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
private val _container_content_replies: RepliesOverlay;
|
||||
private val _container_content_description: DescriptionOverlay;
|
||||
private val _container_content_liveChat: LiveChatOverlay;
|
||||
private val _container_content_support: SupportOverlay;
|
||||
|
||||
private var _container_content_current: View;
|
||||
|
||||
@@ -198,9 +206,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
private val _imageDislikeIcon: ImageView;
|
||||
private val _imageLikeIcon: ImageView;
|
||||
|
||||
private val _buttonSupport: LinearLayout;
|
||||
private val _buttonStore: LinearLayout;
|
||||
private val _layoutMonetization: LinearLayout;
|
||||
private val _monetization: MonetizationView;
|
||||
|
||||
private val _buttonMore: RoundButton;
|
||||
|
||||
@@ -290,11 +296,14 @@ class VideoDetailView : ConstraintLayout {
|
||||
_container_content_replies = findViewById(R.id.videodetail_container_replies);
|
||||
_container_content_description = findViewById(R.id.videodetail_container_description);
|
||||
_container_content_liveChat = findViewById(R.id.videodetail_container_livechat);
|
||||
_container_content_support = findViewById(R.id.videodetail_container_support)
|
||||
|
||||
_textComments = findViewById(R.id.text_comments);
|
||||
_addCommentView = findViewById(R.id.add_comment_view);
|
||||
_commentsList = findViewById(R.id.comments_list);
|
||||
|
||||
_layoutSkip = findViewById(R.id.layout_skip);
|
||||
_textSkip = findViewById(R.id.text_skip);
|
||||
_layoutResume = findViewById(R.id.layout_resume);
|
||||
_textResume = findViewById(R.id.text_resume);
|
||||
_layoutPlayerContainer = findViewById(R.id.layout_player_container);
|
||||
@@ -306,28 +315,25 @@ class VideoDetailView : ConstraintLayout {
|
||||
_imageLikeIcon = findViewById(R.id.image_like_icon);
|
||||
_imageDislikeIcon = findViewById(R.id.image_dislike_icon);
|
||||
|
||||
_buttonSupport = findViewById(R.id.button_support);
|
||||
_buttonStore = findViewById(R.id.button_store);
|
||||
_layoutMonetization = findViewById(R.id.layout_monetization);
|
||||
|
||||
_layoutMonetization.visibility = View.GONE;
|
||||
_monetization = findViewById(R.id.monetization);
|
||||
_player.attachPlayer();
|
||||
|
||||
|
||||
_buttonSubscribe.onSubscribed.subscribe {
|
||||
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
|
||||
};
|
||||
|
||||
_container_content_liveChat.onRaidNow.subscribe {
|
||||
StatePlayer.instance.clearQueue();
|
||||
fragment.navigate<VideoDetailFragment>(it.targetUrl);
|
||||
};
|
||||
|
||||
_buttonSupport.setOnClickListener {
|
||||
val author = video?.author ?: _searchVideo?.author;
|
||||
author?.let { fragment.navigate<ChannelFragment>(it).selectTab(2); };
|
||||
fragment.lifecycleScope.launch {
|
||||
delay(100);
|
||||
fragment.minimizeVideoDetail();
|
||||
};
|
||||
_monetization.onSupportTap.subscribe {
|
||||
_container_content_support.setPolycentricProfile(_polycentricProfile?.profile, false);
|
||||
switchContentView(_container_content_support);
|
||||
};
|
||||
|
||||
_buttonStore.setOnClickListener {
|
||||
_monetization.onStoreTap.subscribe {
|
||||
_polycentricProfile?.profile?.systemState?.store?.let {
|
||||
try {
|
||||
val uri = Uri.parse(it);
|
||||
@@ -340,6 +346,13 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
};
|
||||
|
||||
_player.attachPlayer();
|
||||
|
||||
_container_content_liveChat.onRaidNow.subscribe {
|
||||
StatePlayer.instance.clearQueue();
|
||||
fragment.navigate<VideoDetailFragment>(it.targetUrl);
|
||||
};
|
||||
|
||||
StateApp.instance.preventPictureInPicture.subscribe(this) {
|
||||
Logger.i(TAG, "StateApp.instance.preventPictureInPicture.subscribe preventPictureInPicture = true");
|
||||
preventPictureInPicture = true;
|
||||
@@ -397,6 +410,21 @@ class VideoDetailView : ConstraintLayout {
|
||||
_cast.onSettingsClick.subscribe { showVideoSettings() };
|
||||
_player.onVideoSettings.subscribe { showVideoSettings() };
|
||||
_player.onToggleFullScreen.subscribe(::handleFullScreen);
|
||||
_player.onChapterChanged.subscribe { chapter, isScrub ->
|
||||
if(_layoutSkip.visibility == VISIBLE && chapter?.type != ChapterType.SKIPPABLE)
|
||||
_layoutSkip.visibility = GONE;
|
||||
|
||||
if(!isScrub) {
|
||||
if(chapter?.type == ChapterType.SKIPPABLE) {
|
||||
_layoutSkip.visibility = VISIBLE;
|
||||
}
|
||||
else if(chapter?.type == ChapterType.SKIP) {
|
||||
_player.seekTo(chapter.timeEnd.toLong() * 1000);
|
||||
UIDialogs.toast(context, "Skipped chapter [${chapter.name}]", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_cast.onMinimizeClick.subscribe {
|
||||
_player.setFullScreen(false);
|
||||
onMinimize.emit();
|
||||
@@ -410,6 +438,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
if (!_isCasting && !_didStop) {
|
||||
setLastPositionMilliseconds(position, true);
|
||||
}
|
||||
updatePlaybackTracking(position);
|
||||
};
|
||||
|
||||
_player.onVideoClicked.subscribe {
|
||||
@@ -469,8 +498,14 @@ class VideoDetailView : ConstraintLayout {
|
||||
updatePillButtonVisibilities();
|
||||
|
||||
StateCasting.instance.onActiveDevicePlayChanged.subscribe(this) {
|
||||
if (StateCasting.instance.activeDevice != null) {
|
||||
val activeDevice = StateCasting.instance.activeDevice;
|
||||
if (activeDevice != null) {
|
||||
handlePlayChanged(it);
|
||||
|
||||
val v = video;
|
||||
if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) {
|
||||
nextVideo();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -514,6 +549,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_container_content_liveChat.onClose.subscribe { switchContentView(_container_content_main); };
|
||||
_container_content_queue.onClose.subscribe { switchContentView(_container_content_main); };
|
||||
_container_content_replies.onClose.subscribe { switchContentView(_container_content_main); };
|
||||
_container_content_support.onClose.subscribe { switchContentView(_container_content_main); };
|
||||
|
||||
_description_viewMore.setOnClickListener {
|
||||
switchContentView(_container_content_description);
|
||||
@@ -540,7 +576,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
val replyCount = c.replyCount ?: 0;
|
||||
var metadata = "";
|
||||
if (replyCount > 0) {
|
||||
metadata += "$replyCount replies";
|
||||
metadata += "$replyCount " + context.getString(R.string.replies);
|
||||
}
|
||||
|
||||
if (c is PolycentricPlatformComment) {
|
||||
@@ -575,16 +611,78 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_layoutResume.visibility = View.GONE;
|
||||
};
|
||||
|
||||
_layoutSkip.setOnClickListener {
|
||||
val currentChapter = _player.getCurrentChapter(_player.position);
|
||||
if(currentChapter?.type == ChapterType.SKIPPABLE) {
|
||||
_player.seekTo(currentChapter.timeEnd.toLong() * 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val _trackingUpdateTimeLock = Object();
|
||||
val _trackingUpdateInterval = 2500;
|
||||
var _trackingLastUpdateTime = System.currentTimeMillis();
|
||||
var _trackingLastPosition: Long = 0;
|
||||
var _trackingLastVideo: IPlatformVideoDetails? = null;
|
||||
var _trackingTotalWatched: Long = 0;
|
||||
var _trackingDidCountView: Boolean = false;
|
||||
var _trackingLastVideoSubscription: Subscription? = null;
|
||||
fun updatePlaybackTracking(position: Long) {
|
||||
if(!Settings.instance.subscriptions.allowPlaytimeTracking)
|
||||
return;
|
||||
val now = System.currentTimeMillis();
|
||||
val shouldUpdate = synchronized(_trackingUpdateTimeLock) {
|
||||
val doUpdate = (now - _trackingLastUpdateTime) > _trackingUpdateInterval;
|
||||
if(doUpdate)
|
||||
_trackingLastUpdateTime = now;
|
||||
return@synchronized doUpdate;
|
||||
}
|
||||
if(shouldUpdate) {
|
||||
val currentVideo = video;
|
||||
val delta = position - _trackingLastPosition;
|
||||
_trackingLastPosition = position;
|
||||
|
||||
if(currentVideo != null && currentVideo == _trackingLastVideo) {
|
||||
if(delta > 500 && delta < _trackingUpdateInterval * 1.5) {
|
||||
_trackingLastVideoSubscription?.let {
|
||||
Logger.i(TAG, "Subscription [${it.channel.name}] watch time delta [${delta}]" +
|
||||
"(${"%.2f".format((_trackingTotalWatched / 1000) / currentVideo.duration.toDouble().coerceAtLeast(1.0))})");
|
||||
it.updatePlayback(currentVideo, (delta / 1000).toInt());
|
||||
_trackingTotalWatched += delta;
|
||||
if(!_trackingDidCountView && currentVideo.duration > 0) {
|
||||
val percentage = (_trackingTotalWatched / 1000) / currentVideo.duration.toDouble();
|
||||
if(percentage > 0.4) {
|
||||
Logger.i(TAG, "Subscription [${it.channel.name}] new view");
|
||||
_trackingDidCountView = true;
|
||||
it.addPlaybackView();
|
||||
}
|
||||
}
|
||||
it.saveAsync();
|
||||
};
|
||||
}
|
||||
}
|
||||
else {
|
||||
if(_trackingLastVideo == null && currentVideo == null)
|
||||
return;
|
||||
_trackingLastVideo = currentVideo;
|
||||
_trackingTotalWatched = 0;
|
||||
if(currentVideo?.author?.url != null)
|
||||
_trackingLastVideoSubscription = StateSubscriptions.instance.getSubscription(currentVideo.author.url);
|
||||
else
|
||||
_trackingLastVideoSubscription = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMoreButtons() {
|
||||
val buttons = listOf(RoundButton(context, R.drawable.ic_add, "Add", TAG_ADD) {
|
||||
val buttons = listOf(RoundButton(context, R.drawable.ic_add, context.getString(R.string.add), TAG_ADD) {
|
||||
(video ?: _searchVideo)?.let {
|
||||
_slideUpOverlay = UISlideOverlays.showAddToOverlay(it, _overlayContainer);
|
||||
}
|
||||
},
|
||||
if(video?.isLive ?: false)
|
||||
RoundButton(context, R.drawable.ic_chat, "Live Chat", TAG_LIVECHAT) {
|
||||
RoundButton(context, R.drawable.ic_chat, context.getString(R.string.live_chat), TAG_LIVECHAT) {
|
||||
video?.let {
|
||||
try {
|
||||
loadLiveChat(it);
|
||||
@@ -594,7 +692,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
}
|
||||
} else null,
|
||||
RoundButton(context, R.drawable.ic_screen_share, "Background", TAG_BACKGROUND) {
|
||||
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.background), TAG_BACKGROUND) {
|
||||
if(!allowBackground) {
|
||||
_player.switchToAudioMode();
|
||||
allowBackground = true;
|
||||
@@ -606,31 +704,31 @@ class VideoDetailView : ConstraintLayout {
|
||||
it.text.text = resources.getString(R.string.background);
|
||||
}
|
||||
},
|
||||
RoundButton(context, R.drawable.ic_download, "Download", TAG_DOWNLOAD) {
|
||||
RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) {
|
||||
video?.let {
|
||||
_slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver);
|
||||
};
|
||||
},
|
||||
RoundButton(context, R.drawable.ic_share, "Share", TAG_SHARE) {
|
||||
RoundButton(context, R.drawable.ic_share, context.getString(R.string.share), TAG_SHARE) {
|
||||
video?.let {
|
||||
Logger.i(TAG, "Share preventPictureInPicture = true");
|
||||
preventPictureInPicture = true;
|
||||
shareVideo();
|
||||
};
|
||||
},
|
||||
RoundButton(context, R.drawable.ic_screen_share, "Overlay", TAG_OVERLAY) {
|
||||
RoundButton(context, R.drawable.ic_screen_share, context.getString(R.string.overlay), TAG_OVERLAY) {
|
||||
this.startPictureInPicture();
|
||||
fragment.forcePictureInPicture();
|
||||
//PiPActivity.startPiP(context);
|
||||
},
|
||||
RoundButton(context, R.drawable.ic_export, "Page", TAG_OPEN) {
|
||||
RoundButton(context, R.drawable.ic_export, context.getString(R.string.page), TAG_OPEN) {
|
||||
video?.let {
|
||||
val url = video?.shareUrl ?: _searchVideo?.shareUrl ?: _url;
|
||||
fragment.navigate<BrowserFragment>(url);
|
||||
fragment.minimizeVideoDetail();
|
||||
};
|
||||
},
|
||||
RoundButton(context, R.drawable.ic_refresh, "Reload", "Reload") {
|
||||
RoundButton(context, R.drawable.ic_refresh, context.getString(R.string.reload), "Reload") {
|
||||
reloadVideo();
|
||||
}).filterNotNull();
|
||||
if(!_buttonPinStore.getAllValues().any())
|
||||
@@ -719,7 +817,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
when (Settings.instance.playback.backgroundPlay) {
|
||||
0 -> handlePause();
|
||||
1 -> {
|
||||
if(!(video?.isLive ?: false))
|
||||
if(!(video?.isLive ?: false) && Settings.instance.playback.backgroundSwitchToAudio)
|
||||
_player.switchToAudioMode();
|
||||
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
||||
}
|
||||
@@ -754,6 +852,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_container_content_replies.cleanup();
|
||||
_container_content_queue.cleanup();
|
||||
_container_content_description.cleanup();
|
||||
_container_content_support.cleanup();
|
||||
StateCasting.instance.onActiveDevicePlayChanged.remove(this);
|
||||
StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
|
||||
@@ -856,7 +955,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
val subTitleSegments : ArrayList<String> = ArrayList();
|
||||
if(video.viewCount > 0)
|
||||
subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if(video.isLive) "watching now" else "views"}");
|
||||
subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if(video.isLive) context.getString(R.string.watching_now) else context.getString(R.string.views)}");
|
||||
if(video.datetime != null) {
|
||||
val diff = video.datetime?.getNowDiffSeconds() ?: 0;
|
||||
val ago = video.datetime?.toHumanNowDiffString(true)
|
||||
@@ -870,10 +969,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
_commentsList.clear();
|
||||
_platform.setPlatformFromClientID(video.id.pluginId);
|
||||
_subTitle.text = subTitleSegments.joinToString(" • ");
|
||||
_channelName.text = video.author.name;
|
||||
_playWhenReady = true;
|
||||
if(video.author.subscribers != null) {
|
||||
_channelMeta.text = if((video.author.subscribers ?: 0) > 0) video.author.subscribers!!.toHumanNumber() + " subscribers" else "";
|
||||
_channelMeta.text = if((video.author.subscribers ?: 0) > 0) video.author.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers)else "";
|
||||
(_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_5 * -1).toInt(), 0, 0);
|
||||
} else {
|
||||
_channelMeta.text = "";
|
||||
@@ -897,6 +995,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
} else {
|
||||
setPolycentricProfile(null, animate = false);
|
||||
_taskLoadPolycentricProfile.run(video.author.id);
|
||||
_channelName.text = video.author.name;
|
||||
}
|
||||
|
||||
_player.clear();
|
||||
@@ -932,7 +1031,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
|
||||
if(videoDetail.datetime != null && videoDetail.datetime!! > OffsetDateTime.now())
|
||||
UIDialogs.toast(context, "Planned in ${videoDetail.datetime?.toHumanNowDiffString(true)}")
|
||||
UIDialogs.toast(context, context.getString(R.string.planned_in) + " ${videoDetail.datetime?.toHumanNowDiffString(true)}")
|
||||
|
||||
if (!videoDetail.isLive) {
|
||||
_player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed());
|
||||
@@ -948,6 +1047,19 @@ class VideoDetailView : ConstraintLayout {
|
||||
if(video is JSVideoDetails) {
|
||||
val me = this;
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
//TODO: Implement video.getContentChapters()
|
||||
val chapters = null ?: StatePlatform.instance.getContentChapters(video.url);
|
||||
_player.setChapters(chapters);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to get chapters", ex);
|
||||
_player.setChapters(null);
|
||||
|
||||
/*withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(context, "Failed to get chapters\n" + ex.message);
|
||||
}*/
|
||||
}
|
||||
try {
|
||||
val stopwatch = com.futo.platformplayer.debug.Stopwatch()
|
||||
var tracker = video.getPlaybackTracker()
|
||||
@@ -964,7 +1076,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.showGeneralErrorDialog(context, "Failed to get Playback Tracker", ex);
|
||||
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_get_playback_tracker), ex);
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -982,13 +1094,18 @@ class VideoDetailView : ConstraintLayout {
|
||||
_title.text = video.name;
|
||||
_channelName.text = video.author.name;
|
||||
if(video.author.subscribers != null) {
|
||||
_channelMeta.text = if((video.author.subscribers ?: 0) > 0) video.author.subscribers!!.toHumanNumber() + " subscribers" else "";
|
||||
_channelMeta.text = if((video.author.subscribers ?: 0) > 0) video.author.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers) else "";
|
||||
(_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_5 * -1).toInt(), 0, 0);
|
||||
} else {
|
||||
_channelMeta.text = "";
|
||||
(_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_2).toInt(), 0, 0);
|
||||
}
|
||||
|
||||
video.author.let {
|
||||
if(it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty())
|
||||
_monetization.setPlatformMembership(video.id.pluginId, it.membershipUrl);
|
||||
}
|
||||
|
||||
_minimize_title.text = video.name;
|
||||
_minimize_meta.text = video.author.name;
|
||||
|
||||
@@ -1007,7 +1124,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_platform.setPlatformFromClientID(video.id.pluginId);
|
||||
val subTitleSegments : ArrayList<String> = ArrayList();
|
||||
if(video.viewCount > 0)
|
||||
subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if(video.isLive) "watching now" else "views"}");
|
||||
subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if(video.isLive) context.getString(R.string.watching_now) else context.getString(R.string.views)}");
|
||||
if(video.datetime != null) {
|
||||
val diff = video.datetime?.getNowDiffSeconds() ?: 0;
|
||||
val ago = video.datetime?.toHumanNowDiffString(true)
|
||||
@@ -1053,7 +1170,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
args.processHandle.fullyBackfillServers();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers", e)
|
||||
}
|
||||
@@ -1166,7 +1285,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
livePager = StatePlatform.instance.getLiveEvents(video.url);
|
||||
} catch (ex: Throwable) {
|
||||
livePager = null;
|
||||
UIDialogs.toast("Exception retrieving live events:\n" + ex.message);
|
||||
UIDialogs.toast(context.getString(R.string.exception_retrieving_live_events) + "\n" + ex.message);
|
||||
Logger.e(TAG, "Failed to retrieve live chat events", ex);
|
||||
}
|
||||
try {
|
||||
@@ -1177,7 +1296,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
liveChatWindow = null;
|
||||
UIDialogs.toast("Exception retrieving live chat window:\n" + ex.message);
|
||||
UIDialogs.toast(context.getString(R.string.exception_retrieving_live_chat_window) + "\n" + ex.message);
|
||||
Logger.e(TAG, "Failed to retrieve live chat window", ex);
|
||||
}
|
||||
val liveChat = livePager?.let {
|
||||
@@ -1199,7 +1318,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to load live chat", ex);
|
||||
|
||||
UIDialogs.toast("Live chat failed to load\n" + ex.message);
|
||||
UIDialogs.toast(context.getString(R.string.live_chat_failed_to_load) + "\n" + ex.message);
|
||||
//_liveChat?.handleEvents(listOf(LiveEventComment("SYSTEM", null, "Failed to load live chat:\n" + ex.message, "#FF0000")))
|
||||
/*
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
@@ -1254,9 +1373,13 @@ class VideoDetailView : ConstraintLayout {
|
||||
_lastVideoSource = videoSource;
|
||||
_lastAudioSource = audioSource;
|
||||
}
|
||||
catch(ex: UnsupportedCastException) {
|
||||
Logger.e(TAG, "Failed to load cast media", ex);
|
||||
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.unsupported_cast_format), ex);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to load media", ex);
|
||||
UIDialogs.showGeneralErrorDialog(context, "Failed to load media", ex);
|
||||
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex);
|
||||
}
|
||||
}
|
||||
private fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long) {
|
||||
@@ -1274,7 +1397,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
Logger.i(TAG, "onSourceChanged(videoSource=$videoSource, audioSource=$audioSource, resume=$resume)")
|
||||
|
||||
if((videoSource == null || videoSource is LocalVideoSource) && (audioSource == null || audioSource is LocalAudioSource))
|
||||
UIDialogs.toast(context, "Offline Playback", false);
|
||||
UIDialogs.toast(context, context.getString(R.string.offline_playback), false);
|
||||
//If LiveStream, set to end
|
||||
if(videoSource is IDashManifestSource || videoSource is IHLSManifestSource) {
|
||||
if (video?.isLive == true) {
|
||||
@@ -1315,12 +1438,12 @@ class VideoDetailView : ConstraintLayout {
|
||||
_didTriggerDatasourceError = true;
|
||||
|
||||
UIDialogs.showDialog(context, R.drawable.ic_error_pred,
|
||||
"Media Error",
|
||||
"The media source encountered an unauthorized error.\nThis might be solved by a plugin reload.\nWould you like to reload?\n(Experimental)",
|
||||
context.getString(R.string.media_error),
|
||||
context.getString(R.string.the_media_source_encountered_an_unauthorized_error_this_might_be_solved_by_a_plugin_reload_would_you_like_to_reload_experimental),
|
||||
null,
|
||||
0,
|
||||
UIDialogs.Action("No", { _didTriggerDatasourceError = false }),
|
||||
UIDialogs.Action("Yes", {
|
||||
UIDialogs.Action(context.getString(R.string.no), { _didTriggerDatasourceError = false }),
|
||||
UIDialogs.Action(context.getString(R.string.yes), {
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
StatePlatform.instance.reloadClient(context, config.id);
|
||||
@@ -1418,8 +1541,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
?.filter { it.container == bestAudioContainer }
|
||||
?.toList() ?: listOf();
|
||||
|
||||
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, "Quality", null, true,
|
||||
if (!_isCasting) SlideUpMenuTitle(this.context).apply { setTitle("Playback Rate") } else null,
|
||||
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString(
|
||||
R.string.quality), null, true,
|
||||
if (!_isCasting) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate)) } else null,
|
||||
if (!_isCasting) SlideUpMenuButtonList(this.context).apply {
|
||||
setButtons(listOf("0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25"), _player.getPlaybackRate().toString());
|
||||
onClick.subscribe { v ->
|
||||
@@ -1433,7 +1557,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
} else null,
|
||||
|
||||
if(localVideoSources?.isNotEmpty() == true)
|
||||
SlideUpMenuGroup(this.context, "Offline Video", "video",
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.offline_video), "video",
|
||||
*localVideoSources.stream()
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, "${it.width}x${it.height}", it,
|
||||
@@ -1441,7 +1565,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}.toList().toTypedArray())
|
||||
else null,
|
||||
if(localAudioSource?.isNotEmpty() == true)
|
||||
SlideUpMenuGroup(this.context, "Offline Audio", "audio",
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.offline_audio), "audio",
|
||||
*localAudioSource.stream()
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
|
||||
@@ -1449,7 +1573,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}.toList().toTypedArray())
|
||||
else null,
|
||||
if(localSubtitleSources?.isNotEmpty() == true)
|
||||
SlideUpMenuGroup(this.context, "Offline Subtitles", "subtitles",
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.offline_subtitles), "subtitles",
|
||||
*localSubtitleSources
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", it,
|
||||
@@ -1457,7 +1581,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}.toList().toTypedArray())
|
||||
else null,
|
||||
if(liveStreamVideoFormats?.isEmpty() == false)
|
||||
SlideUpMenuGroup(this.context, "Stream Video", "video",
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video",
|
||||
*liveStreamVideoFormats.stream()
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, it?.label ?: it.containerMimeType ?: it.bitrate.toString(), "${it.width}x${it.height}", it,
|
||||
@@ -1465,7 +1589,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}.toList().toTypedArray())
|
||||
else null,
|
||||
if(liveStreamAudioFormats?.isEmpty() == false)
|
||||
SlideUpMenuGroup(this.context, "Stream Audio", "audio",
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio",
|
||||
*liveStreamAudioFormats.stream()
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_music, "${it?.label ?: it.containerMimeType} ${it.bitrate}", "", it,
|
||||
@@ -1474,7 +1598,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
else null,
|
||||
|
||||
if(bestVideoSources.isNotEmpty())
|
||||
SlideUpMenuGroup(this.context, "Video", "video",
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
|
||||
*bestVideoSources.stream()
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", it,
|
||||
@@ -1482,7 +1606,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}.toList().toTypedArray())
|
||||
else null,
|
||||
if(bestAudioSources.isNotEmpty())
|
||||
SlideUpMenuGroup(this.context, "Audio", "audio",
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
|
||||
*bestAudioSources.stream()
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
|
||||
@@ -1490,7 +1614,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}.toList().toTypedArray())
|
||||
else null,
|
||||
if(video?.subtitles?.isNotEmpty() ?: false && video != null)
|
||||
SlideUpMenuGroup(this.context, "Subtitles", "subtitles",
|
||||
SlideUpMenuGroup(this.context, context.getString(R.string.subtitles), "subtitles",
|
||||
*video.subtitles
|
||||
.map {
|
||||
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", it,
|
||||
@@ -1613,12 +1737,13 @@ class VideoDetailView : ConstraintLayout {
|
||||
private fun handleUnavailableVideo(msg: String? = null) {
|
||||
if (!nextVideo()) {
|
||||
if(video?.datetime == null || video?.datetime!! < OffsetDateTime.now().minusHours(1))
|
||||
UIDialogs.showDialog(context, R.drawable.ic_lock, "Unavailable video", msg ?: "This video is unavailable.", null, 0,
|
||||
UIDialogs.Action("Back", {
|
||||
UIDialogs.showDialog(context, R.drawable.ic_lock, context.getString(R.string.unavailable_video), msg ?: context.getString(R.string.this_video_is_unavailable), null, 0,
|
||||
UIDialogs.Action(context.getString(R.string.back), {
|
||||
this@VideoDetailView.onClose.emit();
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
} else {
|
||||
StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_UNAVAILABLE", "Unavailable video", "There was an unavailable video in your queue [${video?.name}] by [${video?.author?.name}].", AnnouncementType.SESSION)
|
||||
StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_UNAVAILABLE", context.getString(R.string.unavailable_video), context.getString(R.string.there_was_an_unavailable_video_in_your_queue_videoname_by_authorname).replace("{videoName}", video?.name ?: "").replace("{authorName}", video?.author?.name ?: ""), AnnouncementType.SESSION)
|
||||
}
|
||||
|
||||
video?.let { StatePlatform.instance.clearContentDetailCache(it.url) };
|
||||
@@ -1696,6 +1821,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_isCasting = isCasting;
|
||||
|
||||
if(isCasting) {
|
||||
setFullscreen(false);
|
||||
_player.stop();
|
||||
_player.hideControls(false);
|
||||
_cast.visibility = View.VISIBLE;
|
||||
@@ -1870,9 +1996,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
_player.getGlobalVisibleRect(r);
|
||||
r.right = r.right - _player.paddingEnd;
|
||||
val playpauseAction = if(_player.playing)
|
||||
RemoteAction(Icon.createWithResource(context, R.drawable.ic_pause_notif), "Pause", "Pauses the video", MediaControlReceiver.getPauseIntent(context, 5));
|
||||
RemoteAction(Icon.createWithResource(context, R.drawable.ic_pause_notif), context.getString(R.string.pause), context.getString(R.string.pauses_the_video), MediaControlReceiver.getPauseIntent(context, 5));
|
||||
else
|
||||
RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), "Play", "Resumes the video", MediaControlReceiver.getPlayIntent(context, 6));
|
||||
RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), context.getString(R.string.play), context.getString(R.string.resumes_the_video), MediaControlReceiver.getPlayIntent(context, 6));
|
||||
|
||||
return PictureInPictureParams.Builder()
|
||||
.setAspectRatio(Rational(videoSourceWidth, videoSourceHeight))
|
||||
@@ -1971,14 +2097,19 @@ class VideoDetailView : ConstraintLayout {
|
||||
private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
|
||||
_polycentricProfile = cachedPolycentricProfile;
|
||||
|
||||
if (cachedPolycentricProfile?.profile == null) {
|
||||
_layoutMonetization.visibility = View.GONE;
|
||||
_creatorThumbnail.setHarborAvailable(false, animate);
|
||||
return;
|
||||
val dp_35 = 35.dp(context.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 (avatar != null) {
|
||||
_creatorThumbnail.setThumbnail(avatar, animate);
|
||||
} else {
|
||||
_creatorThumbnail.setThumbnail(video?.author?.thumbnail, animate);
|
||||
_creatorThumbnail.setHarborAvailable(profile != null, animate);
|
||||
}
|
||||
|
||||
_layoutMonetization.visibility = View.VISIBLE;
|
||||
_creatorThumbnail.setHarborAvailable(true, animate);
|
||||
_monetization.setPolycentricProfile(cachedPolycentricProfile, animate);
|
||||
}
|
||||
|
||||
fun setProgressBarOverlayed(isOverlayed: Boolean?) {
|
||||
@@ -2051,7 +2182,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
} else {
|
||||
StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_NOSOURCES", "Video without source", "There was a in your queue [${video?.name}] by [${video?.author?.name}] without the required source being enabled, playback was skipped.", AnnouncementType.SESSION)
|
||||
StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_NOSOURCES", context.getString(R.string.video_without_source), context.getString(R.string.there_was_a_in_your_queue_videoname_by_authorname_without_the_required_source_being_enabled_playback_was_skipped).replace("{videoName}", video?.name ?: "").replace("{authorName}", video?.author?.name ?: ""), AnnouncementType.SESSION)
|
||||
}
|
||||
}
|
||||
.exception<ContentNotAvailableYetException> {
|
||||
@@ -2070,9 +2201,10 @@ class VideoDetailView : ConstraintLayout {
|
||||
Logger.w(TAG, "exception<ScriptImplementationException>", it)
|
||||
|
||||
if (!nextVideo()) {
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load video (ScriptImplementationException)", it, ::fetchVideo);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, ::fetchVideo);
|
||||
} else {
|
||||
StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_INVALIDVIDEO", "Invalid video", "There was an invalid video in your queue [${video?.name}] by [${video?.author?.name}], playback was skipped.", AnnouncementType.SESSION)
|
||||
StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_INVALIDVIDEO", context.getString(R.string.invalid_video), context.getString(
|
||||
R.string.there_was_an_invalid_video_in_your_queue_videoname_by_authorname_playback_was_skipped).replace("{videoName}", video?.name ?: "").replace("{authorName}", video?.author?.name ?: ""), AnnouncementType.SESSION)
|
||||
}
|
||||
}
|
||||
.exception<ScriptAgeException> {
|
||||
@@ -2087,7 +2219,9 @@ class VideoDetailView : ConstraintLayout {
|
||||
this@VideoDetailView.onClose.emit();
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
} else {
|
||||
StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_AGERESTRICT", "Age restricted video", "There was an age restricted video in your queue [${video?.name}] by [${video?.author?.name}], this video was not accessible and playback was skipped.", AnnouncementType.SESSION)
|
||||
StateAnnouncement.instance.registerAnnouncement(video?.id?.value + "_Q_AGERESTRICT", context.getString(R.string.age_restricted_video),
|
||||
context.getString(R.string.there_was_an_age_restricted_video_in_your_queue_videoname_by_authorname_this_video_was_not_accessible_and_playback_was_skipped).replace("{videoName}", video?.name ?: "").replace("{authorName}", video?.author?.name ?: ""),
|
||||
AnnouncementType.SESSION)
|
||||
}
|
||||
}
|
||||
.exception<ScriptUnavailableException> {
|
||||
@@ -2103,7 +2237,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_retryJob = null;
|
||||
_liveTryJob?.cancel();
|
||||
_liveTryJob = null;
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load video (ScriptException)", it, ::fetchVideo);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), it, ::fetchVideo);
|
||||
}
|
||||
}
|
||||
.exception<Throwable> {
|
||||
@@ -2115,7 +2249,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_retryJob = null;
|
||||
_liveTryJob?.cancel();
|
||||
_liveTryJob = null;
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load video", it, ::fetchVideo);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, ::fetchVideo);
|
||||
}
|
||||
} else TaskHandler(IPlatformVideoDetails::class.java, {fragment.lifecycleScope});
|
||||
|
||||
@@ -2156,7 +2290,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
val toWait = _liveStreamCheckInterval.toList().sortedBy { abs(diffSeconds - it.first) }.firstOrNull()?.second?.toLong() ?: return;
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main){
|
||||
UIDialogs.toast(context, "Not yet available, retrying in ${toWait}s");
|
||||
UIDialogs.toast(context, context.getString(R.string.not_yet_available_retrying_in_time_s).replace("{time}", toWait.toString()));
|
||||
}
|
||||
|
||||
_liveTryJob?.cancel();
|
||||
@@ -2170,7 +2304,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
if(videoDetail.datetime != null && videoDetail.live == null && !videoDetail.video.videoSources.any()) {
|
||||
if(videoDetail.datetime!! > OffsetDateTime.now())
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(context, "Planned in ${videoDetail.datetime?.toHumanNowDiffString(true)}");
|
||||
UIDialogs.toast(context, context.getString(R.string.planned_in) + " ${videoDetail.datetime?.toHumanNowDiffString(true)}");
|
||||
}
|
||||
startLiveTry(liveTryVideo);
|
||||
}
|
||||
@@ -2183,7 +2317,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to live try fetch video.", ex);
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(context, "Failed to retry for live stream");
|
||||
UIDialogs.toast(context, context.getString(R.string.failed_to_retry_for_live_stream));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-6
@@ -57,10 +57,8 @@ abstract class VideoListEditorView : LinearLayout {
|
||||
buttonPlayAll.setOnClickListener { onPlayAllClick(); };
|
||||
buttonShuffle.setOnClickListener { onShuffleClick(); };
|
||||
|
||||
if (canEdit())
|
||||
_buttonEdit.setOnClickListener { onEditClick(); };
|
||||
else
|
||||
_buttonEdit.visibility = View.GONE;
|
||||
_buttonEdit.setOnClickListener { onEditClick(); };
|
||||
setButtonDownloadVisible(canEdit());
|
||||
|
||||
videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged);
|
||||
videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved);
|
||||
@@ -93,7 +91,7 @@ abstract class VideoListEditorView : LinearLayout {
|
||||
}
|
||||
|
||||
protected fun setVideoCount(videoCount: Int = -1) {
|
||||
_textMetadata.text = if (videoCount == -1) "" else "${videoCount} videos";
|
||||
_textMetadata.text = if (videoCount == -1) "" else "${videoCount} " + context.getString(R.string.videos);
|
||||
}
|
||||
|
||||
protected fun setVideos(videos: List<IPlatformVideo>?, canEdit: Boolean) {
|
||||
@@ -107,7 +105,7 @@ abstract class VideoListEditorView : LinearLayout {
|
||||
.into(it);
|
||||
};
|
||||
} else {
|
||||
_textMetadata.text = "0 videos";
|
||||
_textMetadata.text = "0 " + context.getString(R.string.videos);
|
||||
if(_imagePlaylistThumbnail != null) {
|
||||
Glide.with(_imagePlaylistThumbnail)
|
||||
.load(R.drawable.placeholder_video_thumbnail)
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ class GeneralTopBarFragment : TopFragment() {
|
||||
val view = inflater.inflate(R.layout.fragment_overview_top_bar, container, false);
|
||||
|
||||
view.findViewById<ImageView>(R.id.app_icon).setOnClickListener {
|
||||
UIDialogs.toast("This app is in development. Please submit bug reports and understand that many features are incomplete.", true);
|
||||
UIDialogs.toast(getString(R.string.this_app_is_in_development_please_submit_bug_reports_and_understand_that_many_features_are_incomplete), true);
|
||||
};
|
||||
|
||||
val buttonSearch: ImageButton = view.findViewById(R.id.button_search);
|
||||
|
||||
+1
-1
@@ -194,7 +194,7 @@ class SearchTopBarFragment : TopFragment() {
|
||||
if (editSearch != null) {
|
||||
val text = editSearch.text.toString();
|
||||
if (text.length < 3) {
|
||||
UIDialogs.toast("Please use at least 3 characters");
|
||||
UIDialogs.toast(getString(R.string.please_use_at_least_3_characters));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.futo.platformplayer.images;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.bumptech.glide.Priority;
|
||||
@@ -61,9 +63,21 @@ public class PolycentricModelLoader implements ModelLoader<String, ByteBuffer> {
|
||||
_deferred.invokeOnCompletion(throwable -> {
|
||||
if (throwable != null) {
|
||||
callback.onLoadFailed(new Exception(throwable));
|
||||
return Unit.INSTANCE;
|
||||
}
|
||||
|
||||
Deferred<ByteBuffer> deferred = _deferred;
|
||||
if (deferred == null) {
|
||||
callback.onLoadFailed(new Exception("Deferred is null"));
|
||||
return Unit.INSTANCE;
|
||||
}
|
||||
|
||||
ByteBuffer completed = deferred.getCompleted();
|
||||
if (completed != null) {
|
||||
callback.onDataReady(completed);
|
||||
} else {
|
||||
callback.onLoadFailed(new Exception("Completed is null"));
|
||||
}
|
||||
final ByteBuffer completed = _deferred.getCompleted();
|
||||
callback.onDataReady(completed);
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,12 +64,11 @@ class Logging {
|
||||
|
||||
val client = OkHttpClient()
|
||||
val response: Response = client.newCall(request).execute()
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body?.string();
|
||||
return if (body != null) Json.decodeFromString<String>(body) else null;
|
||||
return if (response.isSuccessful) {
|
||||
response.body?.string();
|
||||
} else {
|
||||
Logger.e("Failed to submit log.") { "Failed to submit logs (${response.code}): ${response.body?.string()}" };
|
||||
return null;
|
||||
null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
package com.futo.platformplayer.models
|
||||
|
||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.getNowDiffDays
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
class Subscription {
|
||||
var channel: SerializedChannel;
|
||||
|
||||
//Settings
|
||||
var doNotifications: Boolean = false;
|
||||
var doFetchLive: Boolean = false;
|
||||
var doFetchStreams: Boolean = true;
|
||||
var doFetchVideos: Boolean = true;
|
||||
var doFetchPosts: Boolean = false;
|
||||
|
||||
//Last found content
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
|
||||
var lastVideo : OffsetDateTime = OffsetDateTime.MAX;
|
||||
@@ -32,24 +43,104 @@ class Subscription {
|
||||
|
||||
//Last video interval
|
||||
var uploadInterval : Int = 0;
|
||||
var uploadStreamInterval : Int = 0;
|
||||
var uploadPostInterval : Int = 0;
|
||||
|
||||
var playbackSeconds: Int = 0;
|
||||
var playbackViews: Int = 0;
|
||||
|
||||
|
||||
constructor(channel : SerializedChannel) {
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
fun shouldFetchStreams() = lastLiveStream.getNowDiffDays() < 7;
|
||||
fun shouldFetchLiveStreams() = lastLiveStream.getNowDiffDays() < 14;
|
||||
fun shouldFetchPosts() = lastPost.getNowDiffDays() < 2;
|
||||
fun shouldFetchVideos() = doFetchVideos &&
|
||||
(lastVideo.getNowDiffDays() < 30 || lastVideoUpdate.getNowDiffDays() >= 1) &&
|
||||
(lastVideo.getNowDiffDays() < 180 || lastVideoUpdate.getNowDiffDays() >= 3);
|
||||
fun shouldFetchStreams() = doFetchStreams && (lastLiveStream.getNowDiffDays() < 7);
|
||||
fun shouldFetchLiveStreams() = doFetchLive && (lastLiveStream.getNowDiffDays() < 14);
|
||||
fun shouldFetchPosts() = doFetchPosts && (lastPost.getNowDiffDays() < 5);
|
||||
|
||||
fun getClient() = StatePlatform.instance.getChannelClientOrNull(channel.url);
|
||||
|
||||
fun save() {
|
||||
StateSubscriptions.instance.saveSubscription(this);
|
||||
}
|
||||
fun saveAsync() {
|
||||
StateSubscriptions.instance.saveSubscription(this);
|
||||
}
|
||||
|
||||
fun updateChannel(channel: IPlatformChannel) {
|
||||
this.channel = SerializedChannel.fromChannel(channel);
|
||||
}
|
||||
|
||||
fun updateVideoStatus(allVideos: List<IPlatformContent>? = null, liveStreams: List<IPlatformContent>? = null) {
|
||||
fun updatePlayback(content: IPlatformContentDetails, seconds: Int) {
|
||||
playbackSeconds += seconds;
|
||||
}
|
||||
fun addPlaybackView() {
|
||||
playbackViews += 1;
|
||||
}
|
||||
|
||||
fun updateSubscriptionState(type: String, initialPage: List<IPlatformContent>) {
|
||||
val interval: Int;
|
||||
val mostRecent: OffsetDateTime?;
|
||||
if(!initialPage.isEmpty()) {
|
||||
val newestVideoDays = initialPage[0].datetime?.getNowDiffDays()?.toInt() ?: 0;
|
||||
val diffs = mutableListOf<Int>()
|
||||
for(i in (initialPage.size - 1) downTo 1) {
|
||||
val currentVideoDays = initialPage[i].datetime?.getNowDiffDays();
|
||||
val nextVideoDays = initialPage[i - 1].datetime?.getNowDiffDays();
|
||||
|
||||
if(currentVideoDays != null && nextVideoDays != null) {
|
||||
val diff = nextVideoDays - currentVideoDays;
|
||||
diffs.add(diff.toInt());
|
||||
}
|
||||
}
|
||||
val averageDiff = if(diffs.size > 0)
|
||||
newestVideoDays.coerceAtLeast(diffs.average().toInt())
|
||||
else
|
||||
newestVideoDays;
|
||||
interval = averageDiff.coerceAtLeast(1);
|
||||
mostRecent = initialPage[0].datetime;
|
||||
}
|
||||
else {
|
||||
interval = 5;
|
||||
mostRecent = null;
|
||||
Logger.i("Subscription", "Subscription [${channel.name}]:${type} no results found");
|
||||
}
|
||||
when(type) {
|
||||
ResultCapabilities.TYPE_VIDEOS -> {
|
||||
uploadInterval = interval;
|
||||
if(mostRecent != null)
|
||||
lastVideo = mostRecent;
|
||||
else if(lastVideo.year > 3000)
|
||||
lastVideo = OffsetDateTime.MIN;
|
||||
lastVideoUpdate = OffsetDateTime.now();
|
||||
}
|
||||
ResultCapabilities.TYPE_MIXED -> {
|
||||
uploadInterval = interval;
|
||||
if(mostRecent != null)
|
||||
lastVideo = mostRecent;
|
||||
else if(lastVideo.year > 3000)
|
||||
lastVideo = OffsetDateTime.MIN;
|
||||
lastVideoUpdate = OffsetDateTime.now();
|
||||
}
|
||||
ResultCapabilities.TYPE_STREAMS -> {
|
||||
uploadStreamInterval = interval;
|
||||
if(mostRecent != null)
|
||||
lastLiveStream = mostRecent;
|
||||
else if(lastLiveStream.year > 3000)
|
||||
lastLiveStream = OffsetDateTime.MIN;
|
||||
lastStreamUpdate = OffsetDateTime.now();
|
||||
}
|
||||
ResultCapabilities.TYPE_POSTS -> {
|
||||
uploadPostInterval = interval;
|
||||
if(mostRecent != null)
|
||||
lastPost = mostRecent;
|
||||
else if(lastPost.year > 3000)
|
||||
lastPost = OffsetDateTime.MIN;
|
||||
lastPostUpdate = OffsetDateTime.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,6 @@ data class Telemetry(
|
||||
val isUnstableBuild: Boolean,
|
||||
val brand: String,
|
||||
val manufacturer: String,
|
||||
val model: String
|
||||
val model: String,
|
||||
val sdkVersion: Int
|
||||
) { }
|
||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.others
|
||||
|
||||
import android.net.Uri
|
||||
import android.webkit.*
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.Serializer
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
@@ -43,6 +44,8 @@ class LoginWebViewClient : WebViewClient {
|
||||
private var urlFound = false;
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
if(BuildConfig.DEBUG)
|
||||
Logger.i(TAG, "Login Url Page: " + url);
|
||||
super.onPageFinished(view, url);
|
||||
onPageLoaded.emit(view, url);
|
||||
}
|
||||
@@ -56,11 +59,29 @@ class LoginWebViewClient : WebViewClient {
|
||||
return null;
|
||||
}
|
||||
|
||||
var completionUrlExcludeQuery = false
|
||||
var completionUrlToCheck = if(urlFound) null else _authConfig.completionUrl;
|
||||
if(completionUrlToCheck != null) {
|
||||
if(completionUrlToCheck.endsWith("?*")) {
|
||||
completionUrlToCheck = completionUrlToCheck.substring(0, completionUrlToCheck.length - 2);
|
||||
completionUrlExcludeQuery = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val domain = request.url.host;
|
||||
val domainLower = request.url.host?.lowercase();
|
||||
val urlString = request.url.toString();
|
||||
if(_authConfig.completionUrl == null)
|
||||
urlFound = true;
|
||||
else urlFound = urlFound || request.url == Uri.parse(_authConfig.completionUrl);
|
||||
else urlFound = urlFound || (
|
||||
if(completionUrlExcludeQuery)
|
||||
(if(urlString.contains("?"))
|
||||
urlString.substring(0, urlString.indexOf("?")) == completionUrlToCheck
|
||||
else urlString == completionUrlToCheck)
|
||||
else
|
||||
request.url == Uri.parse(_authConfig.completionUrl)
|
||||
);
|
||||
|
||||
//HEADERS
|
||||
if(domainLower != null) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
||||
import com.futo.platformplayer.getNowDiffSeconds
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.resolveChannelUrl
|
||||
import com.futo.platformplayer.resolveChannelUrls
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
||||
import com.futo.platformplayer.stores.CachedPolycentricProfileStorage
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
@@ -37,7 +38,13 @@ class PolycentricCache {
|
||||
ContentType.AVATAR.value,
|
||||
ContentType.USERNAME.value,
|
||||
ContentType.DESCRIPTION.value,
|
||||
ContentType.STORE.value
|
||||
ContentType.STORE.value,
|
||||
ContentType.SERVER.value,
|
||||
ContentType.STORE_DATA.value,
|
||||
ContentType.PROMOTION_BANNER.value,
|
||||
ContentType.PROMOTION.value,
|
||||
ContentType.MEMBERSHIP_URLS.value,
|
||||
ContentType.DONATION_DESTINATIONS.value
|
||||
)
|
||||
).eventsList.map { e -> SignedEvent.fromProto(e) };
|
||||
|
||||
@@ -88,8 +95,9 @@ class PolycentricCache {
|
||||
|
||||
if (result.profile != null) {
|
||||
for (claim in result.profile.ownedClaims) {
|
||||
val url = claim.claim.resolveChannelUrl() ?: continue;
|
||||
_profileUrlCache.map[url] = result;
|
||||
val urls = claim.claim.resolveChannelUrls();
|
||||
for (url in urls)
|
||||
_profileUrlCache.map[url] = result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioManager.OnAudioFocusChangeListener
|
||||
import android.media.MediaMetadata
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.SystemClock
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
@@ -278,7 +279,13 @@ class MediaPlaybackService : Service() {
|
||||
|
||||
Logger.i(TAG, "Updating notification bitmap=${if (bitmap != null) "yes" else "no."} channelId=${channel.id} icon=${icon} video=${video?.name ?: ""} playWhenReady=${playWhenReady} session.sessionToken=${session.sessionToken}");
|
||||
|
||||
startForeground(MEDIA_NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// For API 29 and above
|
||||
startForeground(MEDIA_NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
|
||||
} else {
|
||||
// For API 28 and below
|
||||
startForeground(MEDIA_NOTIF_ID, notif);
|
||||
}
|
||||
|
||||
_notif_last_bitmap = bitmap;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user